From d04732222734055d0f82bb1258eedfed0eddb0ab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:24:16 -0700 Subject: [PATCH] feat(native-chat): say terminal sessions kept running, and clear the quality gate The modal lists stopped chats with no way to tell that CLI agents are fine, and the true state of the world is counterintuitive: the terminal sessions survived the restart and the chats did not. One line now says so, next to the heading where it frames the list rather than as a footnote at the bottom. Wording follows the app's own vocabulary rather than inventing a term: the catalog settles on "terminal sessions" (terminalSessionCount, "Terminal sessions are grouped by workspace", "No terminal sessions yet"), and UpdateCard already reassures with "Your terminal sessions won't be interrupted during the update" in the same text-xs text-muted-foreground treatment. "kept running" rather than "were restored" -- nothing reconnected them, they never stopped, and the line says nothing about why. Also clears check:code-quality:changed, which I had not been running -- oxlint alone covers neither the design-system nor the casting audit, so 18 findings had accumulated across the branch. - design system (4): Button spacing hand-rolled as gap-1/px-2 is just size="xs"; PopoverContent and DialogTitle own their typography and spacing, so the text-xs moved to the popover's own children and the title's icon gap moved to a plain wrapper. - casting (14): production code loses its assertions outright via Reflect.get, the idiom already used in managed-hook-detection-commands and worktree-name-retirement. The marker validator reads each field through Reflect.get and now checks recordedAt is a number rather than asserting it; the store-file parse uses the existing `file` shape instead of a second assertion; the runner narrows the admission error's owner with typeof. Test fixtures keep their assertions behind the line-specific SAFETY: rationale the repo mandates for exactly this case. One trap worth recording: the audit reports an assertion at the line its EXPRESSION OPENS, not where `as` appears, so a disable-next-line above the closing brace of a multi-line literal is inert and silently changes nothing. Guards unchanged; ablation re-proved 14/14 at this head. --- ...red-agent-session-restart-resume-runner.ts | 5 +-- ...tured-agent-session-restart-resume.test.ts | 8 +++++ .../agent-session-record-store-file.ts | 6 ++-- .../NativeChatResumeOnRestartGroups.tsx | 8 +---- .../NativeChatResumeOnRestartModal.tsx | 32 +++++++++++++------ src/renderer/src/i18n/locales/en.json | 1 + src/shared/agent-session-resume-marker.ts | 16 ++++++---- 7 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 0d8d58a8804..84913c69a32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -96,12 +96,13 @@ async function resumeOne( }) } catch (error) { const reason = error instanceof Error ? error.message : String(error) - const live = (error as { owner?: string }).owner + const owner = + typeof error === 'object' && error !== null ? Reflect.get(error, 'owner') : undefined return { sessionId, outcome: 'refused', reason, - ...(live === undefined ? {} : { owner: live }) + ...(typeof owner === 'string' ? { owner } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 174368ccb15..de8c8a36071 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -43,6 +43,7 @@ function turnItem( } function record(overrides: { chain?: AgentSessionRecord['providerHandleChain'] } = {}) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a literal fixture standing in for a durable record; the code under test reads only lease, provider, location and providerHandleChain. return { schemaVersion: 2, sessionId: SESSION, @@ -110,6 +111,7 @@ function claudeRecord( leafUuid: string | null, providerSessionId = CLAUDE_PROVIDER_SESSION ): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the base fixture is already record-shaped; this only swaps the provider and its Claude handle chain. return { ...record(), provider: 'claude', @@ -127,6 +129,7 @@ function claudeRecord( } function journal(items: AgentJournalRenderItem[], isReadOnly = false) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only isReadOnly and snapshot(); a real AgentSessionJournal needs an on-disk SQLite store. return { isReadOnly, snapshot: () => ({ items, submissions: [] }) } as never } @@ -416,7 +419,9 @@ describe('the restart-resume surface', () => { ]) return { restartResume: createStructuredAgentSessionRestartResume( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the collaborator reads only getRecord and resumeMarkers from the store, and supportsCreate from the adapter. { store, adapter: { supportsCreate: () => true } } as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the live-session map is read for journal, hasProviderChild and fence only. sessions as never, { revealSession: async () => ({ readable: true }), @@ -429,6 +434,7 @@ describe('the restart-resume surface', () => { send: async ({ envelope, body }) => { sent.push({ sessionId: envelope.sessionId, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restartContinuationBody builds exactly one text block, which is what this assertion reads. text: (body.blocks[0] as { text: string }).text }) return { ok: true } @@ -513,6 +519,7 @@ describe('the restart-resume surface', () => { // predicate drops the session. Reporting "nothing happened" would leave the user pressing a dead // button for a session that IS running. it('reports a session the chat pane already re-acquired as resumed, not as nothing', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the base fixture is already record-shaped; only claimStatus is overridden, to model a lease the pane re-took. const liveRecord = { ...record(), lease: { ...record().lease, claimStatus: 'live' } @@ -537,6 +544,7 @@ describe('the restart-resume surface', () => { // Relaxing the lease clause must not relax the whole predicate. "Resume all" targets every // marker, so a held-but-ineligible session would otherwise be consumed and counted as resumed. it('refuses to settle an already-live session the predicate rejects', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the base fixture is already record-shaped; only claimStatus is overridden, to model a lease the pane re-took. const liveRecord = { ...record(), lease: { ...record().lease, claimStatus: 'live' } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 9d39871e109..19d218d1d2b 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -102,6 +102,7 @@ function parseState( if (typeof parsed !== 'object' || parsed === null) { return null } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is read back as `unknown` and validated below before use; adding `resumeMarkers` brought this long-standing assertion into the changed-code gate. const file = parsed as { schemaVersion?: unknown hostId?: unknown @@ -110,6 +111,7 @@ function parseState( retiredClaimKeys?: unknown unusableRecords?: unknown visibleSessionIds?: unknown + resumeMarkers?: unknown } if ( !Number.isSafeInteger(file.schemaVersion) || @@ -227,9 +229,7 @@ function parseState( } state.visibleSessionIdsIndexPresent = visibleSessionIds.present visibleSessionIds.ids.forEach((sessionId) => state.visibleSessionIds.add(sessionId)) - state.resumeMarkers = parseAgentSessionResumeMarkers( - (parsed as { resumeMarkers?: unknown }).resumeMarkers - ) + state.resumeMarkers = parseAgentSessionResumeMarkers(file.resumeMarkers) return { state, needsRewrite } } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index df25223c051..f33c1a730f5 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -82,13 +82,7 @@ function ResumeCandidateRow({ {formatShortTimeAgo(candidate.recordedAt, listedAt)} - diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 9eb21b3eb9d..0c57b48cfd8 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -94,20 +94,20 @@ function ContinuationExplainer(): React.JSX.Element { - -

