diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 2df58b93..8fbff97c 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -24,7 +24,7 @@ import { } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; -import { isUserLaunchedSession } from '@/shared/state/isUserLaunchedSession'; +import { deservesCanvasCard } from '@/shared/state/isUserLaunchedSession'; import { REVEAL_MIN_ZOOM } from '../../canvas/revealZoom'; import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice'; import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; @@ -326,7 +326,7 @@ export function useDashboardLifecycle({ useEffect(() => { if (!layoutInitialized) return; const dashboardSessionIds = Object.values(sessions) - .filter((s) => s.dashboard_id === dashboardId && isUserLaunchedSession(s)) + .filter((s) => s.dashboard_id === dashboardId && deservesCanvasCard(s)) .map((s) => s.id); const liveIds = dashboardSessionIds.sort().join(','); if (liveIds === prevSessionIdsRef.current) return; diff --git a/frontend/src/shared/state/deservesCanvasCard.test.ts b/frontend/src/shared/state/deservesCanvasCard.test.ts new file mode 100644 index 00000000..46cc1cd8 --- /dev/null +++ b/frontend/src/shared/state/deservesCanvasCard.test.ts @@ -0,0 +1,44 @@ +// Run: node --test frontend/src/shared/state/deservesCanvasCard.test.ts +// +// ENG-256. A workflow run was denied a canvas card, and the browser-docking path is gated on the +// parent card existing, so its browsers spawned as loose windows nothing owned and nothing tore +// down. ENG-248/249/250 were all symptoms of that one missing object. The rule now: a run gets a +// real card while it is running, and loses it when it stops, so a nightly workflow does not leave a +// card behind every night. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { deservesCanvasCard, isUserLaunchedSession } from './isUserLaunchedSession.ts'; + +const chat = { mode: 'agent', status: 'completed' }; +const run = (status: string) => ({ mode: 'agent', status, workflow_run_id: 'run-1' }); + +test('an ordinary chat always has a card, running or not', () => { + assert.equal(deservesCanvasCard(chat), true); + assert.equal(deservesCanvasCard({ mode: 'agent', status: 'running' }), true); +}); + +test('a workflow run has a card while it works', () => { + assert.equal(deservesCanvasCard(run('running')), true); + assert.equal(deservesCanvasCard(run('waiting_approval')), true); +}); + +test('a workflow run loses its card the moment it stops', () => { + for (const s of ['completed', 'failed', 'stopped', 'error', '']) { + assert.equal(deservesCanvasCard(run(s)), false, `status ${s || '(empty)'}`); + } + assert.equal(deservesCanvasCard({ mode: 'agent', workflow_run_id: 'run-1' }), false, 'no status at all'); +}); + +test('plumbing chats never get a card, however they look', () => { + for (const mode of ['browser-agent', 'invoked-agent', 'sub-agent']) { + assert.equal(deservesCanvasCard({ mode, status: 'running' }), false, mode); + } + assert.equal(deservesCanvasCard({ mode: 'agent', status: 'running', workflow_edit_id: 'e1' }), false); +}); + +test('notifications are still a separate question from cards', () => { + // A running workflow now earns a CARD but must not start earning notifications: nobody asked for + // a ping every time a scheduled job fires. + assert.equal(deservesCanvasCard(run('running')), true); + assert.equal(isUserLaunchedSession(run('running')), false); +}); diff --git a/frontend/src/shared/state/isUserLaunchedSession.ts b/frontend/src/shared/state/isUserLaunchedSession.ts index 20c595cf..3e1e6e70 100644 --- a/frontend/src/shared/state/isUserLaunchedSession.ts +++ b/frontend/src/shared/state/isUserLaunchedSession.ts @@ -8,7 +8,23 @@ export interface SessionOrigin { workflow_edit_id?: string | null; } -/** True for a chat the user started themselves, which is the only kind that earns a card or a notification. */ +/** A run that is still working, so its card is worth having on screen. */ +const LIVE_STATUSES: ReadonlySet = new Set(['running', 'waiting_approval']); + +/** True for a chat the user started themselves, which is the only kind that earns a notification. */ export function isUserLaunchedSession(session: SessionOrigin): boolean { return !session.workflow_run_id && !session.workflow_edit_id && !PLUMBING_MODES.has(session.mode); } + +/** True for a session that should have a card on the canvas right now. + * + * A workflow run earns one WHILE IT RUNS. Denying it a card is what made its browsers spawn as loose + * canvas windows that nothing owned and nothing tore down (ENG-248/249/250 were all symptoms of it): + * the dock path is gated on the parent card existing, so no card meant no owner. Give the run a card + * and the existing inline-dock plus despawn logic applies to it unchanged. The card goes when the run + * stops, or a nightly workflow would leave one behind every single night. + */ +export function deservesCanvasCard(session: SessionOrigin & { status?: string }): boolean { + if (isUserLaunchedSession(session)) return true; + return !!session.workflow_run_id && LIVE_STATUSES.has(session.status ?? ''); +}