diff --git a/packages/backend/src/pipelines/recommendation.ts b/packages/backend/src/pipelines/recommendation.ts index bf49b55..cc876c8 100644 --- a/packages/backend/src/pipelines/recommendation.ts +++ b/packages/backend/src/pipelines/recommendation.ts @@ -187,7 +187,7 @@ export async function runPipeline( .set({ recommendations: curatorOutput, status: 'done', title: aiTitle }) .where(eq(recommendations.id, rec.id)); - sseWrite({ stage: 'complete', status: 'done' }); + sseWrite({ stage: 'complete', status: 'done', data: { title: aiTitle } }); log(rec.id, `Pipeline complete (total: ${Date.now() - startTime}ms)`); return curatorOutput; diff --git a/packages/frontend/src/hooks/useRecommendations.ts b/packages/frontend/src/hooks/useRecommendations.ts index 6acde5a..9204368 100644 --- a/packages/frontend/src/hooks/useRecommendations.ts +++ b/packages/frontend/src/hooks/useRecommendations.ts @@ -72,6 +72,10 @@ export function useRecommendations() { [], ); + const updateTitle = useCallback((id: string, title: string) => { + setList((prev) => prev.map((r) => (r.id === id ? { ...r, title } : r))); + }, []); + const deleteRec = useCallback(async (id: string) => { await deleteRecommendation(id); setList((prev) => prev.filter((r) => r.id !== id)); @@ -86,6 +90,7 @@ export function useRecommendations() { rerank, submitFeedback: handleSubmitFeedback, updateStatus, + updateTitle, refreshList, deleteRec, }; diff --git a/packages/frontend/src/pages/Recom.css b/packages/frontend/src/pages/Recom.css index 8e6d439..c5c1ac1 100644 --- a/packages/frontend/src/pages/Recom.css +++ b/packages/frontend/src/pages/Recom.css @@ -97,25 +97,44 @@ .rec-info-delete-row { padding-top: 4px; + align-items: center; } -.btn-danger { - background: none; - border: 1px solid rgba(239, 68, 68, 0.4); - color: #f87171; - border-radius: var(--radius); +/* Shared sizing for the two action buttons in the info panel */ +.btn-danger, +.rec-info-delete-row .btn-rerun { padding: 5px 14px; font-size: 12px; font-weight: 600; + border-radius: var(--radius); + border: 1px solid; + background: none; cursor: pointer; transition: background 0.15s, border-color 0.15s; } +.btn-danger { + border-color: rgba(239, 68, 68, 0.4); + color: #f87171; +} + .btn-danger:hover { background: rgba(239, 68, 68, 0.12); border-color: #f87171; } +/* Re-run button — same shape as Delete, neutral tone */ +.rec-info-delete-row .btn-rerun { + border-color: var(--border); + color: var(--text-muted); +} + +.rec-info-delete-row .btn-rerun:hover { + background: var(--bg-surface-2); + border-color: var(--text-dim); + color: var(--text); +} + .error-state { color: var(--text-muted); } diff --git a/packages/frontend/src/pages/Recom.tsx b/packages/frontend/src/pages/Recom.tsx index 0e0346b..ac912b8 100644 --- a/packages/frontend/src/pages/Recom.tsx +++ b/packages/frontend/src/pages/Recom.tsx @@ -25,24 +25,31 @@ const DEFAULT_STAGES: StageMap = { const STAGE_ORDER: (keyof StageMap)[] = ['interpreter', 'retrieval', 'ranking', 'curator']; export function Recom({ id }: RecomProps) { - const { list, feedback, submitFeedback, rerank, updateStatus, refreshList, createNew, deleteRec } = useRecommendationsContext(); + const { list, feedback, submitFeedback, rerank, updateStatus, updateTitle, refreshList, createNew, deleteRec } = useRecommendationsContext(); const [rec, setRec] = useState(null); const [stages, setStages] = useState(DEFAULT_STAGES); - const [sseUrl, setSseUrl] = useState(null); + // sseKey drives the SSE connection. null = inactive; a number = active. + // Using a timestamp nonce ensures the URL is always unique on (re)connect, + // so useSSE's useEffect always re-runs even if the base path hasn't changed. + const [sseKey, setSseKey] = useState(null); const [showModal, setShowModal] = useState(false); const [infoExpanded, setInfoExpanded] = useState(true); + // Derive the actual URL from the key; query param is ignored by the server + // but makes the string unique so React/Preact state always treats it as changed. + const sseUrl = sseKey !== null ? `/api/recommendations/${id}/stream?_k=${sseKey}` : null; + useEffect(() => { setRec(null); setStages(DEFAULT_STAGES); - setSseUrl(null); + setSseKey(null); getRecommendation(id) .then((data) => { setRec(data); if (data.status === 'running' || data.status === 'pending') { setStages(DEFAULT_STAGES); - setSseUrl(`/api/recommendations/${id}/stream`); + setSseKey(Date.now()); } }) .catch(() => route('/')); @@ -61,14 +68,24 @@ export function Recom({ id }: RecomProps) { } if (event.stage === 'complete' && event.status === 'done') { - setSseUrl(null); + const incoming = event.data as { title?: string } | undefined; + if (incoming?.title) { + updateTitle(id, incoming.title); + setRec((prev) => (prev ? { ...prev, title: incoming.title! } : prev)); + } + setSseKey(null); updateStatus(id, 'done'); - void getRecommendation(id).then(setRec); + void getRecommendation(id) + .then(setRec) + .catch(() => { + // Fetch failed after completion — poll once more via handleSSEClose logic + updateStatus(id, 'done'); + }); void refreshList(); } if (event.status === 'error') { - setSseUrl(null); + setSseKey(null); updateStatus(id, 'error'); setRec((prev) => (prev ? { ...prev, status: 'error' as const } : null)); const stageKey = event.stage as PipelineStage; @@ -77,23 +94,25 @@ export function Recom({ id }: RecomProps) { } } }, - [id, updateStatus, refreshList], + [id, updateStatus, updateTitle, refreshList], ); const handleSSEClose = useCallback(() => { void getRecommendation(id).then((data) => { if (data.status === 'done') { - setSseUrl(null); + setSseKey(null); setRec(data); setStages({ interpreter: 'done', retrieval: 'done', ranking: 'done', curator: 'done' }); updateStatus(id, 'done'); void refreshList(); } else if (data.status === 'error') { - setSseUrl(null); + setSseKey(null); setRec(data); updateStatus(id, 'error'); } else { - setSseUrl(`/api/recommendations/${id}/stream`); + // Pipeline still running — reconnect with a new unique key so that + // useSSE's useEffect always re-fires even if the base URL is the same. + setSseKey(Date.now()); } }); }, [id, updateStatus, refreshList]); @@ -103,14 +122,14 @@ export function Recom({ id }: RecomProps) { const handleRetry = async () => { await rerank(id); setStages(DEFAULT_STAGES); - setSseUrl(`/api/recommendations/${id}/stream`); + setSseKey(Date.now()); setRec((prev) => (prev ? { ...prev, status: 'pending' as const } : null)); }; const handleRerank = async () => { await rerank(id); setStages(DEFAULT_STAGES); - setSseUrl(`/api/recommendations/${id}/stream`); + setSseKey(Date.now()); setRec((prev) => (prev ? { ...prev, status: 'pending' as const } : null)); }; @@ -127,7 +146,7 @@ export function Recom({ id }: RecomProps) { route(`/recom/${newId}`); }; - const isRunning = rec?.status === 'running' || rec?.status === 'pending' || !!sseUrl; + const isRunning = rec?.status === 'running' || rec?.status === 'pending' || sseKey !== null; const feedbackMap = new Map(feedback.map((f) => [f.item_name, f])); return (