From 3aa50b6aef7122ec319ffed9ae6fa4378f9b0dfd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 27 Aug 2026 18:19:00 -0700 Subject: [PATCH] [eric] canvas: stopping a workflow run keeps its card, because you stopped it to read it (ENG-420) --- .../shared/state/deservesCanvasCard.test.ts | 38 +++++++++++++++++++ .../src/shared/state/isUserLaunchedSession.ts | 18 ++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/frontend/src/shared/state/deservesCanvasCard.test.ts b/frontend/src/shared/state/deservesCanvasCard.test.ts index 46cc1cd8..03a9145b 100644 --- a/frontend/src/shared/state/deservesCanvasCard.test.ts +++ b/frontend/src/shared/state/deservesCanvasCard.test.ts @@ -42,3 +42,41 @@ test('notifications are still a separate question from cards', () => { assert.equal(deservesCanvasCard(run('running')), true); assert.equal(isUserLaunchedSession(run('running')), false); }); + +// ENG-420: stopping a workflow-backed agent closed its whole card. Haik, production 1.7.9: +// "Stop should mean stop, not close" -- you lose the transcript the moment you stop the run, which +// is the thing you stopped it to read. The despawn rule could not tell "the nightly run finished" +// from "a person pressed Stop", and it was written for the first one. +test('a run a HUMAN stopped keeps its card', () => { + assert.equal(deservesCanvasCard({ + mode: 'agent', workflow_run_id: 'r1', status: 'stopped', ended_by_user: true, + }), true); +}); + +test('a run that ended on its own still despawns', () => { + // The litter case the rule exists for: a nightly workflow must not leave a card behind every night. + for (const status of ['completed', 'stopped', 'error']) { + assert.equal(deservesCanvasCard({ mode: 'agent', workflow_run_id: 'r1', status }), false, status); + } +}); + +test('Close still dismisses it, even though Close also sets ended_by_user', () => { + // Both routes stamp ended_by_user; only Close stamps closed_at, which is what separates them. + assert.equal(deservesCanvasCard({ + mode: 'agent', workflow_run_id: 'r1', status: 'stopped', + ended_by_user: true, closed_at: '2026-08-28T00:00:00Z', + }), false); +}); + +test('a live run is unaffected either way', () => { + for (const status of ['running', 'waiting_approval']) { + assert.equal(deservesCanvasCard({ mode: 'agent', workflow_run_id: 'r1', status }), true, status); + assert.equal(deservesCanvasCard({ + mode: 'agent', workflow_run_id: 'r1', status, ended_by_user: true, + }), true, `${status} + stopped flag`); + } +}); + +test('a user-launched chat never depends on any of this', () => { + assert.equal(deservesCanvasCard({ mode: 'agent', status: 'stopped' }), true); +}); diff --git a/frontend/src/shared/state/isUserLaunchedSession.ts b/frontend/src/shared/state/isUserLaunchedSession.ts index bdb30e72..29394bac 100644 --- a/frontend/src/shared/state/isUserLaunchedSession.ts +++ b/frontend/src/shared/state/isUserLaunchedSession.ts @@ -6,6 +6,10 @@ export interface SessionOrigin { mode: string; workflow_run_id?: string | null; workflow_edit_id?: string | null; + // A human pressed Stop or Close. Set by those two routes only; a watchdog's stop never sets it. + ended_by_user?: boolean; + // Set by Close, never by Stop. It is what tells the two apart here. + closed_at?: string | null; } /** A run that is still working, so its card is worth having on screen. */ @@ -29,8 +33,18 @@ export function isUserLaunchedSession(session: SessionOrigin): boolean { * 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. + * + * EXCEPT when a human stopped it. "Ends on its own" and "a person hit Stop" are different events and + * this could not tell them apart, so pressing Stop on a workflow-backed agent made its card vanish + * along with the transcript the user stopped it to read (ENG-420). Someone stops a run to LOOK at + * it; the nightly-litter case it was written for is the one that ends by itself, which still + * despawns. Close is what dismisses a card, and Close is the only route that stamps `closed_at`. */ -export function deservesCanvasCard(session: SessionOrigin & { status?: string }): boolean { +export function deservesCanvasCard( + session: SessionOrigin & { status?: string }, +): boolean { if (isUserLaunchedSession(session)) return true; - return !!session.workflow_run_id && LIVE_STATUSES.has(session.status ?? ''); + if (!session.workflow_run_id) return false; + if (LIVE_STATUSES.has(session.status ?? '')) return true; + return !!session.ended_by_user && !session.closed_at; }