+ +

{translate( 'auto.components.NativeChatResumeOnRestartModal.whatIsSentTitle', 'What Orca sends' )}

-

+

{translate( 'auto.components.NativeChatResumeOnRestartModal.whatIsSentBody', 'Continuing sends one short message to each agent, telling it that Orca restarted and asking it to check its last action before carrying on. Your own prompt is never re-sent.' )}

-
+
{AGENT_SESSION_RESTART_CONTINUATION_MESSAGE}
@@ -248,12 +248,15 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { point, so the list scrolls inside the dialog while the header and primary action stay. */} - - - {translate( - 'auto.components.NativeChatResumeOnRestartModal.title', - 'Reconnect interrupted chats?' - )} + + {/* Plain wrapper owns the icon spacing; DialogTitle owns its own. */} + + + {translate( + 'auto.components.NativeChatResumeOnRestartModal.title', + 'Reconnect interrupted chats?' + )} + {interruptedByUpdate @@ -266,6 +269,15 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { 'These chats were mid-turn when Orca closed. Reconnecting restores each one where it stopped, with its full context and without re-sending your prompt — the interrupted reply will not continue on its own.' )} + {/* The true state of things is counterintuitive — the terminal sessions survived and the + chats did not — so say so where it frames the list, not as a footnote. "kept running" + rather than "were restored": nothing reconnected them, they never stopped. */} +

+ {translate( + 'auto.components.NativeChatResumeOnRestartModal.terminalSessionsUnaffected', + 'Only chats are affected — your terminal sessions kept running and need nothing from you.' + )} +

+ const recordedAt = Reflect.get(value, 'recordedAt') + const trigger = Reflect.get(value, 'trigger') return ( - isMarkerField(marker.sessionId) && - isMarkerField(marker.turnId) && - isMarkerField(marker.providerHandleRoot) && - Number.isSafeInteger(marker.recordedAt) && - (marker.recordedAt as number) >= 0 && - (marker.trigger === 'quit' || marker.trigger === 'update') + isMarkerField(Reflect.get(value, 'sessionId')) && + isMarkerField(Reflect.get(value, 'turnId')) && + isMarkerField(Reflect.get(value, 'providerHandleRoot')) && + typeof recordedAt === 'number' && + Number.isSafeInteger(recordedAt) && + recordedAt >= 0 && + (trigger === 'quit' || trigger === 'update') ) }