From 9ddcfc4de3599bf365d675c4fa5a0647c1fe291f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:04:50 -0700 Subject: [PATCH 1/6] feat(native-chat): show each chat's resume as it happens, and start more at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming interrupted chats sent one request for the whole batch, and the host answered only after every agent had replied, so "Resuming 21 chats" stayed up until the slowest chat answered and the dialog could only say "Resuming…" for every row. The host also started 3 agents at a time. The desktop app now sends one request per chat, so each chat's result lands as soon as it has one. The host keeps one start limit across all of those requests, sized to the machine's cores instead of a fixed 3. The status bar reads "Resuming chats 9/21". Reopening the dialog mid-run shows a gray spinner where each resuming chat's checkbox was, with its step (waiting to start, starting, waiting for a reply) and time in the tooltip, then a check or the failure row when it settles. A line above the list counts where the chats stand, with filters for In progress, Resumed and Need you. --- ...ructured-agent-session-restart-cap.test.ts | 13 +- ...tured-agent-session-restart-resume-host.ts | 4 +- ...red-agent-session-restart-resume-runner.ts | 39 +- ...tured-agent-session-restart-resume.test.ts | 48 ++- .../NativeChatResumeOnRestartAgentRow.tsx | 44 ++- .../NativeChatResumeOnRestartGroups.tsx | 10 +- .../NativeChatResumeOnRestartModal.test.tsx | 373 ++++++++---------- .../NativeChatResumeOnRestartModal.tsx | 137 ++++--- .../components/NativeChatResumeRunPanel.tsx | 107 +++++ .../NativeChatResumeRunStatusIcon.tsx | 101 +++++ .../components/NativeChatResumeRunSummary.tsx | 126 ++++++ ...tive-chat-resume-failed-chat-watch.test.ts | 6 +- .../native-chat-resume-offered-chat-watch.ts | 106 +++++ ...at-resume-on-restart-outcome-toast.test.ts | 34 +- .../native-chat-resume-on-restart-store.ts | 270 ++++++------- .../components/native-chat-resume-run-view.ts | 96 +++++ .../components/native-chat-resume-run.test.ts | 146 +++++++ .../src/components/native-chat-resume-run.ts | 98 +++++ .../NativeChatResumeStatusSegment.tsx | 59 ++- .../components/use-resume-run-start-phases.ts | 36 ++ src/renderer/src/i18n/locales/en.json | 36 +- src/renderer/src/i18n/locales/es.json | 36 +- src/renderer/src/i18n/locales/fr.json | 36 +- src/renderer/src/i18n/locales/ja.json | 36 +- src/renderer/src/i18n/locales/ko.json | 36 +- src/renderer/src/i18n/locales/zh.json | 36 +- 26 files changed, 1522 insertions(+), 547 deletions(-) create mode 100644 src/renderer/src/components/NativeChatResumeRunPanel.tsx create mode 100644 src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx create mode 100644 src/renderer/src/components/NativeChatResumeRunSummary.tsx create mode 100644 src/renderer/src/components/native-chat-resume-offered-chat-watch.ts create mode 100644 src/renderer/src/components/native-chat-resume-run-view.ts create mode 100644 src/renderer/src/components/native-chat-resume-run.test.ts create mode 100644 src/renderer/src/components/native-chat-resume-run.ts create mode 100644 src/renderer/src/components/use-resume-run-start-phases.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index 155785ae35ef..9be794967369 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -1,4 +1,4 @@ -// "Resume all" starts agents a few at a time. A continuation is a send, so a chat counts as started +// Resumes start agents a few at a time host-wide. A continuation is a send, so a chat counts as started // once its agent took the message or its start failed — not when the message was accepted, which // is instant and would let every agent start at once. @@ -9,7 +9,6 @@ import { } from './structured-agent-session-restart-continuation' import { resumeStructuredAgentSessionsFromRestart, - STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { marker } from './structured-agent-session-restart-resume-test-harness' @@ -17,6 +16,8 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l type Handover = { dispatchState: string; reason?: string | null } | undefined +const LIMIT = 3 + it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => { const handovers = new Map>() let waiting = 0 @@ -42,7 +43,7 @@ it('holds each slot from accept until handover or rejection, and frees it when t const sessions = Array.from({ length: 8 }, (_, index) => `session-${index}`) const outcomes = resumeStructuredAgentSessionsFromRestart( { - admission: new StructuredAgentSessionResumeAdmission(), + admission: new StructuredAgentSessionResumeAdmission(LIMIT), consumeMarker: async () => true, resume: async (sessionId) => { const started = await startStructuredAgentSessionContinuation( @@ -65,10 +66,10 @@ it('holds each slot from accept until handover or rejection, and frees it when t handovers.get(sessionId)!.resolve(handover) } - await vi.waitFor(() => expect(waiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY)) + await vi.waitFor(() => expect(waiting).toBe(LIMIT)) // Accepted but not yet handed over: a slow start keeps its slot. await new Promise((resolve) => setTimeout(resolve, 20)) - expect(handovers.size).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(handovers.size).toBe(LIMIT) await settle('session-0', { dispatchState: 'pending' }) await settle('session-1', { dispatchState: 'rejected', reason: 'Codex could not start.' }) // The session closed, or too many waited: the wait proves nothing, and the slot is freed. @@ -78,7 +79,7 @@ it('holds each slot from accept until handover or rejection, and frees it when t } const results = await outcomes - expect(mostWaiting).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(mostWaiting).toBe(LIMIT) expect(results.find((result) => result.sessionId === 'session-1')).toMatchObject({ outcome: 'refused', reason: 'Codex could not start.' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 120aefb6f205..decbdf72b265 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -139,8 +139,8 @@ export function createStructuredAgentSessionRestartResume( stillResumable: (marker) => deriveAtSend([marker], 'may-be-held').candidates.length === 1 } - /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at - * a time. The runner counts a chat as done once `continueOne` returns. */ + /** One explicit action: reserve the offers, then continue each through `continueOne`, as many at + * once as the admission's host-wide start limit allows. A chat is done once `continueOne` returns. */ const run = async ( sessionIds: readonly string[] | undefined, owner: string, 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 a298e23f454e..242ab176ed5c 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 @@ -12,12 +12,13 @@ import { isAgentSessionRefusalError, type AgentSessionAnyRefusalDetails } from '../../../shared/agent-session-wire-refusals' -import { forEachWithConcurrency } from '../../../shared/map-with-concurrency' +import { availableParallelism } from 'node:os' +import { PrioritySemaphore } from '../../../shared/priority-semaphore' import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' -/** Providers are expensive to start and 20-30 marked chats is an ordinary morning. Resumes go out - * a few at a time so a launch cannot spawn every app-server at once. */ -export const STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY = 3 +/** Agents STARTING at once on this host, across every resume in flight. A start is a CPU-bound CLI + * boot, so one per core; once started, every chat runs at the same time anyway. */ +export const STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY = Math.max(1, availableParallelism()) export const STRUCTURED_AGENT_SESSION_RESUME_IN_PROGRESS = 'agent_session_resume_already_in_progress' @@ -54,14 +55,22 @@ function resumeAdmissionOwner(error: unknown): string | null { } /** - * One resume per session at a time, whoever is asking. + * One resume per session at a time, whoever is asking, and a bounded number starting host-wide. * * Two surfaces can reach for the same chat at once — the banner's "Resume all" and a user clicking * one row — and both would otherwise send the continuation twice, and leave the loser's * refusal looking like a real failure. The second caller is told who holds it instead. + * + * The start limit lives here rather than per request because a client resumes each chat with its + * own request, so each chat's answer arrives as soon as it has one. */ export class StructuredAgentSessionResumeAdmission { private readonly owners = new Map() + private readonly starts: PrioritySemaphore + + constructor(startLimit: number = STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) { + this.starts = new PrioritySemaphore(startLimit) + } liveOwner(sessionId: string): string | null { return this.owners.get(sessionId) ?? null @@ -72,9 +81,15 @@ export class StructuredAgentSessionResumeAdmission { if (live !== undefined) { throw new StructuredAgentSessionResumeInProgressError(live) } + // Owned while it waits for a slot, so a second caller is told it is already on its way. this.owners.set(sessionId, owner) try { - return await task() + const release = await this.starts.acquire(0) + try { + return await task() + } finally { + release() + } } finally { this.owners.delete(sessionId) } @@ -87,7 +102,6 @@ export type StructuredAgentSessionResumeRunnerDeps = { consumeMarker: (sessionId: string) => Promise /** Continues the reserved session; resolves once its agent took the message or refused it. */ resume: (sessionId: string) => Promise - concurrency?: number } export async function resumeStructuredAgentSessionsFromRestart( @@ -95,15 +109,8 @@ export async function resumeStructuredAgentSessionsFromRestart( candidates: readonly StructuredAgentSessionResumeCandidate[], owner: string ): Promise { - const outcomes: StructuredAgentSessionResumeOutcome[] = [] - await forEachWithConcurrency( - candidates, - deps.concurrency ?? STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY, - async (candidate) => { - outcomes.push(await resumeOne(deps, candidate.sessionId, owner)) - } - ) - return outcomes + // The admission's start limit staggers these, shared with every other resume on this host. + return Promise.all(candidates.map((candidate) => resumeOne(deps, candidate.sessionId, owner))) } async function resumeOne( 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 b16c7277fc10..02f3d54dc8b4 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 @@ -5,6 +5,7 @@ // finished; missing one is an annoyance. Each test removes exactly one input from an otherwise // resumable session, so deleting the matching guard turns that test red. +import { availableParallelism } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { structuredAgentSessionResumableSet } from './structured-agent-session-restart-resume-set' import { @@ -558,14 +559,19 @@ describe('the resumable set', () => { }) describe('spending a marker', () => { - function runner(overrides: { resume?: () => Promise; concurrency?: number } = {}) { + function runner( + overrides: { + resume?: () => Promise + admission?: StructuredAgentSessionResumeAdmission + } = {} + ) { const consumed = new Set() const resume = overrides.resume ?? vi.fn(async () => {}) return { resume, consumed, deps: { - admission: new StructuredAgentSessionResumeAdmission(), + admission: overrides.admission ?? new StructuredAgentSessionResumeAdmission(), // Stands in for the durable store: the first caller spends it, later ones find it gone. consumeMarker: async (sessionId: string) => { if (consumed.has(sessionId)) { @@ -574,8 +580,7 @@ describe('spending a marker', () => { consumed.add(sessionId) return true }, - resume, - ...(overrides.concurrency === undefined ? {} : { concurrency: overrides.concurrency }) + resume } } } @@ -670,6 +675,7 @@ describe('spending a marker', () => { let live = 0 let peak = 0 const { deps } = runner({ + admission: new StructuredAgentSessionResumeAdmission(3), resume: async () => { live += 1 peak = Math.max(peak, live) @@ -684,7 +690,39 @@ describe('spending a marker', () => { const outcomes = await resumeStructuredAgentSessionsFromRestart(deps, candidates, 'banner') // Unbounded fan-out would peak at all 12 — which is the spawn storm this exists to prevent. - expect(peak).toBe(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY) + expect(peak).toBe(3) expect(outcomes).toHaveLength(candidates.length) }) + + // A client resumes each chat with its own request, so the limit has to span requests. + it('shares the start limit across separate resume requests', async () => { + let live = 0 + let peak = 0 + const { deps } = runner({ + admission: new StructuredAgentSessionResumeAdmission(3), + resume: async () => { + live += 1 + peak = Math.max(peak, live) + await new Promise((resolve) => setTimeout(resolve, 5)) + live -= 1 + } + }) + + const outcomes = await Promise.all( + Array.from({ length: 10 }, (_, index) => + resumeStructuredAgentSessionsFromRestart( + deps, + [candidate(`session-separate-${index}`)], + 'modal' + ) + ) + ) + + expect(peak).toBe(3) + expect(outcomes.flat().every((outcome) => outcome.outcome === 'resumed')).toBe(true) + }) + + it('sizes the default start limit to this machine', () => { + expect(STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY).toBe(Math.max(1, availableParallelism())) + }) }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index 3c7e376c00f1..a4340a07c92c 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -40,7 +40,8 @@ export function ResumeCandidateRow({ disabled, onCheckedChange, failure, - onFailureAction + onFailureAction, + renderStatus }: { candidate: ResumeCandidate /** Named in the checkbox's accessible name: several rows otherwise read identically. */ @@ -52,6 +53,8 @@ export function ResumeCandidateRow({ /** Present when an earlier resume of this chat did not carry on. */ failure?: ResumeFailure onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void + /** While a resume follows this chat: its status icon, which takes the checkbox's place. */ + renderStatus?: (sessionId: string, title: string) => React.ReactNode }): React.JSX.Element { const agentLabel = formatAgentTypeLabel(candidate.agent) const title = @@ -59,21 +62,24 @@ export function ResumeCandidateRow({ translate('auto.components.NativeChatResumeOnRestartModal.untitled', 'Untitled chat') const model = candidate.model?.trim() ?? '' const activity = resumeActivityLabel(candidate.activity) - const row = ( - - {/* Two controls: one deletes the offer, one acts on it. Closing snoozes, so it needs none. */} - - {/* Quiet, explicit cleanup of the durable records. */} - - - + {/* Two controls: one deletes the offer, one acts on it. Closing snoozes, so it needs none. + A finished run that left nothing to act on has only the way out. */} + {rows.length === 0 ? ( + + + + ) : ( + + {/* Quiet, explicit cleanup of the durable records. */} + + + + )} ) diff --git a/src/renderer/src/components/NativeChatResumeRunPanel.tsx b/src/renderer/src/components/NativeChatResumeRunPanel.tsx new file mode 100644 index 000000000000..6ce2f7b47d8d --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { Loader2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { useNow } from '@/hooks/use-now' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { resumeRunInFlight, type ResumeRun } from './native-chat-resume-run' +import { resumeRunView, type ResumeRunFilter } from './native-chat-resume-run-view' +import { ResumeRunStatusIcon } from './NativeChatResumeRunStatusIcon' +import { ResumeRunSummary } from './NativeChatResumeRunSummary' +import { useResumeRunStartPhases } from './use-resume-run-start-phases' + +/** + * The dialog's view of a run it is following: its title, the progress line and filters, the rows + * in the order that matters, and each row's status icon. Null when no run is being followed, so + * the dialog is then exactly the offer it was. + */ + +export type ResumeRunPanel = { + title: React.ReactNode + /** Null once the run is over: the offer's own copy then describes what is left. */ + description: string | null + summary: React.ReactNode + rows: ResumeCandidate[] + renderStatus: (sessionId: string, title: string) => React.ReactNode +} + +function runTitle(run: ResumeRun, total: number, resumed: number): React.ReactNode { + if (!resumeRunInFlight(run)) { + return translate( + 'auto.components.NativeChatResumeRunPanel.resumedTitle', + 'Resumed {{value0}} of {{value1}} chats', + { value0: resumed, value1: total } + ) + } + return ( + + + {total === 1 + ? translate('auto.components.NativeChatResumeRunPanel.resumingTitleOne', 'Resuming 1 chat') + : translate( + 'auto.components.NativeChatResumeRunPanel.resumingTitle', + 'Resuming {{value0}} chats', + { value0: total } + )} + + ) +} + +export function useResumeRunPanel({ + run, + rows, + failureFor, + open +}: { + run: ResumeRun | null + rows: readonly ResumeCandidate[] + failureFor: (sessionId: string) => ResumeFailure | undefined + open: boolean +}): ResumeRunPanel | null { + // Each opening starts on All, as the dialog's own ticks start from their defaults. + const [filter, setFilter] = useState<{ value: ResumeRunFilter; open: boolean }>({ + value: 'all', + open + }) + if (filter.open !== open) { + setFilter({ value: 'all', open }) + } + const inFlight = run !== null && resumeRunInFlight(run) + // Ticks only while a visible run is moving: the row timers and the running clock read it. + const now = useNow(1000, open && inFlight) + const view = run ? resumeRunView(run, rows, failureFor, filter.value) : null + const inFlightIds = view + ? [...view.statusBySession].flatMap(([sessionId, status]) => + status.kind === 'in-flight' ? [sessionId] : [] + ) + : [] + const phaseFor = useResumeRunStartPhases(inFlightIds) + if (!run || !view) { + return null + } + return { + title: runTitle(run, view.counts.total, view.counts.resumed), + description: inFlight + ? translate( + 'auto.components.NativeChatResumeRunPanel.description', + 'Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track.' + ) + : null, + summary: ( + setFilter({ value, open })} + /> + ), + rows: view.rows, + renderStatus: (sessionId, title) => { + const status = view.statusBySession.get(sessionId) + return status ? ( + + ) : null + } + } +} diff --git a/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx new file mode 100644 index 000000000000..44cc6b031429 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx @@ -0,0 +1,101 @@ +import { AlertCircle, Check, Clock, Loader2 } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip' +import { translate } from '@/i18n/i18n' +import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status' +import type { ResumeRunRowStatus } from './native-chat-resume-run-view' + +/** + * Where one chat of a run stands, in the slot its checkbox had: a gray spinner while it resumes, + * then what it came to. The words live in the tooltip, so the row carries no badge. + */ + +export type ResumeRunStartPhase = 'starting' | 'ready' | null + +/** In flight, the host's feed says how far the agent got; the resume itself does not report it. */ +function inFlightText(phase: ResumeRunStartPhase): string { + return phase === 'ready' + ? translate( + 'auto.components.NativeChatResumeRunStatusIcon.waitingForReply', + 'Waiting for a reply' + ) + : phase === 'starting' + ? translate('auto.components.NativeChatResumeRunStatusIcon.starting', 'Starting') + : translate( + 'auto.components.NativeChatResumeRunStatusIcon.waitingToStart', + 'Waiting to start' + ) +} + +function statusText(status: ResumeRunRowStatus, phase: ResumeRunStartPhase, now: number): string { + switch (status.kind) { + case 'in-flight': + return translate( + 'auto.components.NativeChatResumeRunStatusIcon.inFlight', + '{{value0}} · {{value1}}', + { + value0: inFlightText(phase), + value1: formatNativeChatDuration((now - status.startedAt) / 1000) + } + ) + case 'resumed': + return translate('auto.components.NativeChatResumeRunStatusIcon.resumed', 'Resumed') + case 'unconfirmed': + return translate( + 'auto.components.NativeChatResumeOutcomeRow.unconfirmed', + 'Couldn’t confirm the chat was resumed' + ) + case 'refused': + return translate('auto.components.NativeChatResumeOutcomeRow.failed', 'Couldn’t resume') + } +} + +function StatusGlyph({ status }: { status: ResumeRunRowStatus }): React.JSX.Element { + switch (status.kind) { + case 'in-flight': + return + case 'resumed': + return + case 'unconfirmed': + // The same glyphs a failure row uses, so a status does not change look when its row arrives. + return + case 'refused': + return + } +} + +export function ResumeRunStatusIcon({ + status, + phase, + now, + title +}: { + status: ResumeRunRowStatus + phase: ResumeRunStartPhase + now: number + /** The chat's name, so the accessible name says which chat this is. */ + title: string +}): React.JSX.Element { + const text = statusText(status, phase, now) + return ( + + + {/* Focusable so keyboard users reach the same tooltip. */} + + + + + + {text} + + + ) +} diff --git a/src/renderer/src/components/NativeChatResumeRunSummary.tsx b/src/renderer/src/components/NativeChatResumeRunSummary.tsx new file mode 100644 index 000000000000..abbfb82325eb --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunSummary.tsx @@ -0,0 +1,126 @@ +import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group' +import { translate } from '@/i18n/i18n' +import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status' +import type { ResumeRunFilter, ResumeRunView } from './native-chat-resume-run-view' +import type { ResumeRunStartPhase } from './NativeChatResumeRunStatusIcon' + +/** + * The line above a followed run's list: how many chats are done and where the rest stand, and the + * filters that narrow the list to one of those groups. + */ + +function progressText( + counts: ResumeRunView['counts'], + phases: readonly ResumeRunStartPhase[] +): string { + const parts = [ + translate('auto.components.NativeChatResumeRunSummary.done', '{{value0}} of {{value1}} done', { + value0: counts.done, + value1: counts.total + }) + ] + const waitingToStart = phases.filter((phase) => phase === null).length + const starting = phases.filter((phase) => phase === 'starting').length + const waitingForReply = phases.filter((phase) => phase === 'ready').length + if (starting > 0) { + parts.push( + translate('auto.components.NativeChatResumeRunSummary.starting', '{{value0}} starting', { + value0: starting + }) + ) + } + if (waitingForReply > 0) { + parts.push( + translate( + 'auto.components.NativeChatResumeRunSummary.waitingForReply', + '{{value0}} waiting for a reply', + { value0: waitingForReply } + ) + ) + } + if (waitingToStart > 0) { + parts.push( + translate( + 'auto.components.NativeChatResumeRunSummary.waitingToStart', + '{{value0}} waiting to start', + { value0: waitingToStart } + ) + ) + } + return parts.join(' · ') +} + +export function ResumeRunSummary({ + counts, + phases, + startedAt, + now, + filter, + onFilterChange +}: { + counts: ResumeRunView['counts'] + /** One per chat still in flight. */ + phases: readonly ResumeRunStartPhase[] + startedAt: number + now: number + filter: ResumeRunFilter + onFilterChange: (filter: ResumeRunFilter) => void +}): React.JSX.Element { + const filters: readonly [ResumeRunFilter, string, number][] = [ + ['all', translate('auto.components.NativeChatResumeRunSummary.all', 'All'), counts.all], + [ + 'in-progress', + translate('auto.components.NativeChatResumeRunSummary.inProgress', 'In progress'), + counts.inProgress + ], + [ + 'resumed', + translate('auto.components.NativeChatResumeRunSummary.resumed', 'Resumed'), + counts.resumed + ], + [ + 'attention', + translate('auto.components.NativeChatResumeRunSummary.attention', 'Need you'), + counts.attention + ] + ] + return ( +
+
+ {progressText(counts, phases)} + {counts.inProgress > 0 && ( + + {translate('auto.components.NativeChatResumeRunSummary.running', 'Running {{value0}}', { + value0: formatNativeChatDuration((now - startedAt) / 1000) + })} + + )} +
+ { + // Pressing the selected filter again would clear it; the list always has one. + if ( + next === 'all' || + next === 'in-progress' || + next === 'resumed' || + next === 'attention' + ) { + onFilterChange(next) + } + }} + aria-label={translate('auto.components.NativeChatResumeRunSummary.filter', 'Show chats')} + > + {filters.map(([value, label, count]) => ( + + {label} + {count} + + ))} + +
+ ) +} diff --git a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts index 4e0697f228b8..669a370ddef8 100644 --- a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts +++ b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts @@ -183,21 +183,25 @@ it('never lets a re-read that was already in flight bring back a dismissed failu it('waits for a resume in flight instead of re-reading under it', async () => { await listFailure({ failed: [failure] }) const acting = Promise.withResolvers() + let failed: unknown[] = [failure] mocks.rpc.mockImplementation((_target: unknown, method: string) => method === 'agentSession.restartContinue' ? acting.promise - : Promise.resolve({ sessions: [], failed: [failure] }) + : Promise.resolve({ sessions: [], failed }) ) const retry = continueNativeChatRestartOffer(['a']) hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) }) await vi.advanceTimersByTimeAsync(500) expect(offerReads()).toBe(1) + // The resume's own read of what is left, once the chat answered, is the only one. + failed = [] acting.resolve({ sessions: [], failed: [], continued: [{ sessionId: 'a', outcome: 'continued' }] }) await retry + expect(offerReads()).toBe(2) expect(getNativeChatRestartOffer().failed).toEqual([]) }) diff --git a/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts b/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts new file mode 100644 index 000000000000..679fe312bcb3 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts @@ -0,0 +1,106 @@ +import type { StructuredAgentSessionStatusFeedOwner } from '@/runtime/structured-agent-session-status-feed' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +/** + * Asks the host again once an offered or failed chat shows new activity, so a message the user + * sent there, or its agent starting, retires its entry here too. The host stays the judge; this + * only asks again. + * + * Held only while something is offered or failed. Keyed on status and prompt rather than every + * summary, so an agent streaming in such a chat costs one re-read, not one per tool call. + */ + +const OFFERED_CHAT_REFRESH_DELAY_MS = 500 + +function activityKey(summary: AgentSessionStatusSummary): string { + return `${summary.status ?? ''}\u0000${summary.latestPrompt}` +} + +export function createOfferedChatWatch(deps: { + feed: () => StructuredAgentSessionStatusFeedOwner + offeredIds: () => Set + /** When the list being watched arrived; a first sighting older than it is not news. */ + listedAt: () => number + refresh: () => void +}): { sync: () => void; release: () => void } { + let watch: { + feed: StructuredAgentSessionStatusFeedOwner + seen: Map + release: () => void + } | null = null + let pendingRefresh: ReturnType | null = null + + const notice = (): void => { + if (!watch) { + return + } + const { feed, seen } = watch + const snapshot = feed.getSnapshot() + let changed = false + for (const sessionId of deps.offeredIds()) { + const summary = snapshot.get(sessionId) + if (!summary) { + continue + } + const key = activityKey(summary) + const previous = seen.get(sessionId) + if (previous !== key) { + seen.set(sessionId, key) + // A first sighting is news only if newer than the list; a change to a known chat always is, + // since the host may have answered the list just before the change was delivered here. + changed ||= previous !== undefined || summary.updatedAt > deps.listedAt() + } + } + if (changed && pendingRefresh === null) { + pendingRefresh = setTimeout(() => { + pendingRefresh = null + deps.refresh() + }, OFFERED_CHAT_REFRESH_DELAY_MS) + } + } + + const release = (): void => { + if (pendingRefresh !== null) { + clearTimeout(pendingRefresh) + pendingRefresh = null + } + watch?.release() + watch = null + } + + const sync = (): void => { + const offeredIds = deps.offeredIds() + if (offeredIds.size === 0) { + release() + return + } + if (!watch) { + const feed = deps.feed() + const unsubscribe = feed.subscribe(notice) + const deactivate = feed.activate() + watch = { + feed, + seen: new Map(), + release: () => { + unsubscribe() + deactivate() + } + } + } + const { feed, seen } = watch + for (const sessionId of seen.keys()) { + if (!offeredIds.has(sessionId)) { + seen.delete(sessionId) + } + } + // What the feed already holds is what this listing answered. + for (const sessionId of offeredIds) { + const summary = feed.getSnapshot().get(sessionId) + if (summary && !seen.has(sessionId)) { + seen.set(sessionId, activityKey(summary)) + } + } + } + + return { sync, release } +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts index 9b8c90eb4333..543b2f4ceaeb 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts @@ -55,11 +55,11 @@ function hostLostAfterListing(): void { }) } -/** How each caller starts a resume: a click names its chats; an opted-in launch names none and - * reports the ones it offered. */ +/** How each caller starts a resume: a click names the chats it ticked; an opted-in launch names + * every chat the host offered. Both resume each chat with its own request. */ const resumes = { click: () => continueNativeChatRestartOffer(['a', 'b']), - launch: () => continueNativeChatRestartOffer(undefined, ['a', 'b']) + launch: () => continueNativeChatRestartOffer(offered.map((candidate) => candidate.sessionId)) } // With the host unreachable there is no list to narrow by: every chat the resume reported failed, @@ -93,19 +93,21 @@ it('answers an opted-in launch that loses its resume request with one toast and // The same toast and copy as a click: the chats it resumed ride along under the one it could not. it('answers a mixed opted-in launch with one toast', async () => { - const failed = [{ ...offered[1]!, failedAt: 1, outcome: 'refused', reason: 'unknown' }] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered, failed: [] } - : { - continued: [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused' } - ], - sessions: [], - failed - } - ) + const failure = { ...offered[1]!, failedAt: 1, outcome: 'refused', reason: 'unknown' } + let acted = false + rpc.mockImplementation(async (_target, method, params: { sessionIds: string[] } | undefined) => { + const failed = acted ? [failure] : [] + if (method === 'agentSession.restartResumable') { + return { sessions: acted ? [] : offered, failed } + } + acted = true + const [sessionId] = params?.sessionIds ?? [] + return { + continued: [{ sessionId, outcome: sessionId === 'b' ? 'refused' : 'continued' }], + sessions: [], + failed: [failure] + } + }) await refreshNativeChatRestartOffer() await resumes.launch() expect(vi.mocked(toast).mock.calls).toEqual([ diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 9111eae70dbe..e23668fbce63 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -1,10 +1,6 @@ import { useEffect, useSyncExternalStore } from 'react' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { - getStructuredAgentSessionStatusFeed, - type StructuredAgentSessionStatusFeedOwner -} from '@/runtime/structured-agent-session-status-feed' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' import { useAppStore } from '../store' import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' import { @@ -16,6 +12,14 @@ import { type ResumeCandidate, type ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + beginResumeRunEntries, + resumeRunInFlight, + resumeRunPendingIds, + resumeRunResultOf, + settleResumeRunEntry, + type ResumeRun +} from './native-chat-resume-run' import { forgetUnsentResumes, markUnsentResumes, @@ -23,8 +27,10 @@ import { } from './native-chat-resume-unsent-requests' import { consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch' /** * Which interrupted chats the host is still offering to resume, and every action that moves that. @@ -57,11 +63,10 @@ let launch: Promise | undefined * a re-read that raced it must neither pre-empt nor undo. */ let actionsBegun = 0 let actionsSettled = 0 -/** The chats each in-flight continue call names, so the status bar can report the resume after - * the dialog that started it has closed. Held only for the call's lifetime; never persisted. */ -const resumeBatches = new Set() -const NOTHING_RESUMING: readonly string[] = [] -let resuming: readonly string[] = NOTHING_RESUMING +/** The resume this window ran, so the status bar and a reopened dialog can follow it after the + * dialog that started it closed. In memory only; replaced by the next run once it has finished. */ +let run: ResumeRun | null = null +let resuming: readonly string[] = resumeRunPendingIds(null) const listeners = new Set<() => void>() const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const @@ -76,123 +81,41 @@ function emit(): void { * actually moves the offer. */ function publish(next: NativeChatRestartOffer): void { offer = next - syncOfferedChatWatch() + offeredChatWatch.sync() emit() } /** A confirmed host answer. One with nothing left also retires any open request for the dialog, - * which has nothing to show; a failed read only hides rows, so it keeps the request. */ + * unless it is showing a run; a failed read only hides rows, so it keeps the request. */ function publishAnswer(answer: NativeChatRestartOffer): void { const next = withUnsentResumes(answer) publish(next) - if (next.candidates.length === 0 && next.failed.length === 0) { + if (next.candidates.length === 0 && next.failed.length === 0 && run === null) { consumeNativeChatResumeOnRestartDialogRequest() } } -function syncResuming(): void { - resuming = resumeBatches.size === 0 ? NOTHING_RESUMING : [...new Set([...resumeBatches].flat())] +function setRun(next: ResumeRun | null): void { + run = next + const pending = resumeRunPendingIds(next) + // Same ids, same array, so readers of `resuming` re-render only when it changes. + resuming = pending.join('\0') === resuming.join('\0') ? resuming : pending emit() } -/** - * Re-reads the host once an offered or failed chat shows new activity, so a message the user sent - * there, or its agent starting, retires its entry here too. The host stays the judge; this only - * asks again. - * - * Held only while something is offered or failed. Keyed on status and prompt rather than every - * summary, so an agent streaming in such a chat costs one re-read, not one per tool call. - */ -const OFFERED_CHAT_REFRESH_DELAY_MS = 500 -let offeredChatWatch: { - feed: StructuredAgentSessionStatusFeedOwner - seen: Map - release: () => void -} | null = null -let offeredChatRefresh: ReturnType | null = null - -function offeredChatIds(): Set { - return new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)) -} - -function offeredChatActivityKey(summary: AgentSessionStatusSummary): string { - return `${summary.status ?? ''}\u0000${summary.latestPrompt}` -} - -function syncOfferedChatWatch(): void { - const offeredIds = offeredChatIds() - if (offeredIds.size === 0) { - releaseOfferedChatWatch() - return - } - if (!offeredChatWatch) { - const feed = getStructuredAgentSessionStatusFeed(LOCAL) - const unsubscribe = feed.subscribe(noticeOfferedChatActivity) - const deactivate = feed.activate() - offeredChatWatch = { - feed, - seen: new Map(), - release: () => { - unsubscribe() - deactivate() - } +// Re-reads once an offered or failed chat shows new activity. Skipped while an action is in +// flight: that action ends by publishing the host's answer, which a racing re-read must not undo. +const offeredChatWatch = createOfferedChatWatch({ + feed: () => getStructuredAgentSessionStatusFeed(LOCAL), + offeredIds: () => new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)), + listedAt: () => offer.listedAt, + refresh: () => { + if (actionsBegun === actionsSettled) { + const issued = actionsBegun + void readNativeChatRestartOffer(() => actionsBegun === issued) } } - const { feed, seen } = offeredChatWatch - for (const sessionId of seen.keys()) { - if (!offeredIds.has(sessionId)) { - seen.delete(sessionId) - } - } - // What the feed already holds is what this listing answered. - for (const sessionId of offeredIds) { - const summary = feed.getSnapshot().get(sessionId) - if (summary && !seen.has(sessionId)) { - seen.set(sessionId, offeredChatActivityKey(summary)) - } - } -} - -function noticeOfferedChatActivity(): void { - if (!offeredChatWatch) { - return - } - const { feed, seen } = offeredChatWatch - const snapshot = feed.getSnapshot() - let changed = false - for (const sessionId of offeredChatIds()) { - const summary = snapshot.get(sessionId) - if (!summary) { - continue - } - const key = offeredChatActivityKey(summary) - const previous = seen.get(sessionId) - if (previous !== key) { - seen.set(sessionId, key) - // A first sighting is news only if newer than the list; a change to a known chat always is, - // since the host may have answered the list just before the change was delivered here. - changed ||= previous !== undefined || summary.updatedAt > offer.listedAt - } - } - if (changed && offeredChatRefresh === null) { - offeredChatRefresh = setTimeout(() => { - offeredChatRefresh = null - if (actionsBegun === actionsSettled) { - const issued = actionsBegun - void readNativeChatRestartOffer(() => actionsBegun === issued) - } - }, OFFERED_CHAT_REFRESH_DELAY_MS) - } -} - -function releaseOfferedChatWatch(): void { - if (offeredChatRefresh !== null) { - clearTimeout(offeredChatRefresh) - offeredChatRefresh = null - } - offeredChatWatch?.release() - offeredChatWatch = null -} +}) export function getNativeChatRestartOffer(): NativeChatRestartOffer { return offer @@ -202,6 +125,17 @@ export function getNativeChatRestartResuming(): readonly string[] { return resuming } +export function getNativeChatRestartRun(): ResumeRun | null { + return run +} + +/** Closing the dialog after a run finished drops its rows; the host's list carries what is left. */ +export function releaseFinishedNativeChatRestartRun(): void { + if (run && !resumeRunInFlight(run)) { + setRun(null) + } +} + function subscribe(listener: () => void): () => void { listeners.add(listener) return () => { @@ -266,62 +200,91 @@ export async function reopenNativeChatRestartOffer(): Promise { if (resuming.length === 0) { await readNativeChatRestartOffer() } - if (resuming.length > 0 || offer.candidates.length > 0 || offer.failed.length > 0) { + if (run !== null || offer.candidates.length > 0 || offer.failed.length > 0) { requestNativeChatResumeOnRestartDialog() } } +/** One chat's own answer; a request lost before reaching the host is marked unsent. `listed`: the + * answer carried the host's failure list, without which "no longer failed" proves nothing. */ +type ChatAnswer = { + sessionId: string + continued: RestartContinuationOutcome[] | null + listed: boolean +} + +async function continueOneChat(sessionId: string): Promise { + try { + const result = await callStructuredAgentSession< + HostOfferPayload & { continued?: RestartContinuationOutcome[] } + >(LOCAL, 'agentSession.restartContinue', { sessionIds: [sessionId] }) + const continued = Array.isArray(result.continued) ? result.continued : undefined + const failed = Array.isArray(result.failed) ? failedFrom(result) : undefined + setRun(settleResumeRunEntry(run, sessionId, resumeRunResultOf(sessionId, continued, failed))) + // An answer without outcomes may still have sent the message, so it counts as unconfirmed. + return { + sessionId, + continued: continued ?? [{ sessionId, outcome: 'unknown' }], + listed: failed !== undefined + } + } catch (error) { + // The row's reason is this side's own code, so the real error is kept in the log. + console.warn('[native-chat-resume] resume request failed before reaching the chat', error) + markUnsentResumes([sessionId], Date.now()) + setRun(settleResumeRunEntry(run, sessionId, 'refused')) + return { sessionId, continued: null, listed: true } + } +} + /** - * Reattach the offered chats, ask each agent to carry on, then replace the offer with the host's + * Reattach the named chats, ask each agent to carry on, then replace the offer with the host's * authoritative remaining list. This keeps the modal and status bar synchronized after every * action, even when the dialog's snapshot became stale while it was open. * - * `sessionIds` is the dialog's selection. An opted-in launch names nothing, so the host acts on - * whatever it still offers rather than on a list this side captured a moment earlier, and passes - * `reported` instead: the chats the status bar shows as resuming. + * Each chat is its OWN request, so its row settles when its agent answers, not when the slowest in + * the batch does. The host still staggers starts, with one limit across every request, and still + * re-derives eligibility for each chat it is named. * * Ends in one toast saying what it did across those chats, whether a click or an opted-in launch * started it; each chat's note stays the record. Never rejects; a lost answer is followed by a - * re-read, never a retry. The chats a rejected request named and the host still offers show as - * failed, with Retry. + * re-read, never a retry. A chat whose request was lost and the host still offers shows as failed, + * with Retry. */ -export async function continueNativeChatRestartOffer( - sessionIds: readonly string[] | undefined, - reported: readonly string[] = sessionIds ?? [] -): Promise { +export async function continueNativeChatRestartOffer(sessionIds: readonly string[]): Promise { + const requested = [...new Set(sessionIds)] + if (requested.length === 0) { + return + } actionsBegun += 1 - forgetUnsentResumes(sessionIds) - const batch = [...reported] - let outcome: Parameters - resumeBatches.add(batch) - syncResuming() + forgetUnsentResumes(requested) + const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row])) + setRun( + beginResumeRunEntries( + run, + requested.flatMap((sessionId) => rows.get(sessionId) ?? []), + Date.now() + ) + ) // `resuming` now names the chats, so the launch's one resume decision is made. markNativeChatLaunchResumeDecided() + let listed: readonly ResumeFailure[] | undefined + const answers = await Promise.all(requested.map(continueOneChat)) try { - const result = await callStructuredAgentSession< - HostOfferPayload & { continued?: RestartContinuationOutcome[] } - >(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {}) - const listed = Array.isArray(result.failed) ? failedFrom(result) : undefined - if (Array.isArray(result.sessions)) { - publishAnswer({ candidates: result.sessions, failed: listed ?? [], listedAt: Date.now() }) - } else { - await refreshNativeChatRestartOffer() - } - outcome = [batch, result.continued, listed, reopenNativeChatRestartOffer] - } catch (error) { - // The row's reason is this side's own code, so the real error is kept in the log. - console.warn('[native-chat-resume] resume request failed before reaching the chats', error) - markUnsentResumes(batch, Date.now()) const read = await readNativeChatRestartOffer() - // Nothing reached the chats, so each is a failure of this resume as the list now shows it. - const listed = read.available ? offer.failed : undefined - outcome = [batch, [], listed, reopenNativeChatRestartOffer] + listed = read.available && answers.every((answer) => answer.listed) ? offer.failed : undefined } finally { actionsSettled += 1 - resumeBatches.delete(batch) - syncResuming() + // Nothing on screen can follow a finished run once the dialog is closed. + if (!resumeRunInFlight(run) && !getNativeChatResumeOnRestartDialogRequest()) { + setRun(null) + } } - announceRestartResults(...outcome) + announceRestartResults( + requested, + answers.flatMap((answer) => answer.continued ?? []), + listed, + reopenNativeChatRestartOffer + ) } /** @@ -391,7 +354,7 @@ async function loadLaunchOffer(): Promise { requestNativeChatResumeOnRestartDialog() return } - await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) + await continueNativeChatRestartOffer(allResumeSessionIds(offered)) } /** @@ -415,13 +378,18 @@ export function useNativeChatRestartResuming(): readonly string[] { return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming) } +/** The run a reopened dialog follows: each chat it asked, and what each has answered so far. */ +export function useNativeChatRestartRun(): ResumeRun | null { + return useSyncExternalStore(subscribe, getNativeChatRestartRun, getNativeChatRestartRun) +} + /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { - releaseOfferedChatWatch() + offeredChatWatch.release() offer = EMPTY forgetUnsentResumes(undefined) - resumeBatches.clear() - resuming = NOTHING_RESUMING + run = null + resuming = resumeRunPendingIds(null) actionsBegun = 0 actionsSettled = 0 launch = undefined diff --git a/src/renderer/src/components/native-chat-resume-run-view.ts b/src/renderer/src/components/native-chat-resume-run-view.ts new file mode 100644 index 000000000000..0f08bc05fc5f --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run-view.ts @@ -0,0 +1,96 @@ +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { ResumeRun } from './native-chat-resume-run' + +/** + * What the dialog shows while it follows a run: one row per chat, each with where it stands. + * + * Pure, so the ordering and the counts are tested without a store. A chat the host already lists as + * failed is shown as that failure row, with its guidance and Retry, rather than as a bare status. + */ + +/** Where one chat of the run stands, as the leading icon shows it in place of the checkbox. */ +export type ResumeRunRowStatus = + | { kind: 'in-flight'; startedAt: number } + | { kind: 'resumed' } + | { kind: 'refused' } + | { kind: 'unconfirmed' } + +export type ResumeRunFilter = 'all' | 'in-progress' | 'resumed' | 'attention' + +type Category = Exclude | 'other' + +export type ResumeRunView = { + rows: ResumeCandidate[] + statusBySession: ReadonlyMap + counts: Readonly<{ + /** Every row the list holds, in the run or not. */ + all: number + /** Chats in this run that still need resuming or did; a chat nobody needed is not counted. */ + total: number + done: number + inProgress: number + resumed: number + /** Failed or unconfirmed, this run's or an earlier one's: the rows that need the user. */ + attention: number + }> +} + +// What needs the user first, then what is still moving, then what is done. +const ORDER: Record = { attention: 0, 'in-progress': 1, resumed: 2, other: 3 } + +export function resumeRunView( + run: ResumeRun, + listed: readonly ResumeCandidate[], + failureFor: (sessionId: string) => ResumeFailure | undefined, + filter: ResumeRunFilter +): ResumeRunView { + const listedById = new Map(listed.map((row) => [row.sessionId, row])) + const statusBySession = new Map() + const placed: { row: ResumeCandidate; category: Category; index: number }[] = [] + const inRun = new Set() + for (const entry of run.entries) { + const { sessionId } = entry.candidate + inRun.add(sessionId) + if (entry.result === 'gone') { + continue + } + const row = listedById.get(sessionId) ?? entry.candidate + if (entry.result === undefined) { + statusBySession.set(sessionId, { kind: 'in-flight', startedAt: entry.startedAt }) + placed.push({ row, category: 'in-progress', index: placed.length }) + } else if (entry.result === 'resumed') { + statusBySession.set(sessionId, { kind: 'resumed' }) + placed.push({ row, category: 'resumed', index: placed.length }) + } else { + // Until the host lists it, the run's own answer is all there is to show. + if (!failureFor(sessionId)) { + statusBySession.set(sessionId, { kind: entry.result }) + } + placed.push({ row, category: 'attention', index: placed.length }) + } + } + for (const row of listed) { + if (!inRun.has(row.sessionId)) { + const category = failureFor(row.sessionId) ? 'attention' : 'other' + placed.push({ row, category, index: placed.length }) + } + } + const count = (category: Category) => placed.filter((entry) => entry.category === category).length + const inRunCount = placed.filter((entry) => inRun.has(entry.row.sessionId)).length + const inProgress = count('in-progress') + return { + rows: placed + .filter((entry) => filter === 'all' || entry.category === filter) + .sort((a, b) => ORDER[a.category] - ORDER[b.category] || a.index - b.index) + .map((entry) => entry.row), + statusBySession, + counts: { + all: placed.length, + total: inRunCount, + done: inRunCount - inProgress, + inProgress, + resumed: count('resumed'), + attention: count('attention') + } + } +} diff --git a/src/renderer/src/components/native-chat-resume-run.test.ts b/src/renderer/src/components/native-chat-resume-run.test.ts new file mode 100644 index 000000000000..31f2493f179e --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + beginResumeRunEntries, + resumeRunInFlight, + resumeRunPendingIds, + resumeRunResultOf, + settleResumeRunEntry +} from './native-chat-resume-run' +import { resumeRunView } from './native-chat-resume-run-view' + +const row = (sessionId: string): ResumeCandidate => ({ + sessionId, + workspaceId: 'workspace', + agent: 'codex', + trigger: 'quit', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1 +}) + +const refused = (sessionId: string) => ({ + ...row(sessionId), + failedAt: 2, + outcome: 'refused' as const, + reason: 'agent_session_conflict' +}) + +describe('a run', () => { + it('settles each chat on its own answer and keeps the order the chats were asked in', () => { + let run = beginResumeRunEntries(null, [row('a'), row('b')], 10) + expect(resumeRunPendingIds(run)).toEqual(['a', 'b']) + run = settleResumeRunEntry(run, 'b', 'resumed')! + expect(resumeRunPendingIds(run)).toEqual(['a']) + expect(resumeRunInFlight(run)).toBe(true) + run = settleResumeRunEntry(run, 'a', 'refused')! + expect(resumeRunInFlight(run)).toBe(false) + expect(run.entries.map((entry) => [entry.candidate.sessionId, entry.result])).toEqual([ + ['a', 'refused'], + ['b', 'resumed'] + ]) + }) + + // A retry while the run still moves joins it; one after it finished starts a fresh run. + it('joins a run in flight and replaces a finished one', () => { + const moving = beginResumeRunEntries(null, [row('a'), row('b')], 10) + const joined = beginResumeRunEntries( + settleResumeRunEntry(moving, 'a', 'refused'), + [row('a')], + 20 + ) + expect(joined.startedAt).toBe(10) + expect(joined.entries.map((entry) => [entry.candidate.sessionId, entry.result])).toEqual([ + ['b', undefined], + ['a', undefined] + ]) + + const finished = settleResumeRunEntry( + settleResumeRunEntry(moving, 'a', 'resumed'), + 'b', + 'resumed' + ) + const fresh = beginResumeRunEntries(finished, [row('c')], 30) + expect(fresh).toEqual({ startedAt: 30, entries: [{ candidate: row('c'), startedAt: 30 }] }) + }) + + it('shares one empty pending list, so readers of it do not re-render for nothing', () => { + expect(resumeRunPendingIds(null)).toBe(resumeRunPendingIds(null)) + }) +}) + +describe("one chat's answer", () => { + it.each([ + ['continued', undefined, 'resumed'], + ['refused', undefined, 'refused'], + ['unknown', undefined, 'unconfirmed'], + // With the host's list read, an unconfirmed send it no longer lists was seen carrying on. + ['unknown', [], 'resumed'], + // A refusal the host no longer lists: the user answered first, and nothing failed. + ['refused', [], 'gone'] + ] as const)('reads %s with failures %j as %s', (outcome, failed, result) => { + expect(resumeRunResultOf('a', [{ sessionId: 'a', outcome }], failed)).toBe(result) + }) + + it('prefers what the host lists the chat as', () => { + expect( + resumeRunResultOf( + 'a', + [{ sessionId: 'a', outcome: 'continued' }], + [{ sessionId: 'a', outcome: 'unconfirmed' }] + ) + ).toBe('unconfirmed') + }) + + it('treats an answer without outcomes as unconfirmed, and one that skipped the chat as gone', () => { + expect(resumeRunResultOf('a', undefined, undefined)).toBe('unconfirmed') + expect(resumeRunResultOf('a', [], [])).toBe('gone') + }) +}) + +describe('the dialog over a run', () => { + const run = (() => { + let next = beginResumeRunEntries(null, [row('a'), row('b'), row('c'), row('d')], 10) + next = settleResumeRunEntry(next, 'a', 'resumed')! + next = settleResumeRunEntry(next, 'c', 'refused')! + return settleResumeRunEntry(next, 'd', 'gone')! + })() + + it('lists what needs the user first, then what is moving, then what is done', () => { + const view = resumeRunView(run, [row('e')], () => undefined, 'all') + expect(view.rows.map((entry) => entry.sessionId)).toEqual(['c', 'b', 'a', 'e']) + expect(view.counts).toEqual({ + all: 4, + total: 3, + done: 2, + inProgress: 1, + resumed: 1, + attention: 1 + }) + expect(view.statusBySession.get('b')).toEqual({ kind: 'in-flight', startedAt: 10 }) + expect(view.statusBySession.get('c')).toEqual({ kind: 'refused' }) + // A chat nobody needed resuming is left out rather than shown as resumed. + expect(view.statusBySession.has('d')).toBe(false) + }) + + // Once the host lists the failure, its row is the failure row with guidance, not a bare icon. + it('shows a failure the host lists as its failure row', () => { + const view = resumeRunView( + run, + [refused('c')], + (sessionId) => (sessionId === 'c' ? refused('c') : undefined), + 'all' + ) + expect(view.statusBySession.has('c')).toBe(false) + expect(view.rows.map((entry) => entry.sessionId)).toEqual(['c', 'b', 'a']) + }) + + it.each([ + ['in-progress', ['b']], + ['resumed', ['a']], + ['attention', ['c']] + ] as const)('narrows the list to %s', (filter, ids) => { + expect( + resumeRunView(run, [], () => undefined, filter).rows.map((entry) => entry.sessionId) + ).toEqual(ids) + }) +}) diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts new file mode 100644 index 000000000000..5dcbc89b95e8 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -0,0 +1,98 @@ +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { RestartContinuationOutcome } from './native-chat-restart-action-notifications' + +/** + * One resume as this window ran it: which chats it asked to carry on, and what each answered. + * + * Each chat is its own request, so a chat's row settles the moment its own answer arrives rather + * than when the slowest chat in the batch does. Held in memory for as long as the dialog may show + * it; the host's offer list stays the record of what is still owed. + */ + +/** `gone`: the host no longer offered the chat, e.g. the user answered it first; nothing failed. */ +export type ResumeRunResult = 'resumed' | 'refused' | 'unconfirmed' | 'gone' + +export type ResumeRunEntry = Readonly<{ + candidate: ResumeCandidate + startedAt: number + result?: ResumeRunResult +}> + +export type ResumeRun = Readonly<{ + startedAt: number + /** In the order the chats were asked, so rows keep their place while answers arrive. */ + entries: readonly ResumeRunEntry[] +}> + +export function resumeRunInFlight(run: ResumeRun | null): boolean { + return run?.entries.some((entry) => entry.result === undefined) ?? false +} + +/** Chats join a run still in flight; a finished run is replaced, so its rows never linger. */ +export function beginResumeRunEntries( + run: ResumeRun | null, + candidates: readonly ResumeCandidate[], + now: number +): ResumeRun { + const base = run && resumeRunInFlight(run) ? run : { startedAt: now, entries: [] } + const joining = new Set(candidates.map((candidate) => candidate.sessionId)) + return { + startedAt: base.startedAt, + entries: [ + ...base.entries.filter((entry) => !joining.has(entry.candidate.sessionId)), + ...candidates.map((candidate) => ({ candidate, startedAt: now })) + ] + } +} + +export function settleResumeRunEntry( + run: ResumeRun | null, + sessionId: string, + result: ResumeRunResult +): ResumeRun | null { + if (!run) { + return run + } + return { + ...run, + entries: run.entries.map((entry) => + entry.candidate.sessionId === sessionId && entry.result === undefined + ? { ...entry, result } + : entry + ) + } +} + +/** + * One chat's own answer, read the way the toast reads it: an unconfirmed send the host stopped + * listing was seen carrying on, and a chat the host acted on without a verdict is unconfirmed. + */ +export function resumeRunResultOf( + sessionId: string, + continued: readonly RestartContinuationOutcome[] | undefined, + failed: readonly Pick[] | undefined +): ResumeRunResult { + const listed = failed?.find((failure) => failure.sessionId === sessionId) + if (listed) { + return listed.outcome + } + const outcome = continued?.find((entry) => entry.sessionId === sessionId)?.outcome + if (outcome === 'continued') { + return 'resumed' + } + if (outcome === 'pending' || outcome === 'unknown') { + return failed === undefined ? 'unconfirmed' : 'resumed' + } + if (outcome === 'refused') { + return failed === undefined ? 'refused' : 'gone' + } + // No verdict for it at all: an answer without outcomes may still have sent the message. + return continued === undefined ? 'unconfirmed' : 'gone' +} + +/** Ids still waiting on their own answer; one shared empty array keeps the snapshot stable. */ +const NONE: readonly string[] = [] +export function resumeRunPendingIds(run: ResumeRun | null): readonly string[] { + const pending = run?.entries.filter((entry) => entry.result === undefined) ?? [] + return pending.length === 0 ? NONE : pending.map((entry) => entry.candidate.sessionId) +} diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index be3b743b4244..2b6ce37e0560 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -5,8 +5,9 @@ import { translate } from '@/i18n/i18n' import { reopenNativeChatRestartOffer, useNativeChatRestartOffer, - useNativeChatRestartResuming + useNativeChatRestartRun } from '../native-chat-resume-on-restart-store' +import { resumeRunInFlight } from '../native-chat-resume-run' // Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is // then the only surface left carrying it, so it is always rendered rather than gated by @@ -51,30 +52,19 @@ function Segment({ type SegmentText = { label: string; ariaLabel: string; tooltip: string } -function resumingText(count: number): SegmentText { +/** Chats answered out of the chats asked, each counted as its own answer arrives. */ +function resumingText(done: number, total: number): SegmentText { return { - label: - count === 1 - ? translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingLabelOne', - 'Resuming 1 chat' - ) - : translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingLabel', - 'Resuming {{value0}} chats', - { value0: count } - ), - ariaLabel: - count === 1 - ? translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingAriaOne', - 'Resuming 1 chat. Click to open details.' - ) - : translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingAria', - 'Resuming {{value0}} chats. Click to open details.', - { value0: count } - ), + label: translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingProgressLabel', + 'Resuming chats {{value0}}/{{value1}}', + { value0: done, value1: total } + ), + ariaLabel: translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingProgressAria', + 'Resuming chats, {{value0}} of {{value1}} done. Click to open details.', + { value0: done, value1: total } + ), tooltip: translate( 'auto.components.status.bar.NativeChatResumeStatusSegment.resumingTooltip', 'Restoring interrupted chats and asking them to carry on…' @@ -152,27 +142,30 @@ export function NativeChatResumeStatusSegment({ }): React.JSX.Element | null { const offerEnabled = useNativeChatRestartOfferEnabled() const { candidates, failed } = useNativeChatRestartOffer(offerEnabled) - const resumingIds = useNativeChatRestartResuming() + const run = useNativeChatRestartRun() if (!offerEnabled) { return null } - // A chat being resumed is counted once, as in flight, until the host answers for it. - const inFlight = new Set(resumingIds) - const waiting = failed.filter((failure) => !inFlight.has(failure.sessionId)) - const resuming = inFlight.size - const pending = candidates.filter((candidate) => !inFlight.has(candidate.sessionId)).length + // While a run is moving, its chats are counted once, by it, until the host's list is read again. + const running = run !== null && resumeRunInFlight(run) + const inRun = new Set(running ? run.entries.map((entry) => entry.candidate.sessionId) : []) + const waiting = failed.filter((failure) => !inRun.has(failure.sessionId)) + const pending = candidates.filter((candidate) => !inRun.has(candidate.sessionId)).length const failures = waiting.length // An unconfirmed chat may be working, so "failed" would invite a duplicate "continue". const unconfirmed = waiting.some((failure) => failure.outcome === 'unconfirmed') return ( <> - {resuming > 0 && ( + {running && ( } - {...resumingText(resuming)} + {...resumingText( + run.entries.filter((entry) => entry.result !== undefined).length, + run.entries.length + )} /> )} {pending > 0 && ( diff --git a/src/renderer/src/components/use-resume-run-start-phases.ts b/src/renderer/src/components/use-resume-run-start-phases.ts new file mode 100644 index 000000000000..0456322e39e4 --- /dev/null +++ b/src/renderer/src/components/use-resume-run-start-phases.ts @@ -0,0 +1,36 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +type StartPhase = NonNullable + +const LOCAL = { kind: 'local' } as const + +/** + * Whether each chat's agent is still starting or has started, from the host's own status feed, so + * a resuming row can say where it is without the resume reporting it. Null: not started yet. + * + * One subscription for the whole list, selected as a joined string so the snapshot is a PRIMITIVE + * and a status change elsewhere does not re-render every row. + */ +export function useResumeRunStartPhases( + sessionIds: readonly string[] +): (sessionId: string) => StartPhase | null { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(LOCAL), []) + const active = sessionIds.length > 0 + useEffect(() => (active ? feed.activate() : undefined), [feed, active]) + const key = sessionIds.join('\0') + const joined = useSyncExternalStore(feed.subscribe, () => + key === '' + ? '' + : key + .split('\0') + .map((sessionId) => feed.getSnapshot().get(sessionId)?.hostExecutionPhase ?? '') + .join('\0') + ) + const phases = joined.split('\0') + return (sessionId: string) => { + const phase = phases[sessionIds.indexOf(sessionId)] + return phase === 'starting' || phase === 'ready' ? phase : null + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2b087c53efd2..f94fc0ff45bf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2127,7 +2127,8 @@ "activitySubagentOne": "Subagent running: {{value0}}", "activitySubagentMany": "{{value0}} subagents running", "activityMonitorOne": "Monitoring: {{value0}}", - "activityMonitorMany": "Monitoring {{value0}} background tasks" + "activityMonitorMany": "Monitoring {{value0}} background tasks", + "done": "Done" }, "StarNagCard": { "92b0f9d921": "is authenticated and try again.", @@ -4293,11 +4294,9 @@ "checkAriaOne": "1 chat to check after resuming. Click for details.", "checkAria": "{{value0}} chats to check after resuming. Click for details.", "checkTooltip": "Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details.", - "resumingLabelOne": "Resuming 1 chat", - "resumingLabel": "Resuming {{value0}} chats", - "resumingAriaOne": "Resuming 1 chat. Click to open details.", - "resumingAria": "Resuming {{value0}} chats. Click to open details.", - "resumingTooltip": "Restoring interrupted chats and asking them to carry on…" + "resumingTooltip": "Restoring interrupted chats and asking them to carry on…", + "resumingProgressLabel": "Resuming chats {{value0}}/{{value1}}", + "resumingProgressAria": "Resuming chats, {{value0}} of {{value1}} done. Click to open details." } } }, @@ -17815,6 +17814,31 @@ "hostOnly": "Cursor sign-in details are only readable on the computer running Orca." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "Resumed {{value0}} of {{value1}} chats", + "resumingTitleOne": "Resuming 1 chat", + "resumingTitle": "Resuming {{value0}} chats", + "description": "Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "Waiting for a reply", + "starting": "Starting", + "waitingToStart": "Waiting to start", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "Resumed" + }, + "NativeChatResumeRunSummary": { + "done": "{{value0}} of {{value1}} done", + "starting": "{{value0}} starting", + "waitingForReply": "{{value0}} waiting for a reply", + "waitingToStart": "{{value0}} waiting to start", + "all": "All", + "inProgress": "In progress", + "resumed": "Resumed", + "attention": "Need you", + "running": "Running {{value0}}", + "filter": "Show chats" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 43265aa6f455..34aa41845c59 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4041,16 +4041,14 @@ "checkAriaOne": "1 chat por revisar tras la reanudación. Haz clic para ver los detalles.", "checkAria": "{{value0}} chats por revisar tras la reanudación. Haz clic para ver los detalles.", "checkTooltip": "Chats que Orca no pudo reanudar, o no pudo confirmar que reanudó, tras el reinicio. Haz clic para ver los detalles.", - "resumingLabelOne": "Reanudando 1 chat", - "resumingLabel": "Reanudando {{value0}} chats", - "resumingAriaOne": "Reanudando 1 chat. Haz clic para ver los detalles.", - "resumingAria": "Reanudando {{value0}} chats. Haz clic para ver los detalles.", "resumingTooltip": "Restaurando los chats interrumpidos y pidiéndoles que continúen…", "labelOne": "1 chat para reanudar", "label": "{{value0}} chats para reanudar", "ariaLabelOne": "1 chat disponible para reanudar", "ariaLabel": "{{value0}} chats disponibles para reanudar", - "tooltip": "Abrir chats interrumpidos disponibles para reanudar" + "tooltip": "Abrir chats interrumpidos disponibles para reanudar", + "resumingProgressLabel": "Reanudando chats {{value0}}/{{value1}}", + "resumingProgressAria": "Reanudando chats, {{value0}} de {{value1}} listos. Haz clic para ver los detalles." }, "RuntimeHostStatusRow": { "previous_connection_closed": "La conexión anterior cerrada.", @@ -17373,7 +17371,8 @@ "selectAgent": "Reanudar {{value0}} chat \"{{value1}}\" en {{value2}}", "resumeSelected": "Reanudar {{value0}} chats", "resumeSelectedOne": "Reanudar 1 chat", - "dismissAll": "Descartar todo" + "dismissAll": "Descartar todo", + "done": "Listo" }, "NativeChatResumeOutcomeRow": { "openChat": "Abrir chat", @@ -17473,6 +17472,31 @@ "HostedReviewUnlinkMenuItem": { "label": "Desvincular {{value0}} del espacio de trabajo", "description": "Orca ocultará los detalles de {{value0}} {{value1}} en este espacio de trabajo. El {{value0}} y la rama en {{value2}} no se modificarán." + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "Se reanudaron {{value0}} de {{value1}} chats", + "resumingTitleOne": "Reanudando 1 chat", + "resumingTitle": "Reanudando {{value0}} chats", + "description": "Cada chat se restaura con todo su contexto y se le pide que revise dónde se detuvo. Puedes cerrar esto; la barra de estado sigue el progreso." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "Esperando respuesta", + "starting": "Iniciando", + "waitingToStart": "Esperando para iniciar", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "Reanudado" + }, + "NativeChatResumeRunSummary": { + "done": "{{value0}} de {{value1}} listos", + "starting": "{{value0}} iniciando", + "waitingForReply": "{{value0}} esperando respuesta", + "waitingToStart": "{{value0}} esperando para iniciar", + "all": "Todos", + "inProgress": "En curso", + "resumed": "Reanudados", + "attention": "Requieren tu atención", + "running": "En curso desde hace {{value0}}", + "filter": "Mostrar chats" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3fc2046a980b..ea87a80fbc42 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4164,11 +4164,9 @@ "checkAriaOne": "1 chat à vérifier après la reprise. Cliquez pour plus de détails.", "checkAria": "{{value0}} chats à vérifier après la reprise. Cliquez pour plus de détails.", "checkTooltip": "Chats qu'Orca n'a pas pu reprendre, ou dont il n'a pas pu confirmer la reprise, après le redémarrage. Cliquez pour plus de détails.", - "resumingLabelOne": "Reprise d’1 conversation", - "resumingLabel": "Reprise de {{value0}} conversations", - "resumingAriaOne": "Reprise d’1 conversation. Cliquez pour voir les détails.", - "resumingAria": "Reprise de {{value0}} conversations. Cliquez pour voir les détails.", - "resumingTooltip": "Restauration des conversations interrompues et demande de poursuite…" + "resumingTooltip": "Restauration des conversations interrompues et demande de poursuite…", + "resumingProgressLabel": "Reprise des discussions {{value0}}/{{value1}}", + "resumingProgressAria": "Reprise des discussions, {{value0}} sur {{value1}} terminées. Cliquez pour voir les détails." }, "provider": { "extra": { @@ -17494,7 +17492,8 @@ "activitySubagentOne": "Sous-agent en cours : {{value0}}", "activitySubagentMany": "{{value0}} sous-agents en cours", "activityMonitorOne": "Surveillance : {{value0}}", - "activityMonitorMany": "Surveillance de {{value0}} tâches en arrière-plan" + "activityMonitorMany": "Surveillance de {{value0}} tâches en arrière-plan", + "done": "Terminé" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "Filtrage des statuts des équipes {{value0}} sur {{value1}} : les problèmes des équipes restantes ne sont pas inclus.", @@ -17575,6 +17574,31 @@ "hostOnly": "Les détails de connexion Cursor ne sont lisibles que sur l'ordinateur qui exécute Orca." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "{{value0}} discussions sur {{value1}} reprises", + "resumingTitleOne": "Reprise d’une discussion", + "resumingTitle": "Reprise de {{value0}} discussions", + "description": "Chaque discussion est restaurée avec tout son contexte, et l’agent vérifie où il s’était arrêté. Vous pouvez fermer cette fenêtre ; la barre d’état suit la progression." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "En attente de réponse", + "starting": "Démarrage", + "waitingToStart": "En attente de démarrage", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "Reprise" + }, + "NativeChatResumeRunSummary": { + "done": "{{value0}} sur {{value1}} terminées", + "starting": "{{value0}} en démarrage", + "waitingForReply": "{{value0}} en attente de réponse", + "waitingToStart": "{{value0}} en attente de démarrage", + "all": "Toutes", + "inProgress": "En cours", + "resumed": "Reprises", + "attention": "À traiter", + "running": "En cours depuis {{value0}}", + "filter": "Afficher les discussions" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d6f74a559fec..18f363ea064a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4059,11 +4059,9 @@ "checkAriaOne": "再開後に確認が必要なチャット 1 件。詳細はクリックしてください。", "checkAria": "再開後に確認が必要なチャット {{value0}} 件。詳細はクリックしてください。", "checkTooltip": "再起動後に Orca が再開できなかった、または再開を確認できなかったチャット。詳細はクリックしてください。", - "resumingLabelOne": "1 件のチャットを再開中", - "resumingLabel": "{{value0}} 件のチャットを再開中", - "resumingAriaOne": "1 件のチャットを再開中。詳細はクリックしてください。", - "resumingAria": "{{value0}} 件のチャットを再開中。詳細はクリックしてください。", - "resumingTooltip": "中断されたチャットを復元し、続行を依頼しています…" + "resumingTooltip": "中断されたチャットを復元し、続行を依頼しています…", + "resumingProgressLabel": "チャットを再開中 {{value0}}/{{value1}}", + "resumingProgressAria": "チャットを再開中。{{value1}} 件中 {{value0}} 件完了。クリックすると詳細を表示します。" }, "provider": { "extra": { @@ -17378,7 +17376,8 @@ "activitySubagentOne": "サブ Agent 実行中: {{value0}}", "activitySubagentMany": "{{value0}} 個のサブ Agent が実行中", "activityMonitorOne": "監視中: {{value0}}", - "activityMonitorMany": "{{value0}} 個のバックグラウンドタスクを監視中" + "activityMonitorMany": "{{value0}} 個のバックグラウンドタスクを監視中", + "done": "完了" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "{{value0}}/{{value1}} チームステータスをフィルタリングしています。残りのチームからの問題は含まれません。", @@ -17474,6 +17473,31 @@ "hostOnly": "Cursor のサインイン詳細は、Orca を実行しているコンピュータでのみ読み取れます。" } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "{{value1}} 件中 {{value0}} 件のチャットを再開しました", + "resumingTitleOne": "1 件のチャットを再開中", + "resumingTitle": "{{value0}} 件のチャットを再開中", + "description": "各チャットは全コンテキストとともに復元され、中断した箇所を確認するよう依頼されます。このダイアログは閉じても構いません。進行状況はステータスバーで確認できます。" + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "返信を待っています", + "starting": "起動中", + "waitingToStart": "起動待ち", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "再開しました" + }, + "NativeChatResumeRunSummary": { + "done": "{{value1}} 件中 {{value0}} 件完了", + "starting": "{{value0}} 件起動中", + "waitingForReply": "{{value0}} 件が返信待ち", + "waitingToStart": "{{value0}} 件が起動待ち", + "all": "すべて", + "inProgress": "進行中", + "resumed": "再開済み", + "attention": "要対応", + "running": "経過 {{value0}}", + "filter": "表示するチャット" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b7d15e012b20..e4390ffe5ad1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4059,11 +4059,9 @@ "checkAriaOne": "재개한 후 확인할 채팅 1개. 자세히 보려면 클릭하세요.", "checkAria": "재개한 후 확인할 채팅 {{value0}}개. 자세히 보려면 클릭하세요.", "checkTooltip": "다시 시작한 후 Orca가 재개하지 못했거나 재개했는지 확인하지 못한 채팅. 자세히 보려면 클릭하세요.", - "resumingLabelOne": "채팅 1개 재개 중", - "resumingLabel": "채팅 {{value0}}개 재개 중", - "resumingAriaOne": "채팅 1개 재개 중. 자세히 보려면 클릭하세요.", - "resumingAria": "채팅 {{value0}}개 재개 중. 자세히 보려면 클릭하세요.", - "resumingTooltip": "중단된 채팅을 복원하고 계속하도록 요청하는 중…" + "resumingTooltip": "중단된 채팅을 복원하고 계속하도록 요청하는 중…", + "resumingProgressLabel": "채팅 재개 중 {{value0}}/{{value1}}", + "resumingProgressAria": "채팅 재개 중, {{value1}}개 중 {{value0}}개 완료. 자세히 보려면 클릭하세요." }, "provider": { "extra": { @@ -17378,7 +17376,8 @@ "activitySubagentOne": "하위 에이전트 실행 중: {{value0}}", "activitySubagentMany": "하위 에이전트 {{value0}}개 실행 중", "activityMonitorOne": "모니터링 중: {{value0}}", - "activityMonitorMany": "백그라운드 작업 {{value0}}개 모니터링 중" + "activityMonitorMany": "백그라운드 작업 {{value0}}개 모니터링 중", + "done": "완료" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "{{value1}} 팀 상태 중 {{value0}} 필터링 중 - 나머지 팀의 문제는 포함되지 않습니다.", @@ -17474,6 +17473,31 @@ "hostOnly": "Cursor 로그인 정보는 Orca가 실행 중인 컴퓨터에서만 읽을 수 있습니다." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "채팅 {{value1}}개 중 {{value0}}개를 재개했습니다", + "resumingTitleOne": "채팅 1개 재개 중", + "resumingTitle": "채팅 {{value0}}개 재개 중", + "description": "각 채팅은 전체 컨텍스트와 함께 복원되며, 멈춘 지점을 확인하도록 요청됩니다. 이 창을 닫아도 상태 표시줄에서 진행 상황을 계속 볼 수 있습니다." + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "응답 대기 중", + "starting": "시작 중", + "waitingToStart": "시작 대기 중", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "재개됨" + }, + "NativeChatResumeRunSummary": { + "done": "{{value1}}개 중 {{value0}}개 완료", + "starting": "{{value0}}개 시작 중", + "waitingForReply": "{{value0}}개 응답 대기 중", + "waitingToStart": "{{value0}}개 시작 대기 중", + "all": "전체", + "inProgress": "진행 중", + "resumed": "재개됨", + "attention": "확인 필요", + "running": "경과 {{value0}}", + "filter": "표시할 채팅" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 16e2d6f3d578..02ef637e1e06 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4059,11 +4059,9 @@ "checkAriaOne": "恢复后有 1 个待检查的聊天。点击查看详情。", "checkAria": "恢复后有 {{value0}} 个待检查的聊天。点击查看详情。", "checkTooltip": "重启后 Orca 未能恢复或未能确认已恢复的聊天。点击查看详情。", - "resumingLabelOne": "正在恢复 1 个聊天", - "resumingLabel": "正在恢复 {{value0}} 个聊天", - "resumingAriaOne": "正在恢复 1 个聊天。点击查看详情。", - "resumingAria": "正在恢复 {{value0}} 个聊天。点击查看详情。", - "resumingTooltip": "正在恢复中断的聊天并请它们继续…" + "resumingTooltip": "正在恢复中断的聊天并请它们继续…", + "resumingProgressLabel": "正在恢复聊天 {{value0}}/{{value1}}", + "resumingProgressAria": "正在恢复聊天,已完成 {{value0}}/{{value1}}。点击查看详情。" }, "provider": { "extra": { @@ -17378,7 +17376,8 @@ "activitySubagentOne": "子智能体运行中:{{value0}}", "activitySubagentMany": "{{value0}} 个子智能体运行中", "activityMonitorOne": "正在监控:{{value0}}", - "activityMonitorMany": "正在监控 {{value0}} 个后台任务" + "activityMonitorMany": "正在监控 {{value0}} 个后台任务", + "done": "完成" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "过滤 {{value1}} 团队状态中的 {{value0}} — 不包括其余团队的问题。", @@ -17474,6 +17473,31 @@ "hostOnly": "Cursor 登录详情仅可在运行 Orca 的电脑上读取。" } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "已恢复 {{value0}}/{{value1}} 个聊天", + "resumingTitleOne": "正在恢复 1 个聊天", + "resumingTitle": "正在恢复 {{value0}} 个聊天", + "description": "每个聊天都会带着完整上下文恢复,并被要求检查它停在了哪里。你可以关闭此窗口,状态栏会继续显示进度。" + }, + "NativeChatResumeRunStatusIcon": { + "waitingForReply": "等待回复", + "starting": "正在启动", + "waitingToStart": "等待启动", + "inFlight": "{{value0}} · {{value1}}", + "resumed": "已恢复" + }, + "NativeChatResumeRunSummary": { + "done": "已完成 {{value0}}/{{value1}}", + "starting": "{{value0}} 个正在启动", + "waitingForReply": "{{value0}} 个等待回复", + "waitingToStart": "{{value0}} 个等待启动", + "all": "全部", + "inProgress": "进行中", + "resumed": "已恢复", + "attention": "需要处理", + "running": "已运行 {{value0}}", + "filter": "显示聊天" } }, "i18n": { From cabd03c663d988535bd62b75a25bdc7c867df7db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:02:49 -0700 Subject: [PATCH 2/6] fix(native-chat): publish restart progress from one host action --- .../structured-agent-session-host-types.ts | 2 + .../structured-agent-session-host.ts | 3 +- ...ructured-agent-session-restart-cap.test.ts | 37 ++++ ...-agent-session-restart-offer-withdrawal.ts | 2 +- ...tured-agent-session-restart-resume-host.ts | 119 ++++++---- ...nt-session-restart-resume-progress.test.ts | 99 +++++++++ ...d-agent-session-restart-resume-progress.ts | 58 +++++ ...red-agent-session-restart-resume-runner.ts | 53 +++-- ...red-agent-session-restart-resume-wiring.ts | 1 + .../structured-agent-session-status-feed.ts | 2 + ...d-agent-session-status-summary-equality.ts | 1 + ...structured-agent-session-status-summary.ts | 2 + .../NativeChatResumeOnRestartAgentRow.tsx | 2 +- .../NativeChatResumeOnRestartModal.test.tsx | 208 +++-------------- .../components/NativeChatResumeRunPanel.tsx | 19 +- .../native-chat-resume-bulk-progress.test.tsx | 194 ++++++++++++++++ ...tive-chat-resume-failed-chat-watch.test.ts | 4 +- .../native-chat-resume-modal.test-support.tsx | 209 ++++++++++++++++++ .../native-chat-resume-on-restart-store.ts | 105 ++++----- .../components/native-chat-resume-run-view.ts | 42 ++-- .../components/native-chat-resume-run.test.ts | 201 +++++++---------- .../src/components/native-chat-resume-run.ts | 94 +------- .../NativeChatResumeStatusSegment.tsx | 16 +- .../components/use-resume-run-start-phases.ts | 36 --- .../components/use-resume-run-status-feed.ts | 51 +++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...agent-session-status-snapshot-fold.test.ts | 36 +++ .../agent-session-status-snapshot-fold.ts | 3 +- src/shared/agent-session-wire.ts | 3 + 34 files changed, 1039 insertions(+), 571 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts create mode 100644 src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx create mode 100644 src/renderer/src/components/native-chat-resume-modal.test-support.tsx delete mode 100644 src/renderer/src/components/use-resume-run-start-phases.ts create mode 100644 src/renderer/src/components/use-resume-run-status-feed.ts create mode 100644 src/shared/agent-session-status-snapshot-fold.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 2230efa15c9b..34fe554d31cd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -109,6 +109,8 @@ export type StructuredAgentSessionHostSession = { * observed exit. */ child: StructuredAgentSessionProviderChild | null lastEndedChild?: StructuredAgentSessionEndedChild + /** Owned by one live restart action, so another caller cannot replace its progress. */ + restartResume?: NonNullable & { operationId: string } } export type StructuredAgentSessionHostDeps = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index e0f193ba0b49..9a033b5f831b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -152,7 +152,8 @@ export class StructuredAgentSessionHost { }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), - readChildWork: this.clientDelivery.readChildWork + readChildWork: this.clientDelivery.readChildWork, + publishStatus: this.clientDelivery.publishStatusAndSettlement }) this.lifetime = createStructuredAgentSessionConversationLifetime({ context: () => this.lifetimeContext(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index 9be794967369..59adcf21a68e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts @@ -18,6 +18,43 @@ type Handover = { dispatchState: string; reason?: string | null } | undefined const LIMIT = 3 +it('publishes queued chats separately from the chat holding the start slot', async () => { + const first = Promise.withResolvers() + const phases = new Map() + const action = resumeStructuredAgentSessionsFromRestart( + { + admission: new StructuredAgentSessionResumeAdmission(1), + onPhase: (sessionId, phase) => phases.set(sessionId, phase), + consumeMarker: async () => true, + resume: async (sessionId) => { + if (sessionId === 'a') { + await first.promise + } + } + }, + ['a', 'b'].map((sessionId) => ({ + sessionId, + workspaceId: 'folder', + executionHostId: 'local', + workspaceKind: 'folder', + agent: 'codex', + work: { kind: 'turn', id: 'turn' }, + trigger: 'quit', + recordedAt: 1, + latestPrompt: '' + })), + 'modal' + ) + try { + await vi.waitFor(() => expect(phases.get('a')).toBe('starting')) + expect(phases.get('b')).toBe('queued') + } finally { + first.resolve() + await action + } + expect(phases.get('b')).toBe('starting') +}) + it('holds each slot from accept until handover or rejection, and frees it when the wait ends (P2-27)', async () => { const handovers = new Map>() let waiting = 0 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 4924860b05f2..96aabe1483bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts @@ -21,7 +21,7 @@ export type StructuredAgentSessionRestartOfferWithdrawal = ReturnType< export type StructuredAgentSessionRestartOfferSession = Pick< StructuredAgentSessionHostSession, - 'journal' | 'child' | 'lastEndedChild' + 'journal' | 'child' | 'lastEndedChild' | 'restartResume' > export function createStructuredAgentSessionRestartOfferWithdrawal(deps: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index decbdf72b265..88fcc8896a20 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -3,6 +3,7 @@ // action removes them — or files what went wrong. Starting the chat's agent again withdraws them. import { randomUUID } from 'node:crypto' +import { createRestartResumeProgress } from './structured-agent-session-restart-resume-progress' import { AgentSessionRefusalError, agentSessionRefusalFromReference @@ -145,7 +146,8 @@ export function createStructuredAgentSessionRestartResume( sessionIds: readonly string[] | undefined, owner: string, audience: StructuredAgentSessionRestartAudience | undefined, - continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise + continueOne: (marker: AgentSessionResumeMarker, continuationId: string) => Promise, + progress: ReturnType ) => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. @@ -182,6 +184,8 @@ export function createStructuredAgentSessionRestartResume( const outcomes = await resumeStructuredAgentSessionsFromRestart( { admission, + onPhase: progress.set, + onRefused: (sessionId) => progress.set(sessionId, 'refused'), consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) return ( @@ -219,55 +223,74 @@ export function createStructuredAgentSessionRestartResume( owner, audience ) => { - const continued: StructuredAgentSessionContinuationOutcome[] = [] - const verdicts: Promise[] = [] - // A chat holds its slot until its agent took the continuation or its start failed, so a batch - // never starts more agents at once than the runner allows; the provider's answer comes after. - const action = await run(sessionIds, owner, audience, async (marker, continuationId) => { - const started = await startStructuredAgentSessionContinuation( - continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), - marker.sessionId, - marker, - continuationId + const progress = createRestartResumeProgress(sessions, surfaces.publishStatus) + try { + const continued: StructuredAgentSessionContinuationOutcome[] = [] + const verdicts: Promise[] = [] + // A chat holds its slot until its agent took the continuation or its start failed, so a batch + // never starts more agents at once than the runner allows; the provider's answer comes after. + const action = await run( + sessionIds, + owner, + audience, + async (marker, continuationId) => { + const started = await startStructuredAgentSessionContinuation( + continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), + marker.sessionId, + marker, + continuationId + ) + if ('done' in started) { + continued.push(started.done) + progress.verdict(started.done) + const { outcome, reason, refusal } = started.done + if (outcome === 'refused') { + // Thrown as a refusal so the filed failure keeps its details beside the code. + throw refusal + ? new AgentSessionRefusalError( + agentSessionRefusalFromReference(refusal, refusal.code) + ) + : new Error(reason ?? 'agent_session_continuation_refused') + } + return + } + verdicts.push( + started.verdict().then((outcome) => { + continued.push(outcome) + progress.verdict(outcome) + }) + ) + }, + progress ) - if ('done' in started) { - continued.push(started.done) - const { outcome, reason, refusal } = started.done - if (outcome === 'refused') { - // Thrown as a refusal so the filed failure keeps its details beside the code. - throw refusal - ? new AgentSessionRefusalError(agentSessionRefusalFromReference(refusal, refusal.code)) - : new Error(reason ?? 'agent_session_continuation_refused') - } - return + await Promise.all(verdicts) + const resumed = action?.outcomes ?? [] + if (action) { + await failures.settle(action.operationId, resumed, { + candidates: action.candidates, + markers: action.markers, + failureAfterResume: (sessionId) => { + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome ? continuationFailureOutcome(outcome.outcome) : null + }, + failureReason: (sessionId) => { + const outcome = continued.find((entry) => entry.sessionId === sessionId) + return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' + } + }) } - verdicts.push(started.verdict().then((outcome) => void continued.push(outcome))) - }) - await Promise.all(verdicts) - const resumed = action?.outcomes ?? [] - if (action) { - await failures.settle(action.operationId, resumed, { - candidates: action.candidates, - markers: action.markers, - failureAfterResume: (sessionId) => { - const outcome = continued.find((entry) => entry.sessionId === sessionId) - return outcome ? continuationFailureOutcome(outcome.outcome) : null - }, - failureReason: (sessionId) => { - const outcome = continued.find((entry) => entry.sessionId === sessionId) - return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' - } - }) - } - continued.push(...unstartedRestartRefusals(resumed, continued)) - return { - resumed, - continued, - ...(await remainingRestartRows( - () => list(audience), - () => listFailures(audience), - deps.logger - )) + continued.push(...unstartedRestartRefusals(resumed, continued)) + return { + resumed, + continued, + ...(await remainingRestartRows( + () => list(audience), + () => listFailures(audience), + deps.logger + )) + } + } finally { + progress.clear() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts new file mode 100644 index 000000000000..514c7fcddde7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -0,0 +1,99 @@ +import * as runner from './structured-agent-session-restart-resume-runner' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { + interruptedRestart, + throwAfterContinuationAccepted +} from './structured-agent-session-restart-interruption-test-harness' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +afterEach(() => vi.restoreAllMocks()) + +it.each(['continued', 'refused', 'unconfirmed'] as const)( + 'publishes %s before bookkeeping finishes, then clears progress on return', + async (phase) => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + const summaries: AgentSessionStatusSummary[] = [] + const release = host.subscribeStatus({ + id: 'progress', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } + } + }) + if (phase === 'refused') { + acquire.mockRejectedValueOnce(new Error('start refused')) + } + if (phase === 'unconfirmed') { + throwAfterContinuationAccepted() + } + const bookkeeping = Promise.withResolvers() + const complete = AgentSessionRecoveryCapsule.prototype.completeResume + vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'completeResume').mockImplementation( + async function (this: AgentSessionRecoveryCapsule, ...args) { + await bookkeeping.promise + return complete.apply(this, args) + } + ) + let finished = false + const action = host.restartResume.continueAfterRestart([SESSION], 'modal').then((result) => { + finished = true + return result + }) + try { + await vi.waitFor(() => expect(summaries.at(-1)?.restartResume?.phase).toBe(phase)) + expect(finished).toBe(false) + expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('queued') + expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('starting') + // Reopening takes a snapshot of the same live action, without needing its earlier frames. + const snapshots: AgentSessionStatusSummary[] = [] + const closeSnapshot = host.subscribeStatus({ + id: 'reopen', + emit: (event) => { + if (event.type === 'snapshot') { + snapshots.push(...event.sessions) + } + } + }) + expect(snapshots.find((summary) => summary.sessionId === SESSION)?.restartResume?.phase).toBe( + phase + ) + closeSnapshot() + } finally { + bookkeeping.resolve() + await action + release() + } + expect(summaries.at(-1)).not.toHaveProperty('restartResume') + } +) + +it('clears progress when the resume action throws', async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + const summaries: AgentSessionStatusSummary[] = [] + const release = host.subscribeStatus({ + id: 'progress', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } + } + }) + const resume = runner.resumeStructuredAgentSessionsFromRestart + vi.spyOn(runner, 'resumeStructuredAgentSessionsFromRestart').mockImplementation( + async (...args) => { + await resume(...args) + throw new Error('resume action failed') + } + ) + await expect(host.restartResume.continueAfterRestart([SESSION], 'modal')).rejects.toThrow( + 'resume action failed' + ) + expect(summaries.some((summary) => summary.restartResume?.phase === 'starting')).toBe(true) + expect(summaries.at(-1)).not.toHaveProperty('restartResume') + release() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts new file mode 100644 index 000000000000..674c5b7a82dd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionRestartOfferSession } from './structured-agent-session-restart-offer-withdrawal' + +type Phase = NonNullable['phase'] + +/** Progress belongs to the action, never to the durable recovery obligation. */ +export function createRestartResumeProgress( + sessions: ReadonlyMap, + publish: ((sessionId: string) => void) | undefined +) { + const operationId = randomUUID() + const owned = new Map() + let active = true + return { + set(sessionId: string, phase: Phase): void { + if (!active) { + return + } + const session = sessions.get(sessionId) + if (!session) { + return + } + if (phase === 'queued') { + if (session.restartResume) { + return + } + owned.set(sessionId, session) + } else if (session.restartResume?.operationId !== operationId) { + return + } + session.restartResume = { operationId, phase } + publish?.(sessionId) + }, + verdict(outcome: StructuredAgentSessionContinuationOutcome): void { + this.set( + outcome.sessionId, + outcome.outcome === 'continued' + ? 'continued' + : outcome.outcome === 'refused' + ? 'refused' + : 'unconfirmed' + ) + }, + clear(): void { + active = false + for (const [sessionId, session] of owned) { + if (session.restartResume?.operationId !== operationId) { + continue + } + delete session.restartResume + publish?.(sessionId) + } + owned.clear() + } + } +} 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 242ab176ed5c..14471e808dc3 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 @@ -61,8 +61,7 @@ function resumeAdmissionOwner(error: unknown): string | null { * one row — and both would otherwise send the continuation twice, and leave the loser's * refusal looking like a real failure. The second caller is told who holds it instead. * - * The start limit lives here rather than per request because a client resumes each chat with its - * own request, so each chat's answer arrives as soon as it has one. + * The start limit spans callers, so simultaneous actions share the same host budget. */ export class StructuredAgentSessionResumeAdmission { private readonly owners = new Map() @@ -76,7 +75,12 @@ export class StructuredAgentSessionResumeAdmission { return this.owners.get(sessionId) ?? null } - async run(sessionId: string, owner: string, task: () => Promise): Promise { + async run( + sessionId: string, + owner: string, + task: () => Promise, + onPhase?: (phase: 'queued' | 'starting') => void + ): Promise { const live = this.owners.get(sessionId) if (live !== undefined) { throw new StructuredAgentSessionResumeInProgressError(live) @@ -84,8 +88,10 @@ export class StructuredAgentSessionResumeAdmission { // Owned while it waits for a slot, so a second caller is told it is already on its way. this.owners.set(sessionId, owner) try { + onPhase?.('queued') const release = await this.starts.acquire(0) try { + onPhase?.('starting') return await task() } finally { release() @@ -98,6 +104,8 @@ export class StructuredAgentSessionResumeAdmission { export type StructuredAgentSessionResumeRunnerDeps = { admission: StructuredAgentSessionResumeAdmission + onPhase?: (sessionId: string, phase: 'queued' | 'starting') => void + onRefused?: (sessionId: string) => void /** Validates this action's durable reservation. False means the candidate is no longer eligible. */ consumeMarker: (sessionId: string) => Promise /** Continues the reserved session; resolves once its agent took the message or refused it. */ @@ -110,7 +118,15 @@ export async function resumeStructuredAgentSessionsFromRestart( owner: string ): Promise { // The admission's start limit staggers these, shared with every other resume on this host. - return Promise.all(candidates.map((candidate) => resumeOne(deps, candidate.sessionId, owner))) + return Promise.all( + candidates.map(async (candidate) => { + const outcome = await resumeOne(deps, candidate.sessionId, owner) + if (outcome.outcome === 'refused') { + deps.onRefused?.(candidate.sessionId) + } + return outcome + }) + ) } async function resumeOne( @@ -119,19 +135,24 @@ async function resumeOne( owner: string ): Promise { try { - return await deps.admission.run(sessionId, owner, async () => { - // Validate BEFORE provider acquisition. The durable reservation is removed only after the - // action succeeds, and a failed acquisition reopens it for the next explicit attempt. - if (!(await deps.consumeMarker(sessionId))) { - return { - sessionId, - outcome: 'refused' as const, - reason: STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + return await deps.admission.run( + sessionId, + owner, + async () => { + // Validate BEFORE provider acquisition. The durable reservation is removed only after the + // action succeeds, and a failed acquisition reopens it for the next explicit attempt. + if (!(await deps.consumeMarker(sessionId))) { + return { + sessionId, + outcome: 'refused' as const, + reason: STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + } } - } - await deps.resume(sessionId) - return { sessionId, outcome: 'resumed' as const } - }) + await deps.resume(sessionId) + return { sessionId, outcome: 'resumed' as const } + }, + (phase) => deps.onPhase?.(sessionId, phase) + ) } catch (error) { const reason = error instanceof Error ? error.message : String(error) const owner = resumeAdmissionOwner(error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 1c17dc11e301..4a35bee89776 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -34,6 +34,7 @@ export type StructuredAgentSessionRestartResumeSurfaces = { ) => Promise<{ value: AgentSessionSendResult } | undefined> /** The session's child records, the host's one read of them. */ readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined + publishStatus?: (sessionId: string) => void now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index e053d401200f..2a26af994dcb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -46,6 +46,7 @@ type StatusFeedSession = { journal: AgentSessionJournal params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } child?: Pick | null + restartResume?: AgentSessionStatusSummary['restartResume'] } export type StructuredAgentSessionStatusFeedDeps = { @@ -164,6 +165,7 @@ export class StructuredAgentSessionStatusFeed { hostExecutionOwned: _hostExecutionOwned, hostExecutionPhase: _hostExecutionPhase, stopping: _stopping, + restartResume: _restartResume, ...retained } = previous this.published.set(sessionId, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index f5c3aba19781..ed9371e8e42c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -20,6 +20,7 @@ export function structuredStatusSummariesEqual( a.status === b.status && a.hostExecutionOwned === b.hostExecutionOwned && a.hostExecutionPhase === b.hostExecutionPhase && + a.restartResume?.phase === b.restartResume?.phase && a.rewindBlockedReason === b.rewindBlockedReason && // A moved state clock changes ranking; row activity alone, including a subagent's, does not. // An idle state the journal cannot date still republishes, since readers date it by `updatedAt`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 1b5c8d2b0924..70980484b772 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -22,6 +22,7 @@ export function structuredAgentSessionStatusSummary({ session: { params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] } child?: Pick | null + restartResume?: AgentSessionStatusSummary['restartResume'] } journal: AgentSessionJournal record: AgentSessionRecord | null @@ -47,6 +48,7 @@ export function structuredAgentSessionStatusSummary({ hostExecutionPhase: session.child.phase } : {}), + ...(session.restartResume ? { restartResume: { phase: session.restartResume.phase } } : {}), ...projected, // Only a working session is still being stopped; any other status already ended that work. ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index a4340a07c92c..a085da3cf8b9 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -119,7 +119,7 @@ export function ResumeCandidateRow({ {content} ) - if (!failure) { + if (!failure || status) { return
  • {row}
  • } const act = (action: ResumeFailureAction) => onFailureAction?.(action, candidate.sessionId) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 077903db4d5f..00d917380f88 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -1,186 +1,52 @@ // @vitest-environment happy-dom - +import { + createResumeModalFixture, + offered, + failure, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' import { act, StrictMode } from 'react' import { toast } from 'sonner' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { expect, it, vi, type Mock } from 'vitest' import { useAppStore } from '../store' import { getDefaultSettings } from '../../../shared/constants' import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' -import { TooltipProvider } from './ui/tooltip' import { lastToastShow } from './native-chat-resume-toast.test-support' -import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' import { - consumeNativeChatResumeOnRestartDialogRequest, getNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { - _resetNativeChatRestartOffer, getNativeChatRestartOffer, refreshNativeChatRestartOffer } from './native-chat-resume-on-restart-store' -const rpc = vi.hoisted(() => vi.fn()) -const activate = vi.hoisted(() => vi.fn(async () => true)) +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +const activate: Mock<() => Promise> = vi.hoisted(() => vi.fn(async () => true)) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, - // A failed row opens the status feed; these cases never drive it. - subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: (event: Parameters[0]) => void + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: activate })) vi.mock('sonner', () => ({ toast: vi.fn() })) -globalThis.IS_REACT_ACT_ENVIRONMENT = true -let root: Root -let container: HTMLDivElement -const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ - sessionId, - workspaceId: 'workspace', - agent: 'codex', - trigger: 'quit', - latestPrompt: `Prompt ${sessionId}`, - recordedAt: 1_800_000_000_000, - executionHostId: 'local', - workspaceKind: 'git-worktree' -})) - -/** A chat the host acted on and could not carry on, as it reports it. */ -function failure( - sessionId: string, - reason = 'agent_session_restart_work_superseded' -): ResumeFailure { - const candidate = offered.find((entry) => entry.sessionId === sessionId)! - return { - ...candidate, - failedAt: candidate.recordedAt + 60_000, - outcome: 'refused', - reason - } -} - -/** Outcome rows carry tooltips, so every mount needs the provider the app shell supplies. */ -async function mount(node: React.ReactNode): Promise { - await act(async () => root.render({node})) -} - -function button(text: string): HTMLButtonElement { - const found = [...document.querySelectorAll('button')].find( - (entry) => entry.textContent?.trim() === text || entry.getAttribute('aria-label') === text - ) - if (!found) { - throw new Error(`Missing button: ${text}`) - } - return found -} - -function checkbox(index: number): HTMLElement { - const found = document.querySelectorAll('[role="checkbox"]')[index] - if (!found) { - throw new Error(`Missing checkbox: ${index}`) - } - return found -} - -function offerIds(): string[] { - return getNativeChatRestartOffer().candidates.map((candidate) => candidate.sessionId) -} - -/** What each toast said: its title, and its description when it has one. */ -function toasts(): unknown[][] { - return vi - .mocked(toast) - .mock.calls.map(([title, options]) => - options?.description === undefined ? [title] : [title, options.description] - ) -} - -type Verdict = 'continued' | 'refused' | 'unknown' | 'skipped' - -/** - * The host as these cases need it: what it lists, and what resuming ONE chat does to that list. - * The client resumes each chat with its own request; `hold` keeps a chat's answer back. - */ -function fakeHost( - init: { sessions?: ResumeCandidate[]; failed?: ResumeFailure[] } = {}, - verdictFor: (sessionId: string) => Verdict = () => 'continued', - reason = 'agent_session_restart_work_superseded' -) { - const state = { sessions: [...(init.sessions ?? offered)], failed: [...(init.failed ?? [])] } - const held = new Map>() - rpc.mockImplementation(async (_target, method, params: { sessionIds?: string[] } | undefined) => { - if (method === 'agentSession.restartResumable') { - return { sessions: state.sessions, failed: state.failed } - } - const [sessionId] = params?.sessionIds ?? [] - await held.get(sessionId!)?.promise - const verdict = verdictFor(sessionId!) - if (verdict === 'skipped') { - return { resumed: [], continued: [], sessions: state.sessions, failed: state.failed } - } - state.sessions = state.sessions.filter((entry) => entry.sessionId !== sessionId) - state.failed = state.failed.filter((entry) => entry.sessionId !== sessionId) - if (verdict !== 'continued') { - const outcome = verdict === 'refused' ? 'refused' : 'unconfirmed' - state.failed.push({ ...failure(sessionId!, reason), outcome }) - } - return { - resumed: [{ sessionId, outcome: 'resumed' }], - continued: [{ sessionId, outcome: verdict }], - sessions: state.sessions, - failed: state.failed - } - }) - return { - state, - hold: (...sessionIds: string[]) => - sessionIds.forEach((sessionId) => held.set(sessionId, Promise.withResolvers())), - release: async (...sessionIds: string[]) => - act(async () => sessionIds.forEach((sessionId) => held.get(sessionId)?.resolve())) - } -} - -/** The status icon a run shows in a chat's checkbox slot, found by its chat and what it says. */ -function runStatus(prompt: string): string | null { - return ( - document.querySelector(`[role="img"][aria-label^="${prompt}: "]`)?.getAttribute('aria-label') ?? - null - ) -} - -function calls(): unknown[][] { - return rpc.mock.calls.map((call) => [call[1], call[2]]) -} - -beforeEach(() => { - rpc.mockReset() - _resetNativeChatRestartOffer() - consumeNativeChatResumeOnRestartDialogRequest() - vi.mocked(toast).mockClear() - useAppStore.setState(useAppStore.getInitialState(), true) - useAppStore.setState({ - settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, - updateSettings: async (changes) => { - useAppStore.setState((state) => ({ - settings: { ...getDefaultSettings(''), ...state.settings, ...changes } - })) - } - }) - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => root.unmount()) - container.remove() - useAppStore.setState(useAppStore.getInitialState(), true) - _resetNativeChatRestartOffer() - consumeNativeChatResumeOnRestartDialogRequest() -}) +const { mount, button, checkbox, offerIds, toasts, fakeHost, runStatus, calls } = + createResumeModalFixture(rpc, statusStream) it('keeps next-launch preference out of the current resume action', async () => { const host = fakeHost() @@ -195,8 +61,8 @@ it('keeps next-launch preference out of the current resume action', async () => ['agentSession.restartContinue', { sessionIds: ['a'] }] ]) await host.release('a') - // One read of what is left once the chat answered; nothing else is sent. - expect(calls().slice(2)).toEqual([['agentSession.restartResumable', undefined]]) + // The reply already carries the remaining list. + expect(calls().slice(2)).toEqual([]) }) // One primary action and one way out of it; the body copy carries the transparency. @@ -457,12 +323,10 @@ it('resumes and continues once when the launch begins opted in', async () => { await act(async () => useAppStore.getState().updateSettings({ experimentalStructuredNativeChat: true }) ) - // One request per chat, so each answers on its own; then one read of what is left. + // One bulk action, with its remaining list in the reply. expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], - ['agentSession.restartContinue', { sessionIds: ['a'] }], - ['agentSession.restartContinue', { sessionIds: ['b'] }], - ['agentSession.restartResumable', undefined] + ['agentSession.restartContinue', { sessionIds: ['a', 'b'] }] ]) // Answered once, as a click is, however often the settings above re-render the surfaces. expect(toasts()).toEqual([['Resumed 2 chats']]) @@ -525,7 +389,7 @@ it('dispatches the selected action while a future preference save is still pendi await act(async () => button('Resume 1 chat').click()) expect(calls()).toContainEqual(['agentSession.restartContinue', { sessionIds: ['a'] }]) await act(async () => saved.reject(new Error('settings write failed'))) - expect(rpc).toHaveBeenCalledTimes(3) + expect(rpc).toHaveBeenCalledTimes(2) }) // The click gets one toast; the host's failure list stays in the status bar. Both keep an unconfirmed @@ -546,8 +410,8 @@ it.each([ await act(async () => button('Resume 2 chats').click()) expect(button(label)).toBeTruthy() expect(toasts()).toEqual([[said]]) - // A read, one request per chat, and one read of what is left. - expect(rpc).toHaveBeenCalledTimes(4) + // One offer read and one bulk action. + expect(rpc).toHaveBeenCalledTimes(2) } ) @@ -601,8 +465,7 @@ it('reports the chats a lost resume request named once, as failed, until it is r // A lost action response is followed by a read-only reconciliation, never a retry. expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], - ['agentSession.restartContinue', { sessionIds: ['a'] }], - ['agentSession.restartContinue', { sessionIds: ['b'] }], + ['agentSession.restartContinue', { sessionIds: ['a', 'b'] }], ['agentSession.restartResumable', undefined] ]) expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -627,10 +490,7 @@ it('reports the chats a lost resume request named once, as failed, until it is r 'Orca couldn’t resume this chat. Open it to continue manually.' ) await act(async () => button('Retry').click()) - expect(calls().slice(-2)).toEqual([ - ['agentSession.restartContinue', { sessionIds: ['b'] }], - ['agentSession.restartResumable', undefined] - ]) + expect(calls().at(-1)).toEqual(['agentSession.restartContinue', { sessionIds: ['b'] }]) expect(getNativeChatRestartOffer()).toMatchObject({ candidates: [], failed: [] }) expect(toasts().at(-1)).toEqual(['Resumed 1 chat']) }) @@ -749,7 +609,7 @@ it.each(['footer', 'row'] as const)( } else { // A row action leaves the dialog open, where the retried chat shows how it went. expect(document.querySelector('[role="dialog"]')?.textContent).toContain( - 'Resumed 1 of 1 chats' + 'Resumed 1 of 1 chat' ) expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') } diff --git a/src/renderer/src/components/NativeChatResumeRunPanel.tsx b/src/renderer/src/components/NativeChatResumeRunPanel.tsx index 6ce2f7b47d8d..7a581de5ba6c 100644 --- a/src/renderer/src/components/NativeChatResumeRunPanel.tsx +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -7,7 +7,7 @@ import { resumeRunInFlight, type ResumeRun } from './native-chat-resume-run' import { resumeRunView, type ResumeRunFilter } from './native-chat-resume-run-view' import { ResumeRunStatusIcon } from './NativeChatResumeRunStatusIcon' import { ResumeRunSummary } from './NativeChatResumeRunSummary' -import { useResumeRunStartPhases } from './use-resume-run-start-phases' +import { useResumeRunStatusFeed } from './use-resume-run-status-feed' /** * The dialog's view of a run it is following: its title, the progress line and filters, the rows @@ -26,6 +26,13 @@ export type ResumeRunPanel = { function runTitle(run: ResumeRun, total: number, resumed: number): React.ReactNode { if (!resumeRunInFlight(run)) { + if (total === 1) { + return translate( + 'auto.components.NativeChatResumeRunPanel.resumedTitleOne', + 'Resumed {{value0}} of 1 chat', + { value0: resumed } + ) + } return translate( 'auto.components.NativeChatResumeRunPanel.resumedTitle', 'Resumed {{value0}} of {{value1}} chats', @@ -68,13 +75,19 @@ export function useResumeRunPanel({ const inFlight = run !== null && resumeRunInFlight(run) // Ticks only while a visible run is moving: the row timers and the running clock read it. const now = useNow(1000, open && inFlight) - const view = run ? resumeRunView(run, rows, failureFor, filter.value) : null + const hostStatusFor = useResumeRunStatusFeed( + inFlight && run ? run.entries.map((entry) => entry.candidate.sessionId) : [] + ) + const view = run ? resumeRunView(run, rows, failureFor, filter.value, hostStatusFor) : null const inFlightIds = view ? [...view.statusBySession].flatMap(([sessionId, status]) => status.kind === 'in-flight' ? [sessionId] : [] ) : [] - const phaseFor = useResumeRunStartPhases(inFlightIds) + const phaseFor = (sessionId: string) => { + const status = view?.statusBySession.get(sessionId) + return status?.kind === 'in-flight' ? status.phase : null + } if (!run || !view) { return null } diff --git a/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx new file mode 100644 index 000000000000..10b9b8088026 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { + createResumeModalFixture, + offered, + failure, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { + continueNativeChatRestartOffer, + getNativeChatRestartOffer, + refreshNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: (event: Parameters[0]) => void + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: vi.fn(async () => true) +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, checkbox, offerIds, toasts, fakeHost, runStatus } = createResumeModalFixture( + rpc, + statusStream +) + +it('resumes a 21-chat selection with one action and no redundant listing', async () => { + const candidates = Array.from({ length: 21 }, (_, index) => ({ + ...offered[0]!, + sessionId: `chat-${index}` + })) + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: candidates, failed: [] } + : { + sessions: [], + failed: [], + continued: candidates.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })) + } + ) + await refreshNativeChatRestartOffer() + await continueNativeChatRestartOffer(candidates.map(({ sessionId }) => sessionId)) + expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + ['agentSession.restartResumable', undefined], + ['agentSession.restartContinue', { sessionIds: candidates.map(({ sessionId }) => sessionId) }] + ]) + expect(toasts()).toEqual([['Resumed 21 chats']]) +}) + +it('uses the host unconfirmed result after a lost bulk reply without reporting a failure', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let reads = 0 + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartContinue') { + throw new Error('reply lost') + } + return reads++ === 0 + ? { sessions: offered, failed: [] } + : { + sessions: [], + failed: [ + { ...failure('a'), outcome: 'unconfirmed' }, + { ...failure('b'), outcome: 'unconfirmed' } + ] + } + }) + try { + await refreshNativeChatRestartOffer() + await continueNativeChatRestartOffer(['a', 'b']) + expect(toasts()).toEqual([['Couldn’t confirm 2 chats were resumed']]) + expect(getNativeChatRestartOffer().failed.map((row) => row.outcome)).toEqual([ + 'unconfirmed', + 'unconfirmed' + ]) + } finally { + warn.mockRestore() + } +}) + +it('publishes a refusal reply without needing a second host read', async () => { + let reads = 0 + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + if (reads++ > 0) { + throw new Error('listing unavailable') + } + return { sessions: offered, failed: [] } + } + return { + sessions: [offered[1]], + failed: [failure('a')], + continued: [{ sessionId: 'a', outcome: 'refused' }] + } + }) + await mount() + await act(async () => checkbox(1).click()) + await act(async () => button('Resume 1 chat').click()) + expect(reads).toBe(1) + expect(offerIds()).toEqual(['b']) + expect(getNativeChatRestartOffer().failed.map((row) => row.sessionId)).toEqual(['a']) + expect(toasts()).toEqual([['1 chat couldn’t be resumed']]) +}) + +it('keeps the run in flight after every feed verdict until the fallback list is published', async () => { + const answer = Promise.withResolvers<{ + continued: { sessionId: string; outcome: 'continued' }[] + }>() + const listing = Promise.withResolvers<{ sessions: ResumeCandidate[]; failed: ResumeFailure[] }>() + let reads = 0 + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartContinue' + ? answer.promise + : reads++ === 0 + ? { sessions: offered, failed: [] } + : listing.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) + await act(async () => { + for (const { sessionId } of offered) { + statusStream.emit({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + restartResume: { phase: 'continued' } + } + }) + } + answer.resolve({ + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })) + }) + }) + expect(button('Resuming chats 2/2')).toBeTruthy() + expect(button('Resuming…').disabled).toBe(true) + expect(document.body.textContent).not.toContain('chats to resume') + await act(async () => listing.resolve({ sessions: [], failed: [] })) + expect(offerIds()).toEqual([]) + expect(button('Done').disabled).toBe(false) + expect(document.body.textContent).not.toContain('Resuming chats') +}) + +it('a retry hides its old failure, then a dismissed final failure leaves Need you', async () => { + const host = fakeHost( + { sessions: [], failed: [failure('b', 'agent_session_conflict')] }, + () => 'refused', + 'agent_session_conflict' + ) + host.hold('b') + await mount() + await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => button('Retry').click()) + expect(runStatus('Prompt b')).toMatch(/Waiting to start/) + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Another Orca') + expect(document.querySelector('[aria-label*="Couldn’t resume"]')).toBeNull() + await host.release('b') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you1') + const dismiss = document.querySelector('button[aria-label^="Dismiss"]') + expect(dismiss).not.toBeNull() + await act(async () => dismiss?.click()) + expect(getNativeChatRestartOffer().failed).toEqual([]) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you0') + expect(runStatus('Prompt b')).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts index 669a370ddef8..81e90f10c914 100644 --- a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts +++ b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts @@ -194,7 +194,7 @@ it('waits for a resume in flight instead of re-reading under it', async () => { await vi.advanceTimersByTimeAsync(500) expect(offerReads()).toBe(1) - // The resume's own read of what is left, once the chat answered, is the only one. + // The action reply carries the remaining list, so no extra read is needed. failed = [] acting.resolve({ sessions: [], @@ -202,6 +202,6 @@ it('waits for a resume in flight instead of re-reading under it', async () => { continued: [{ sessionId: 'a', outcome: 'continued' }] }) await retry - expect(offerReads()).toBe(2) + expect(offerReads()).toBe(1) expect(getNativeChatRestartOffer().failed).toEqual([]) }) diff --git a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx new file mode 100644 index 000000000000..6d774b33601d --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx @@ -0,0 +1,209 @@ +import { act } from 'react' +import { toast } from 'sonner' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { useAppStore } from '../store' +import { getDefaultSettings } from '../../../shared/constants' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { TooltipProvider } from './ui/tooltip' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { consumeNativeChatResumeOnRestartDialogRequest } from './native-chat-resume-on-restart-dialog' +import { + _resetNativeChatRestartOffer, + getNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' + +export const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ + sessionId, + workspaceId: 'workspace', + agent: 'codex', + trigger: 'quit', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1_800_000_000_000, + executionHostId: 'local', + workspaceKind: 'git-worktree' +})) + +/** A chat the host acted on and could not carry on, as it reports it. */ +export function failure( + sessionId: string, + reason = 'agent_session_restart_work_superseded' +): ResumeFailure { + const candidate = offered.find((entry) => entry.sessionId === sessionId)! + return { + ...candidate, + failedAt: candidate.recordedAt + 60_000, + outcome: 'refused', + reason + } +} + +export type RestartRpc = ( + target: unknown, + method: string, + params?: { sessionIds?: string[] } +) => Promise +export type ResumeStatusStream = { + emit: (event: AgentSessionStatusEvent) => void + snapshot: Map +} + +export function createResumeModalFixture(rpc: Mock, statusStream: ResumeStatusStream) { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + let root: Root + let container: HTMLDivElement + /** Outcome rows carry tooltips, so every mount needs the provider the app shell supplies. */ + async function mount(node: React.ReactNode): Promise { + await act(async () => root.render({node})) + } + + function button(text: string): HTMLButtonElement { + const found = [...document.querySelectorAll('button')].find( + (entry) => entry.textContent?.trim() === text || entry.getAttribute('aria-label') === text + ) + if (!found) { + throw new Error(`Missing button: ${text}`) + } + return found + } + + function checkbox(index: number): HTMLElement { + const found = document.querySelectorAll('[role="checkbox"]')[index] + if (!found) { + throw new Error(`Missing checkbox: ${index}`) + } + return found + } + + function offerIds(): string[] { + return getNativeChatRestartOffer().candidates.map((candidate) => candidate.sessionId) + } + + /** What each toast said: its title, and its description when it has one. */ + function toasts(): unknown[][] { + return vi + .mocked(toast) + .mock.calls.map(([title, options]) => + options?.description === undefined ? [title] : [title, options.description] + ) + } + + type Verdict = 'continued' | 'refused' | 'unknown' | 'skipped' + + /** A bulk host action publishes each verdict before returning its authoritative list. */ + function fakeHost( + init: { sessions?: ResumeCandidate[]; failed?: ResumeFailure[] } = {}, + verdictFor: (sessionId: string) => Verdict = () => 'continued', + reason = 'agent_session_restart_work_superseded' + ) { + const state = { sessions: [...(init.sessions ?? offered)], failed: [...(init.failed ?? [])] } + const held = new Map>() + rpc.mockImplementation( + async (_target, method, params: { sessionIds?: string[] } | undefined) => { + if (method === 'agentSession.restartResumable') { + return { sessions: state.sessions, failed: state.failed } + } + if (method === 'agentSession.restartResumableDismiss') { + state.failed = state.failed.filter( + (entry) => !params?.sessionIds?.includes(entry.sessionId) + ) + state.sessions = state.sessions.filter( + (entry) => !params?.sessionIds?.includes(entry.sessionId) + ) + return { sessions: state.sessions, failed: state.failed } + } + const continued = await Promise.all( + (params?.sessionIds ?? []).map(async (sessionId) => { + await held.get(sessionId)?.promise + const verdict = verdictFor(sessionId) + if (verdict === 'skipped') { + return [] + } + state.sessions = state.sessions.filter((entry) => entry.sessionId !== sessionId) + state.failed = state.failed.filter((entry) => entry.sessionId !== sessionId) + const phase = verdict === 'unknown' ? 'unconfirmed' : verdict + if (verdict !== 'continued') { + state.failed.push({ + ...failure(sessionId, reason), + outcome: phase === 'refused' ? 'refused' : 'unconfirmed' + }) + } + const summary: AgentSessionStatusSummary = { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + restartResume: { phase } + } + statusStream.snapshot.set(sessionId, summary) + statusStream.emit({ type: 'status', session: summary }) + return [{ sessionId, outcome: verdict }] + }) + ) + return { + resumed: [], + continued: continued.flat(), + sessions: state.sessions, + failed: state.failed + } + } + ) + return { + state, + hold: (...sessionIds: string[]) => + sessionIds.forEach((sessionId) => held.set(sessionId, Promise.withResolvers())), + release: async (...sessionIds: string[]) => + act(async () => sessionIds.forEach((sessionId) => held.get(sessionId)?.resolve())) + } + } + + /** The status icon a run shows in a chat's checkbox slot, found by its chat and what it says. */ + function runStatus(prompt: string): string | null { + return ( + document + .querySelector(`[role="img"][aria-label^="${prompt}: "]`) + ?.getAttribute('aria-label') ?? null + ) + } + + function calls(): unknown[][] { + return rpc.mock.calls.map((call) => [call[1], call[2]]) + } + + beforeEach(() => { + rpc.mockReset() + resetStructuredAgentSessionStatusFeedsForTests() + statusStream.snapshot.clear() + statusStream.emit = () => {} + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + vi.mocked(toast).mockClear() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true }, + updateSettings: async (changes) => { + useAppStore.setState((state) => ({ + settings: { ...getDefaultSettings(''), ...state.settings, ...changes } + })) + } + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + }) + return { mount, button, checkbox, offerIds, toasts, fakeHost, runStatus, calls } +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index e23668fbce63..5c65b4752d96 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -13,11 +13,9 @@ import { type ResumeFailure } from './native-chat-resume-on-restart-grouping' import { - beginResumeRunEntries, + beginResumeRun, resumeRunInFlight, resumeRunPendingIds, - resumeRunResultOf, - settleResumeRunEntry, type ResumeRun } from './native-chat-resume-run' import { @@ -205,51 +203,7 @@ export async function reopenNativeChatRestartOffer(): Promise { } } -/** One chat's own answer; a request lost before reaching the host is marked unsent. `listed`: the - * answer carried the host's failure list, without which "no longer failed" proves nothing. */ -type ChatAnswer = { - sessionId: string - continued: RestartContinuationOutcome[] | null - listed: boolean -} - -async function continueOneChat(sessionId: string): Promise { - try { - const result = await callStructuredAgentSession< - HostOfferPayload & { continued?: RestartContinuationOutcome[] } - >(LOCAL, 'agentSession.restartContinue', { sessionIds: [sessionId] }) - const continued = Array.isArray(result.continued) ? result.continued : undefined - const failed = Array.isArray(result.failed) ? failedFrom(result) : undefined - setRun(settleResumeRunEntry(run, sessionId, resumeRunResultOf(sessionId, continued, failed))) - // An answer without outcomes may still have sent the message, so it counts as unconfirmed. - return { - sessionId, - continued: continued ?? [{ sessionId, outcome: 'unknown' }], - listed: failed !== undefined - } - } catch (error) { - // The row's reason is this side's own code, so the real error is kept in the log. - console.warn('[native-chat-resume] resume request failed before reaching the chat', error) - markUnsentResumes([sessionId], Date.now()) - setRun(settleResumeRunEntry(run, sessionId, 'refused')) - return { sessionId, continued: null, listed: true } - } -} - -/** - * Reattach the named chats, ask each agent to carry on, then replace the offer with the host's - * authoritative remaining list. This keeps the modal and status bar synchronized after every - * action, even when the dialog's snapshot became stale while it was open. - * - * Each chat is its OWN request, so its row settles when its agent answers, not when the slowest in - * the batch does. The host still staggers starts, with one limit across every request, and still - * re-derives eligibility for each chat it is named. - * - * Ends in one toast saying what it did across those chats, whether a click or an opted-in launch - * started it; each chat's note stays the record. Never rejects; a lost answer is followed by a - * re-read, never a retry. A chat whose request was lost and the host still offers shows as failed, - * with Retry. - */ +/** One host action owns the selection; its status feed reports each chat before the reply. */ export async function continueNativeChatRestartOffer(sessionIds: readonly string[]): Promise { const requested = [...new Set(sessionIds)] if (requested.length === 0) { @@ -258,33 +212,48 @@ export async function continueNativeChatRestartOffer(sessionIds: readonly string actionsBegun += 1 forgetUnsentResumes(requested) const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row])) - setRun( - beginResumeRunEntries( - run, - requested.flatMap((sessionId) => rows.get(sessionId) ?? []), - Date.now() - ) + const actionRun = beginResumeRun( + requested.flatMap((sessionId) => rows.get(sessionId) ?? []), + Date.now() ) - // `resuming` now names the chats, so the launch's one resume decision is made. + setRun(actionRun) markNativeChatLaunchResumeDecided() - let listed: readonly ResumeFailure[] | undefined - const answers = await Promise.all(requested.map(continueOneChat)) + let outcome: Parameters + let continued: readonly RestartContinuationOutcome[] | undefined try { + const result = await callStructuredAgentSession< + HostOfferPayload & { continued?: RestartContinuationOutcome[] } + >(LOCAL, 'agentSession.restartContinue', { sessionIds: requested }) + const listed = Array.isArray(result.failed) ? failedFrom(result) : undefined + if (Array.isArray(result.sessions)) { + publishAnswer({ candidates: result.sessions, failed: listed ?? [], listedAt: Date.now() }) + } else { + await refreshNativeChatRestartOffer() + } + continued = result.continued + outcome = [requested, result.continued, listed, reopenNativeChatRestartOffer] + } catch (error) { + console.warn('[native-chat-resume] resume request failed before reaching the chats', error) + markUnsentResumes(requested, Date.now()) const read = await readNativeChatRestartOffer() - listed = read.available && answers.every((answer) => answer.listed) ? offer.failed : undefined + outcome = [ + requested, + [], + read.available ? offer.failed : undefined, + reopenNativeChatRestartOffer + ] } finally { actionsSettled += 1 - // Nothing on screen can follow a finished run once the dialog is closed. - if (!resumeRunInFlight(run) && !getNativeChatResumeOnRestartDialogRequest()) { - setRun(null) + // Publish the offer first: reopening must never expose the old selection as actionable. + if (run === actionRun) { + setRun( + getNativeChatResumeOnRestartDialogRequest() + ? { ...actionRun, inFlight: false, continued } + : null + ) } } - announceRestartResults( - requested, - answers.flatMap((answer) => answer.continued ?? []), - listed, - reopenNativeChatRestartOffer - ) + announceRestartResults(...outcome) } /** @@ -378,7 +347,7 @@ export function useNativeChatRestartResuming(): readonly string[] { return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming) } -/** The run a reopened dialog follows: each chat it asked, and what each has answered so far. */ +/** The selection and reply history a reopened progress dialog follows. */ export function useNativeChatRestartRun(): ResumeRun | null { return useSyncExternalStore(subscribe, getNativeChatRestartRun, getNativeChatRestartRun) } diff --git a/src/renderer/src/components/native-chat-resume-run-view.ts b/src/renderer/src/components/native-chat-resume-run-view.ts index 0f08bc05fc5f..3a292aa66628 100644 --- a/src/renderer/src/components/native-chat-resume-run-view.ts +++ b/src/renderer/src/components/native-chat-resume-run-view.ts @@ -1,5 +1,6 @@ import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' import type { ResumeRun } from './native-chat-resume-run' +import type { ResumeRunHostStatus } from './use-resume-run-status-feed' /** * What the dialog shows while it follows a run: one row per chat, each with where it stands. @@ -10,7 +11,7 @@ import type { ResumeRun } from './native-chat-resume-run' /** Where one chat of the run stands, as the leading icon shows it in place of the checkbox. */ export type ResumeRunRowStatus = - | { kind: 'in-flight'; startedAt: number } + | { kind: 'in-flight'; startedAt: number; phase: 'starting' | 'ready' | null } | { kind: 'resumed' } | { kind: 'refused' } | { kind: 'unconfirmed' } @@ -42,31 +43,44 @@ export function resumeRunView( run: ResumeRun, listed: readonly ResumeCandidate[], failureFor: (sessionId: string) => ResumeFailure | undefined, - filter: ResumeRunFilter + filter: ResumeRunFilter, + hostStatusFor: (sessionId: string) => ResumeRunHostStatus | undefined = () => undefined ): ResumeRunView { const listedById = new Map(listed.map((row) => [row.sessionId, row])) + const continuedBySession = new Map( + run.continued?.map((entry) => [entry.sessionId, entry.outcome]) + ) const statusBySession = new Map() const placed: { row: ResumeCandidate; category: Category; index: number }[] = [] const inRun = new Set() for (const entry of run.entries) { const { sessionId } = entry.candidate inRun.add(sessionId) - if (entry.result === 'gone') { - continue - } const row = listedById.get(sessionId) ?? entry.candidate - if (entry.result === undefined) { - statusBySession.set(sessionId, { kind: 'in-flight', startedAt: entry.startedAt }) + const failure = failureFor(sessionId) + const hostStatus = run.inFlight ? hostStatusFor(sessionId) : undefined + const phase = hostStatus?.restartResume?.phase + if (run.inFlight && (phase === undefined || phase === 'queued' || phase === 'starting')) { + const hostPhase = hostStatus?.hostExecutionPhase + statusBySession.set(sessionId, { + kind: 'in-flight', + startedAt: run.startedAt, + phase: phase === 'starting' ? (hostPhase === 'ready' ? 'ready' : 'starting') : null + }) placed.push({ row, category: 'in-progress', index: placed.length }) - } else if (entry.result === 'resumed') { + } else if ( + phase === 'continued' || + (!run.inFlight && !failure && continuedBySession.get(sessionId) === 'continued') + ) { statusBySession.set(sessionId, { kind: 'resumed' }) placed.push({ row, category: 'resumed', index: placed.length }) - } else { - // Until the host lists it, the run's own answer is all there is to show. - if (!failureFor(sessionId)) { - statusBySession.set(sessionId, { kind: entry.result }) - } + } else if (run.inFlight && (phase === 'refused' || phase === 'unconfirmed')) { + statusBySession.set(sessionId, { kind: phase }) + placed.push({ row, category: 'attention', index: placed.length }) + } else if (failure) { placed.push({ row, category: 'attention', index: placed.length }) + } else if (listedById.has(sessionId)) { + placed.push({ row, category: 'other', index: placed.length }) } } for (const row of listed) { @@ -76,7 +90,7 @@ export function resumeRunView( } } const count = (category: Category) => placed.filter((entry) => entry.category === category).length - const inRunCount = placed.filter((entry) => inRun.has(entry.row.sessionId)).length + const inRunCount = run.entries.length const inProgress = count('in-progress') return { rows: placed diff --git a/src/renderer/src/components/native-chat-resume-run.test.ts b/src/renderer/src/components/native-chat-resume-run.test.ts index 31f2493f179e..c10f354cdc91 100644 --- a/src/renderer/src/components/native-chat-resume-run.test.ts +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -1,146 +1,115 @@ import { describe, expect, it } from 'vitest' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' -import { - beginResumeRunEntries, - resumeRunInFlight, - resumeRunPendingIds, - resumeRunResultOf, - settleResumeRunEntry -} from './native-chat-resume-run' +import { beginResumeRun, resumeRunInFlight, resumeRunPendingIds } from './native-chat-resume-run' import { resumeRunView } from './native-chat-resume-run-view' +import type { ResumeRunHostStatus } from './use-resume-run-status-feed' const row = (sessionId: string): ResumeCandidate => ({ sessionId, - workspaceId: 'workspace', + workspaceId: 'folder', agent: 'codex', trigger: 'quit', latestPrompt: `Prompt ${sessionId}`, recordedAt: 1 }) +const failure = { ...row('a'), failedAt: 2, outcome: 'unconfirmed' as const, reason: 'pending' } +const run = beginResumeRun([row('a'), row('b')], 10) -const refused = (sessionId: string) => ({ - ...row(sessionId), - failedAt: 2, - outcome: 'refused' as const, - reason: 'agent_session_conflict' -}) - -describe('a run', () => { - it('settles each chat on its own answer and keeps the order the chats were asked in', () => { - let run = beginResumeRunEntries(null, [row('a'), row('b')], 10) - expect(resumeRunPendingIds(run)).toEqual(['a', 'b']) - run = settleResumeRunEntry(run, 'b', 'resumed')! - expect(resumeRunPendingIds(run)).toEqual(['a']) +describe('the dialog over a bulk action', () => { + it('settles a chat from its host verdict while the request remains in flight', () => { + const statusFor = (id: string): ResumeRunHostStatus => ({ + restartResume: { phase: id === 'a' ? 'continued' : 'queued' } + }) + const view = resumeRunView(run, [], () => undefined, 'all', statusFor) + expect(view.rows.map((entry) => entry.sessionId)).toEqual(['b', 'a']) + expect(view.statusBySession.get('a')).toEqual({ kind: 'resumed' }) + expect(view.counts).toMatchObject({ total: 2, done: 1, resumed: 1, inProgress: 1 }) expect(resumeRunInFlight(run)).toBe(true) - run = settleResumeRunEntry(run, 'a', 'refused')! - expect(resumeRunInFlight(run)).toBe(false) - expect(run.entries.map((entry) => [entry.candidate.sessionId, entry.result])).toEqual([ - ['a', 'refused'], - ['b', 'resumed'] - ]) + expect(resumeRunPendingIds(run)).toEqual(['a', 'b']) }) - // A retry while the run still moves joins it; one after it finished starts a fresh run. - it('joins a run in flight and replaces a finished one', () => { - const moving = beginResumeRunEntries(null, [row('a'), row('b')], 10) - const joined = beginResumeRunEntries( - settleResumeRunEntry(moving, 'a', 'refused'), - [row('a')], - 20 - ) - expect(joined.startedAt).toBe(10) - expect(joined.entries.map((entry) => [entry.candidate.sessionId, entry.result])).toEqual([ - ['b', undefined], - ['a', undefined] - ]) - - const finished = settleResumeRunEntry( - settleResumeRunEntry(moving, 'a', 'resumed'), - 'b', - 'resumed' + it('degrades an older host to waiting until the reply, even if its ordinary status is ready', () => { + const view = resumeRunView( + run, + [], + () => undefined, + 'all', + () => ({ hostExecutionPhase: 'ready' }) ) - const fresh = beginResumeRunEntries(finished, [row('c')], 30) - expect(fresh).toEqual({ startedAt: 30, entries: [{ candidate: row('c'), startedAt: 30 }] }) + expect(view.counts.inProgress).toBe(2) + expect(view.statusBySession.get('a')).toEqual({ kind: 'in-flight', phase: null, startedAt: 10 }) }) - it('shares one empty pending list, so readers of it do not re-render for nothing', () => { - expect(resumeRunPendingIds(null)).toBe(resumeRunPendingIds(null)) - }) -}) - -describe("one chat's answer", () => { - it.each([ - ['continued', undefined, 'resumed'], - ['refused', undefined, 'refused'], - ['unknown', undefined, 'unconfirmed'], - // With the host's list read, an unconfirmed send it no longer lists was seen carrying on. - ['unknown', [], 'resumed'], - // A refusal the host no longer lists: the user answered first, and nothing failed. - ['refused', [], 'gone'] - ] as const)('reads %s with failures %j as %s', (outcome, failed, result) => { - expect(resumeRunResultOf('a', [{ sessionId: 'a', outcome }], failed)).toBe(result) - }) - - it('prefers what the host lists the chat as', () => { - expect( - resumeRunResultOf( - 'a', - [{ sessionId: 'a', outcome: 'continued' }], - [{ sessionId: 'a', outcome: 'unconfirmed' }] + it('distinguishes waiting for a start slot from holding one and waiting for delivery', () => { + for (const hostExecutionPhase of ['starting', 'ready'] as const) { + const view = resumeRunView( + run, + [], + () => undefined, + 'all', + () => ({ restartResume: { phase: 'starting' }, hostExecutionPhase }) ) - ).toBe('unconfirmed') + expect(view.statusBySession.get('a')).toEqual({ + kind: 'in-flight', + phase: hostExecutionPhase, + startedAt: 10 + }) + } }) - it('treats an answer without outcomes as unconfirmed, and one that skipped the chat as gone', () => { - expect(resumeRunResultOf('a', undefined, undefined)).toBe('unconfirmed') - expect(resumeRunResultOf('a', [], [])).toBe('gone') + it('a retry shows its current progress over the previous failure', () => { + const view = resumeRunView(run, [failure], () => failure, 'all') + expect(view.counts.attention).toBe(0) + expect(view.statusBySession.get('a')?.kind).toBe('in-flight') }) -}) - -describe('the dialog over a run', () => { - const run = (() => { - let next = beginResumeRunEntries(null, [row('a'), row('b'), row('c'), row('d')], 10) - next = settleResumeRunEntry(next, 'a', 'resumed')! - next = settleResumeRunEntry(next, 'c', 'refused')! - return settleResumeRunEntry(next, 'd', 'gone')! - })() - it('lists what needs the user first, then what is moving, then what is done', () => { - const view = resumeRunView(run, [row('e')], () => undefined, 'all') - expect(view.rows.map((entry) => entry.sessionId)).toEqual(['c', 'b', 'a', 'e']) - expect(view.counts).toEqual({ - all: 4, - total: 3, - done: 2, - inProgress: 1, - resumed: 1, - attention: 1 - }) - expect(view.statusBySession.get('b')).toEqual({ kind: 'in-flight', startedAt: 10 }) - expect(view.statusBySession.get('c')).toEqual({ kind: 'refused' }) - // A chat nobody needed resuming is left out rather than shown as resumed. - expect(view.statusBySession.has('d')).toBe(false) + it('withdrawn and dismissed failures leave Need you after the reply', () => { + const finished = { + ...run, + inFlight: false, + continued: [{ sessionId: 'a', outcome: 'unknown' as const }] + } + const failed = resumeRunView( + finished, + [failure], + (id) => (id === 'a' ? failure : undefined), + 'attention' + ) + expect(failed.rows).toEqual([failure]) + const withdrawn = resumeRunView(finished, [], () => undefined, 'attention') + expect(withdrawn.rows).toEqual([]) + expect(withdrawn.counts.attention).toBe(0) }) - // Once the host lists the failure, its row is the failure row with guidance, not a bare icon. - it('shows a failure the host lists as its failure row', () => { - const view = resumeRunView( - run, - [refused('c')], - (sessionId) => (sessionId === 'c' ? refused('c') : undefined), - 'all' + it('retains successful history but always shows current host failures', () => { + const finished = { + ...run, + inFlight: false, + continued: [{ sessionId: 'a', outcome: 'continued' as const }] + } + expect(resumeRunView(finished, [], () => undefined, 'resumed').rows).toEqual([row('a')]) + const failed = resumeRunView( + finished, + [failure], + (id) => (id === 'a' ? failure : undefined), + 'attention' ) - expect(view.statusBySession.has('c')).toBe(false) - expect(view.rows.map((entry) => entry.sessionId)).toEqual(['c', 'b', 'a']) + expect(failed.rows).toEqual([failure]) + expect(failed.statusBySession.has('a')).toBe(false) + expect(resumeRunPendingIds(finished)).toBe(resumeRunPendingIds(null)) }) - it.each([ - ['in-progress', ['b']], - ['resumed', ['a']], - ['attention', ['c']] - ] as const)('narrows the list to %s', (filter, ids) => { - expect( - resumeRunView(run, [], () => undefined, filter).rows.map((entry) => entry.sessionId) - ).toEqual(ids) + it('shows final per-chat failures from the feed before the batch returns', () => { + for (const phase of ['refused', 'unconfirmed'] as const) { + const view = resumeRunView( + run, + [], + () => undefined, + 'attention', + () => ({ restartResume: { phase } }) + ) + expect(view.counts.attention).toBe(2) + expect(view.statusBySession.get('a')).toEqual({ kind: phase }) + } }) }) diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts index 5dcbc89b95e8..a8be517d1cf5 100644 --- a/src/renderer/src/components/native-chat-resume-run.ts +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -1,98 +1,24 @@ -import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' import type { RestartContinuationOutcome } from './native-chat-restart-action-notifications' -/** - * One resume as this window ran it: which chats it asked to carry on, and what each answered. - * - * Each chat is its own request, so a chat's row settles the moment its own answer arrives rather - * than when the slowest chat in the batch does. Held in memory for as long as the dialog may show - * it; the host's offer list stays the record of what is still owed. - */ - -/** `gone`: the host no longer offered the chat, e.g. the user answered it first; nothing failed. */ -export type ResumeRunResult = 'resumed' | 'refused' | 'unconfirmed' | 'gone' - -export type ResumeRunEntry = Readonly<{ - candidate: ResumeCandidate - startedAt: number - result?: ResumeRunResult -}> - +/** Selection and history belong to this window; live progress and failures belong to the host. */ export type ResumeRun = Readonly<{ startedAt: number - /** In the order the chats were asked, so rows keep their place while answers arrive. */ - entries: readonly ResumeRunEntry[] + entries: readonly Readonly<{ candidate: ResumeCandidate }>[] + inFlight: boolean + continued?: readonly RestartContinuationOutcome[] }> export function resumeRunInFlight(run: ResumeRun | null): boolean { - return run?.entries.some((entry) => entry.result === undefined) ?? false -} - -/** Chats join a run still in flight; a finished run is replaced, so its rows never linger. */ -export function beginResumeRunEntries( - run: ResumeRun | null, - candidates: readonly ResumeCandidate[], - now: number -): ResumeRun { - const base = run && resumeRunInFlight(run) ? run : { startedAt: now, entries: [] } - const joining = new Set(candidates.map((candidate) => candidate.sessionId)) - return { - startedAt: base.startedAt, - entries: [ - ...base.entries.filter((entry) => !joining.has(entry.candidate.sessionId)), - ...candidates.map((candidate) => ({ candidate, startedAt: now })) - ] - } -} - -export function settleResumeRunEntry( - run: ResumeRun | null, - sessionId: string, - result: ResumeRunResult -): ResumeRun | null { - if (!run) { - return run - } - return { - ...run, - entries: run.entries.map((entry) => - entry.candidate.sessionId === sessionId && entry.result === undefined - ? { ...entry, result } - : entry - ) - } + return run?.inFlight ?? false } -/** - * One chat's own answer, read the way the toast reads it: an unconfirmed send the host stopped - * listing was seen carrying on, and a chat the host acted on without a verdict is unconfirmed. - */ -export function resumeRunResultOf( - sessionId: string, - continued: readonly RestartContinuationOutcome[] | undefined, - failed: readonly Pick[] | undefined -): ResumeRunResult { - const listed = failed?.find((failure) => failure.sessionId === sessionId) - if (listed) { - return listed.outcome - } - const outcome = continued?.find((entry) => entry.sessionId === sessionId)?.outcome - if (outcome === 'continued') { - return 'resumed' - } - if (outcome === 'pending' || outcome === 'unknown') { - return failed === undefined ? 'unconfirmed' : 'resumed' - } - if (outcome === 'refused') { - return failed === undefined ? 'refused' : 'gone' - } - // No verdict for it at all: an answer without outcomes may still have sent the message. - return continued === undefined ? 'unconfirmed' : 'gone' +export function beginResumeRun(candidates: readonly ResumeCandidate[], now: number): ResumeRun { + return { startedAt: now, entries: candidates.map((candidate) => ({ candidate })), inFlight: true } } -/** Ids still waiting on their own answer; one shared empty array keeps the snapshot stable. */ const NONE: readonly string[] = [] +/** Keep the selection protected until the action's authoritative reply has been published. */ export function resumeRunPendingIds(run: ResumeRun | null): readonly string[] { - const pending = run?.entries.filter((entry) => entry.result === undefined) ?? [] - return pending.length === 0 ? NONE : pending.map((entry) => entry.candidate.sessionId) + return run?.inFlight ? run.entries.map((entry) => entry.candidate.sessionId) : NONE } diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index 2b6ce37e0560..84140c4a1887 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -8,6 +8,8 @@ import { useNativeChatRestartRun } from '../native-chat-resume-on-restart-store' import { resumeRunInFlight } from '../native-chat-resume-run' +import { resumeRunView } from '../native-chat-resume-run-view' +import { useResumeRunStatusFeed } from '../use-resume-run-status-feed' // Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is // then the only surface left carrying it, so it is always rendered rather than gated by @@ -143,11 +145,18 @@ export function NativeChatResumeStatusSegment({ const offerEnabled = useNativeChatRestartOfferEnabled() const { candidates, failed } = useNativeChatRestartOffer(offerEnabled) const run = useNativeChatRestartRun() + const hostStatusFor = useResumeRunStatusFeed( + run?.inFlight ? run.entries.map((entry) => entry.candidate.sessionId) : [] + ) + const failureBySession = new Map(failed.map((entry) => [entry.sessionId, entry])) + const view = run + ? resumeRunView(run, candidates, (id) => failureBySession.get(id), 'all', hostStatusFor) + : null if (!offerEnabled) { return null } - // While a run is moving, its chats are counted once, by it, until the host's list is read again. + // Count the selection once until the action publishes the host's remaining list. const running = run !== null && resumeRunInFlight(run) const inRun = new Set(running ? run.entries.map((entry) => entry.candidate.sessionId) : []) const waiting = failed.filter((failure) => !inRun.has(failure.sessionId)) @@ -162,10 +171,7 @@ export function NativeChatResumeStatusSegment({ iconOnly={iconOnly} count={inRun.size} icon={} - {...resumingText( - run.entries.filter((entry) => entry.result !== undefined).length, - run.entries.length - )} + {...resumingText(view?.counts.done ?? 0, run.entries.length)} /> )} {pending > 0 && ( diff --git a/src/renderer/src/components/use-resume-run-start-phases.ts b/src/renderer/src/components/use-resume-run-start-phases.ts deleted file mode 100644 index 0456322e39e4..000000000000 --- a/src/renderer/src/components/use-resume-run-start-phases.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' - -type StartPhase = NonNullable - -const LOCAL = { kind: 'local' } as const - -/** - * Whether each chat's agent is still starting or has started, from the host's own status feed, so - * a resuming row can say where it is without the resume reporting it. Null: not started yet. - * - * One subscription for the whole list, selected as a joined string so the snapshot is a PRIMITIVE - * and a status change elsewhere does not re-render every row. - */ -export function useResumeRunStartPhases( - sessionIds: readonly string[] -): (sessionId: string) => StartPhase | null { - const feed = useMemo(() => getStructuredAgentSessionStatusFeed(LOCAL), []) - const active = sessionIds.length > 0 - useEffect(() => (active ? feed.activate() : undefined), [feed, active]) - const key = sessionIds.join('\0') - const joined = useSyncExternalStore(feed.subscribe, () => - key === '' - ? '' - : key - .split('\0') - .map((sessionId) => feed.getSnapshot().get(sessionId)?.hostExecutionPhase ?? '') - .join('\0') - ) - const phases = joined.split('\0') - return (sessionId: string) => { - const phase = phases[sessionIds.indexOf(sessionId)] - return phase === 'starting' || phase === 'ready' ? phase : null - } -} diff --git a/src/renderer/src/components/use-resume-run-status-feed.ts b/src/renderer/src/components/use-resume-run-status-feed.ts new file mode 100644 index 000000000000..7af81c3d76e0 --- /dev/null +++ b/src/renderer/src/components/use-resume-run-status-feed.ts @@ -0,0 +1,51 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +export type ResumeRunHostStatus = Pick< + AgentSessionStatusSummary, + 'restartResume' | 'hostExecutionPhase' +> +const LOCAL = { kind: 'local' } as const + +/** Subscribe only to the selected chats' progress, so unrelated status ticks cost no render. */ +export function useResumeRunStatusFeed( + sessionIds: readonly string[] +): (sessionId: string) => ResumeRunHostStatus | undefined { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(LOCAL), []) + const active = sessionIds.length > 0 + useEffect(() => (active ? feed.activate() : undefined), [feed, active]) + const key = sessionIds.join('\0') + const joined = useSyncExternalStore(feed.subscribe, () => + key === '' + ? '' + : key + .split('\0') + .map((sessionId) => { + const summary = feed.getSnapshot().get(sessionId) + return summary?.restartResume + ? `${summary.restartResume.phase}:${summary.hostExecutionPhase ?? ''}` + : '' + }) + .join('\0') + ) + const statuses = joined.split('\0') + return (sessionId) => { + const [phase, hostPhase] = (statuses[sessionIds.indexOf(sessionId)] ?? '').split(':') + if ( + phase !== 'queued' && + phase !== 'starting' && + phase !== 'continued' && + phase !== 'refused' && + phase !== 'unconfirmed' + ) { + return undefined + } + return { + restartResume: { phase }, + ...(hostPhase === 'starting' || hostPhase === 'ready' + ? { hostExecutionPhase: hostPhase } + : {}) + } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f94fc0ff45bf..baeabd10e969 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17819,7 +17819,8 @@ "resumedTitle": "Resumed {{value0}} of {{value1}} chats", "resumingTitleOne": "Resuming 1 chat", "resumingTitle": "Resuming {{value0}} chats", - "description": "Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track." + "description": "Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track.", + "resumedTitleOne": "Resumed {{value0}} of 1 chat" }, "NativeChatResumeRunStatusIcon": { "waitingForReply": "Waiting for a reply", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 34aa41845c59..e89977367d06 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17475,6 +17475,7 @@ }, "NativeChatResumeRunPanel": { "resumedTitle": "Se reanudaron {{value0}} de {{value1}} chats", + "resumedTitleOne": "{{value0}} de 1 chat reanudado", "resumingTitleOne": "Reanudando 1 chat", "resumingTitle": "Reanudando {{value0}} chats", "description": "Cada chat se restaura con todo su contexto y se le pide que revise dónde se detuvo. Puedes cerrar esto; la barra de estado sigue el progreso." diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ea87a80fbc42..fefc4f007299 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17577,6 +17577,7 @@ }, "NativeChatResumeRunPanel": { "resumedTitle": "{{value0}} discussions sur {{value1}} reprises", + "resumedTitleOne": "{{value0}} discussion sur 1 reprise", "resumingTitleOne": "Reprise d’une discussion", "resumingTitle": "Reprise de {{value0}} discussions", "description": "Chaque discussion est restaurée avec tout son contexte, et l’agent vérifie où il s’était arrêté. Vous pouvez fermer cette fenêtre ; la barre d’état suit la progression." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 18f363ea064a..083a18053064 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17476,6 +17476,7 @@ }, "NativeChatResumeRunPanel": { "resumedTitle": "{{value1}} 件中 {{value0}} 件のチャットを再開しました", + "resumedTitleOne": "1 件中 {{value0}} 件のチャットを再開しました", "resumingTitleOne": "1 件のチャットを再開中", "resumingTitle": "{{value0}} 件のチャットを再開中", "description": "各チャットは全コンテキストとともに復元され、中断した箇所を確認するよう依頼されます。このダイアログは閉じても構いません。進行状況はステータスバーで確認できます。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e4390ffe5ad1..3a3be54a855d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17476,6 +17476,7 @@ }, "NativeChatResumeRunPanel": { "resumedTitle": "채팅 {{value1}}개 중 {{value0}}개를 재개했습니다", + "resumedTitleOne": "채팅 1개 중 {{value0}}개를 재개했습니다", "resumingTitleOne": "채팅 1개 재개 중", "resumingTitle": "채팅 {{value0}}개 재개 중", "description": "각 채팅은 전체 컨텍스트와 함께 복원되며, 멈춘 지점을 확인하도록 요청됩니다. 이 창을 닫아도 상태 표시줄에서 진행 상황을 계속 볼 수 있습니다." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 02ef637e1e06..b3c675b7048b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17476,6 +17476,7 @@ }, "NativeChatResumeRunPanel": { "resumedTitle": "已恢复 {{value0}}/{{value1}} 个聊天", + "resumedTitleOne": "已恢复 {{value0}}/1 个聊天", "resumingTitleOne": "正在恢复 1 个聊天", "resumingTitle": "正在恢复 {{value0}} 个聊天", "description": "每个聊天都会带着完整上下文恢复,并被要求检查它停在了哪里。你可以关闭此窗口,状态栏会继续显示进度。" diff --git a/src/shared/agent-session-status-snapshot-fold.test.ts b/src/shared/agent-session-status-snapshot-fold.test.ts new file mode 100644 index 000000000000..dc08c809d451 --- /dev/null +++ b/src/shared/agent-session-status-snapshot-fold.test.ts @@ -0,0 +1,36 @@ +import { expect, it } from 'vitest' +import type { AgentSessionStatusSummary } from './agent-session-wire' +import { + foldAgentSessionStatusEvent, + revokeAgentSessionStatusLive +} from './agent-session-status-snapshot-fold' + +const summary: AgentSessionStatusSummary = { + sessionId: 'a', + workspaceId: 'folder', + agent: 'codex', + status: 'idle', + latestPrompt: 'Continue', + updatedAt: 1 +} + +it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed'] as const)( + 'folds %s progress and revokes it on contact loss, even without a provider child', + (phase) => { + const snapshot = foldAgentSessionStatusEvent(new Map(), { + type: 'snapshot', + sessions: [{ ...summary, restartResume: { phase } }] + }) + expect(snapshot.get('a')?.restartResume?.phase).toBe(phase) + const disconnected = revokeAgentSessionStatusLive(snapshot) + expect(disconnected.get('a')).toEqual(summary) + expect(revokeAgentSessionStatusLive(disconnected)).toBe(disconnected) + } +) + +it('a clearing frame removes progress rather than merging the previous optional field', () => { + const snapshot = new Map([['a', { ...summary, restartResume: { phase: 'continued' as const } }]]) + expect( + foldAgentSessionStatusEvent(snapshot, { type: 'status', session: summary }).get('a') + ).toEqual(summary) +}) diff --git a/src/shared/agent-session-status-snapshot-fold.ts b/src/shared/agent-session-status-snapshot-fold.ts index ce88e1d2d824..2c1486b087b9 100644 --- a/src/shared/agent-session-status-snapshot-fold.ts +++ b/src/shared/agent-session-status-snapshot-fold.ts @@ -35,7 +35,7 @@ export function revokeAgentSessionStatusLive( ): AgentSessionStatusSnapshot { let next: Map | null = null for (const [sessionId, summary] of snapshot) { - if (!summary.hostExecutionOwned && !summary.stopping) { + if (!summary.hostExecutionOwned && !summary.stopping && !summary.restartResume) { continue } next ??= new Map(snapshot) @@ -43,6 +43,7 @@ export function revokeAgentSessionStatusLive( hostExecutionOwned: _owned, hostExecutionPhase: _phase, stopping: _stopping, + restartResume: _restartResume, ...retained } = summary next.set(sessionId, retained) diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index a3235cd12928..09f5c1b57e72 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -239,6 +239,9 @@ export type AgentSessionStatusSummary = { /** With `hostExecutionOwned`: whether that child has proven its start. `starting` is a * published session whose provider has not yet answered startup; absent on older hosts. */ hostExecutionPhase?: 'starting' | 'ready' + /** This restart action's progress, derived by the live host and never persisted. + * Cleared when the action returns; absent on older hosts. */ + restartResume?: { phase: 'queued' | 'starting' | 'continued' | 'refused' | 'unconfirmed' } latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string From bf9baca6d835a72bad4516ac2b924eccdf3bf52e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:50:37 -0700 Subject: [PATCH 3/6] Keep chat resume progress stable through reply handoff --- ...red-agent-session-restart-audience.test.ts | 1 + ...ed-agent-session-restart-ownership.test.ts | 1 + ...d-agent-session-restart-resume-contract.ts | 2 + ...tured-agent-session-restart-resume-host.ts | 6 + ...nt-session-restart-resume-progress.test.ts | 51 ++++++ ...d-agent-session-restart-resume-progress.ts | 13 +- ...tured-agent-session-restart-resume.test.ts | 2 +- .../components/NativeChatResumeRunPanel.tsx | 6 +- .../NativeChatResumeRunStatusIcon.tsx | 5 +- .../components/NativeChatResumeRunSummary.tsx | 5 +- ...ative-chat-restart-action-notifications.ts | 30 ++-- .../native-chat-resume-bulk-progress.test.tsx | 146 ++++++++++++++++++ .../native-chat-resume-modal.test-support.tsx | 25 +++ ...at-resume-on-restart-outcome-toast.test.ts | 9 +- .../native-chat-resume-on-restart-store.ts | 36 ++++- .../native-chat-resume-run-status-watch.ts | 27 ++++ .../components/native-chat-resume-run-view.ts | 18 +-- .../components/native-chat-resume-run.test.ts | 44 +++++- .../src/components/native-chat-resume-run.ts | 50 +++++- .../NativeChatResumeStatusSegment.tsx | 12 +- .../components/use-resume-run-status-feed.ts | 51 ------ ...agent-session-status-snapshot-fold.test.ts | 2 +- src/shared/agent-session-wire.ts | 4 +- 23 files changed, 438 insertions(+), 108 deletions(-) create mode 100644 src/renderer/src/components/native-chat-resume-run-status-watch.ts delete mode 100644 src/renderer/src/components/use-resume-run-status-feed.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts index f468d0bd12ee..c53f9b659517 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -41,6 +41,7 @@ it('neither continues nor reserves an offer the caller cannot show, named or not expect(await host.restartResume.continueAfterRestart(named, 'modal', cannotShowCodex)).toEqual({ resumed: [], continued: [], + ...(named ? { skipped: [SESSION] } : {}), sessions: [], failed: [] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 0d73656b3477..8614f4353200 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -326,6 +326,7 @@ it('fails closed on corrupt recovery storage while an ordinary send still works' expect(await host.restartResume.continueAfterRestart([SESSION], 'modal')).toEqual({ resumed: [], continued: [], + skipped: [SESSION], sessions: [], failed: [] }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts index 1cbbd8236ad0..d15ab6c874c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts @@ -33,6 +33,8 @@ export type StructuredAgentSessionRestartResume = { ) => Promise<{ resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] + /** Requested chats excluded from this action. */ + skipped?: string[] sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 88fcc8896a20..e1a4e14cd0d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -161,6 +161,7 @@ export function createStructuredAgentSessionRestartResume( const eligible = restartRowsFor(derived.candidates, audience).filter((candidate) => requested.has(candidate.sessionId) ) + progress.skipExcluded(sessionIds ?? [], eligible) if (eligible.length === 0) { return null } @@ -180,6 +181,10 @@ export function createStructuredAgentSessionRestartResume( )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held').candidates + progress.skipExcluded( + eligible.map((candidate) => candidate.sessionId), + candidates + ) try { const outcomes = await resumeStructuredAgentSessionsFromRestart( { @@ -283,6 +288,7 @@ export function createStructuredAgentSessionRestartResume( return { resumed, continued, + ...(progress.skipped.length > 0 ? { skipped: progress.skipped } : {}), ...(await remainingRestartRows( () => list(audience), () => listFailures(audience), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts index 514c7fcddde7..50efad841257 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -4,12 +4,63 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { interruptedRestart, + startAgent, throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' afterEach(() => vi.restoreAllMocks()) +it('publishes skipped when an offer disappears during reservation without starting its agent', async () => { + const { host, acquire, dispatch } = await interruptedRestart() + expect(await host.restartResume.list()).toHaveLength(1) + vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'beginResume').mockResolvedValueOnce([]) + const phases: (string | undefined)[] = [] + const release = host.subscribeStatus({ + id: 'reservation-skipped', + emit: (event) => { + if (event.type === 'status') { + phases.push(event.session.restartResume?.phase) + } + } + }) + try { + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result).toMatchObject({ skipped: [SESSION], resumed: [], continued: [] }) + expect(phases).toEqual(['skipped', undefined]) + expect(acquire).not.toHaveBeenCalled() + expect(dispatch).not.toHaveBeenCalled() + } finally { + release() + } +}) + +it('publishes skipped for an offer started elsewhere and sends no continuation', async () => { + const state = await interruptedRestart() + expect(await state.host.restartResume.list()).toHaveLength(1) + await startAgent(state) + state.acquire.mockClear() + const summaries: AgentSessionStatusSummary[] = [] + const release = state.host.subscribeStatus({ + id: 'skipped', + emit: (event) => { + if (event.type === 'status') { + summaries.push(event.session) + } + } + }) + try { + const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result).toMatchObject({ skipped: [SESSION], resumed: [], continued: [] }) + expect(state.acquire).not.toHaveBeenCalled() + expect(state.dispatch).not.toHaveBeenCalled() + expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('skipped') + expect(summaries.at(-1)).not.toHaveProperty('restartResume') + } finally { + release() + } +}) + it.each(['continued', 'refused', 'unconfirmed'] as const)( 'publishes %s before bookkeeping finishes, then clears progress on return', async (phase) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts index 674c5b7a82dd..eb4a3fe89faa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts @@ -12,8 +12,19 @@ export function createRestartResumeProgress( ) { const operationId = randomUUID() const owned = new Map() + const skipped: string[] = [] let active = true return { + skipped, + skipExcluded(requested: readonly string[], admitted: readonly { sessionId: string }[]): void { + const admittedIds = new Set(admitted.map((entry) => entry.sessionId)) + for (const sessionId of new Set(requested)) { + if (!admittedIds.has(sessionId)) { + skipped.push(sessionId) + this.set(sessionId, 'skipped') + } + } + }, set(sessionId: string, phase: Phase): void { if (!active) { return @@ -22,7 +33,7 @@ export function createRestartResumeProgress( if (!session) { return } - if (phase === 'queued') { + if (phase === 'queued' || phase === 'skipped') { if (session.restartResume) { return } 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 02f3d54dc8b4..bc88fe1c4152 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 @@ -694,7 +694,7 @@ describe('spending a marker', () => { expect(outcomes).toHaveLength(candidates.length) }) - // A client resumes each chat with its own request, so the limit has to span requests. + // Different callers share one host-wide start limit. it('shares the start limit across separate resume requests', async () => { let live = 0 let peak = 0 diff --git a/src/renderer/src/components/NativeChatResumeRunPanel.tsx b/src/renderer/src/components/NativeChatResumeRunPanel.tsx index 7a581de5ba6c..83746c6b0554 100644 --- a/src/renderer/src/components/NativeChatResumeRunPanel.tsx +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -7,7 +7,6 @@ import { resumeRunInFlight, type ResumeRun } from './native-chat-resume-run' import { resumeRunView, type ResumeRunFilter } from './native-chat-resume-run-view' import { ResumeRunStatusIcon } from './NativeChatResumeRunStatusIcon' import { ResumeRunSummary } from './NativeChatResumeRunSummary' -import { useResumeRunStatusFeed } from './use-resume-run-status-feed' /** * The dialog's view of a run it is following: its title, the progress line and filters, the rows @@ -75,10 +74,7 @@ export function useResumeRunPanel({ const inFlight = run !== null && resumeRunInFlight(run) // Ticks only while a visible run is moving: the row timers and the running clock read it. const now = useNow(1000, open && inFlight) - const hostStatusFor = useResumeRunStatusFeed( - inFlight && run ? run.entries.map((entry) => entry.candidate.sessionId) : [] - ) - const view = run ? resumeRunView(run, rows, failureFor, filter.value, hostStatusFor) : null + const view = run ? resumeRunView(run, rows, failureFor, filter.value) : null const inFlightIds = view ? [...view.statusBySession].flatMap(([sessionId, status]) => status.kind === 'in-flight' ? [sessionId] : [] diff --git a/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx index 44cc6b031429..9a9170fd34ed 100644 --- a/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx +++ b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx @@ -4,10 +4,7 @@ import { translate } from '@/i18n/i18n' import { formatNativeChatDuration } from '../../../shared/native-chat-turn-status' import type { ResumeRunRowStatus } from './native-chat-resume-run-view' -/** - * Where one chat of a run stands, in the slot its checkbox had: a gray spinner while it resumes, - * then what it came to. The words live in the tooltip, so the row carries no badge. - */ +/** Tooltips keep per-chat progress from adding another label to each row. */ export type ResumeRunStartPhase = 'starting' | 'ready' | null diff --git a/src/renderer/src/components/NativeChatResumeRunSummary.tsx b/src/renderer/src/components/NativeChatResumeRunSummary.tsx index abbfb82325eb..f7ed32622ac9 100644 --- a/src/renderer/src/components/NativeChatResumeRunSummary.tsx +++ b/src/renderer/src/components/NativeChatResumeRunSummary.tsx @@ -4,10 +4,7 @@ import { formatNativeChatDuration } from '../../../shared/native-chat-turn-statu import type { ResumeRunFilter, ResumeRunView } from './native-chat-resume-run-view' import type { ResumeRunStartPhase } from './NativeChatResumeRunStatusIcon' -/** - * The line above a followed run's list: how many chats are done and where the rest stand, and the - * filters that narrow the list to one of those groups. - */ +/** Counts and filters use the same categories so narrowing the list preserves its meaning. */ function progressText( counts: ResumeRunView['counts'], diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.ts b/src/renderer/src/components/native-chat-restart-action-notifications.ts index f2d41b29abd3..da34606a84c5 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.ts @@ -118,6 +118,25 @@ export function restartChatsNotContinued( return [...new Set(requested)].filter((sessionId) => bySession.get(sessionId) !== 'continued') } +/** Reconcile once with the action's confirmed list; later dismissals are not successes. */ +export function restartContinuationHistory( + requested: readonly string[], + reported: readonly RestartContinuationOutcome[] | undefined, + failed: readonly Pick[] | undefined +): readonly RestartContinuationOutcome[] { + const results = Array.isArray(reported) + ? reported + : requested.map((sessionId) => ({ sessionId, outcome: 'unknown' as const })) + const failedIds = new Set(failed?.map((entry) => entry.sessionId)) + return results.map((entry) => + failed !== undefined && + !failedIds.has(entry.sessionId) && + (entry.outcome === 'unknown' || entry.outcome === 'pending') + ? { ...entry, outcome: 'continued' as const } + : entry + ) +} + export function announceRestartResults( requested: readonly string[], /** Undefined for an answer without outcomes, which may still have sent the message. */ @@ -127,9 +146,7 @@ export function announceRestartResults( /** Opens the dialog over a fresh read; never rejects. */ show: () => Promise ): void { - const results = Array.isArray(reportedResults) - ? reportedResults - : requested.map((sessionId) => ({ sessionId, outcome: 'unknown' as const })) + const results = restartContinuationHistory(requested, reportedResults, hostFailed) const notContinued = restartChatsNotContinued(requested, results) const failed = new Map(hostFailed?.map((failure) => [failure.sessionId, failure.outcome])) // A host that lists failures has already dropped chats that moved on by themselves or that the @@ -141,18 +158,13 @@ export function announceRestartResults( const outcomes = new Map(results.map((result) => [result.sessionId, result.outcome])) const sentUnconfirmed = (sessionId: string): boolean => outcomes.get(sessionId) === 'pending' || outcomes.get(sessionId) === 'unknown' - // An unconfirmed send the host no longer lists was seen carrying on (or answered by the user), so - // it was resumed and asked to continue; left out of both counts, the resume would say nothing. - const seenCarryingOn = notContinued.filter( - (sessionId) => hostFailed !== undefined && !failed.has(sessionId) && sentUnconfirmed(sessionId) - ) // The filed outcome is what the list shows, so the toast uses it too. const unconfirmed = (sessionId: string): boolean => (failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) === 'unconfirmed' const unconfirmedCount = reported.filter(unconfirmed).length announceResume( - new Set(requested).size - notContinued.length + seenCarryingOn.length, + new Set(requested).size - notContinued.length, reported.length - unconfirmedCount, unconfirmedCount, hostFailed === undefined ? undefined : show diff --git a/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx index 10b9b8088026..035d15bf8fbd 100644 --- a/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx +++ b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx @@ -11,6 +11,7 @@ import { expect, it, vi, type Mock } from 'vitest' import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { continueNativeChatRestartOffer, @@ -44,6 +45,24 @@ const { mount, button, checkbox, offerIds, toasts, fakeHost, runStatus } = creat statusStream ) +function emitPhase( + sessionId: string, + phase?: NonNullable['phase'] +): void { + statusStream.emit({ + type: 'status', + session: { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + ...(phase ? { restartResume: { phase } } : {}) + } + }) +} + it('resumes a 21-chat selection with one action and no redundant listing', async () => { const candidates = Array.from({ length: 21 }, (_, index) => ({ ...offered[0]!, @@ -162,6 +181,9 @@ it('keeps the run in flight after every feed verdict until the fallback list is }) }) expect(button('Resuming chats 2/2')).toBeTruthy() + await act(async () => offered.forEach(({ sessionId }) => emitPhase(sessionId))) + expect(button('Resuming chats 2/2')).toBeTruthy() + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') expect(button('Resuming…').disabled).toBe(true) expect(document.body.textContent).not.toContain('chats to resume') await act(async () => listing.resolve({ sessions: [], failed: [] })) @@ -170,6 +192,130 @@ it('keeps the run in flight after every feed verdict until the fallback list is expect(document.body.textContent).not.toContain('Resuming chats') }) +it('retains success and refusal frames through cleanup before a delayed bulk reply', async () => { + const reply = Promise.withResolvers() + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' ? { sessions: offered, failed: [] } : reply.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + // Verdicts and cleanup can arrive together before React gets a render. + await act(async () => { + emitPhase('a', 'continued') + emitPhase('b', 'refused') + emitPhase('a') + emitPhase('b') + }) + expect(button('Resuming chats 2/2')).toBeTruthy() + await act(async () => button('Resuming chats 2/2').click()) + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Couldn’t resume') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you1') + await act(async () => + reply.resolve({ + sessions: [], + failed: [failure('b')], + continued: [ + { sessionId: 'a', outcome: 'continued' }, + { sessionId: 'b', outcome: 'refused' } + ] + }) + ) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 1 of 2 chats') +}) + +it('removes a skipped selection from pending rows and all progress counts', async () => { + const host = fakeHost({}, (sessionId) => (sessionId === 'a' ? 'skipped' : 'continued')) + host.hold('b') + await mount( + <> + + + + ) + host.state.sessions = host.state.sessions.filter((row) => row.sessionId !== 'a') + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('Resuming chats 0/1').click()) + const dialog = () => document.querySelector('[role="dialog"]')?.textContent + expect(dialog()).not.toContain('Prompt a') + expect(dialog()).toContain('All1') + expect(dialog()).toContain('0 of 1 done') + expect(dialog()).toContain('In progress1') + await host.release('b') + expect(dialog()).toContain('Resumed 1 of 1 chat') + expect(dialog()).toContain('1 of 1 done') + expect(dialog()).toContain('All1') + expect(dialog()).toContain('In progress0') + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + expect(toasts()).toEqual([['Resumed 1 chat']]) +}) + +it('uses reply skipped ids even when the skip frame was missed', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered, failed: [] } + : { + sessions: [], + failed: [], + skipped: ['b'], + continued: [{ sessionId: 'a', outcome: 'continued' }] + } + ) + await mount() + await act(async () => continueNativeChatRestartOffer(['a', 'b'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 1 of 1 chat') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('All1') + expect(runStatus('Prompt b')).toBeNull() +}) + +it.each(['unknown', 'pending'] as const)( + 'reconciles %s with the reply failure list for both dialog and toast', + async (outcome) => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed: [] } + : { sessions: [], failed: [], continued: [{ sessionId: 'a', outcome }] } + ) + await mount() + await act(async () => continueNativeChatRestartOffer(['a'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 1 of 1 chat') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(toasts()).toEqual([['Resumed 1 chat']]) + } +) + +it('dismissing a failure after the reply never promotes it to resumed history', async () => { + fakeHost({ sessions: [offered[0]!] }, () => 'unknown') + await mount() + await act(async () => continueNativeChatRestartOffer(['a'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat') + const dismiss = document.querySelector('button[aria-label^="Dismiss"]') + expect(dismiss).not.toBeNull() + await act(async () => dismiss?.click()) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat') + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you0') + expect(runStatus('Prompt a')).toBeNull() + expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']]) +}) + +it('keeps an unknown reply unconfirmed when it carries no confirmed failure list', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed: [] } + : { sessions: [], continued: [{ sessionId: 'a', outcome: 'unknown' }] } + ) + await mount() + await act(async () => continueNativeChatRestartOffer(['a'])) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat') + expect(runStatus('Prompt a')).toBeNull() + expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']]) +}) + it('a retry hides its old failure, then a dismissed final failure leaves Need you', async () => { const host = fakeHost( { sessions: [], failed: [failure('b', 'agent_session_conflict')] }, diff --git a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx index 6d774b33601d..0bbc9dcdf310 100644 --- a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx +++ b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx @@ -121,6 +121,17 @@ export function createResumeModalFixture(rpc: Mock, statusStream: Re await held.get(sessionId)?.promise const verdict = verdictFor(sessionId) if (verdict === 'skipped') { + const summary: AgentSessionStatusSummary = { + sessionId, + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + restartResume: { phase: 'skipped' } + } + statusStream.snapshot.set(sessionId, summary) + statusStream.emit({ type: 'status', session: summary }) return [] } state.sessions = state.sessions.filter((entry) => entry.sessionId !== sessionId) @@ -146,9 +157,23 @@ export function createResumeModalFixture(rpc: Mock, statusStream: Re return [{ sessionId, outcome: verdict }] }) ) + const skipped: string[] = [] + for (const sessionId of params?.sessionIds ?? []) { + const previous = statusStream.snapshot.get(sessionId) + if (!previous) { + continue + } + if (previous.restartResume?.phase === 'skipped') { + skipped.push(sessionId) + } + const { restartResume: _restartResume, ...summary } = previous + statusStream.snapshot.set(sessionId, summary) + statusStream.emit({ type: 'status', session: summary }) + } return { resumed: [], continued: continued.flat(), + skipped, sessions: state.sessions, failed: state.failed } diff --git a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts index 543b2f4ceaeb..cf30530c453b 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts @@ -55,8 +55,7 @@ function hostLostAfterListing(): void { }) } -/** How each caller starts a resume: a click names the chats it ticked; an opted-in launch names - * every chat the host offered. Both resume each chat with its own request. */ +/** Clicks and opted-in launches use the same bulk action over their selection. */ const resumes = { click: () => continueNativeChatRestartOffer(['a', 'b']), launch: () => continueNativeChatRestartOffer(offered.map((candidate) => candidate.sessionId)) @@ -101,9 +100,11 @@ it('answers a mixed opted-in launch with one toast', async () => { return { sessions: acted ? [] : offered, failed } } acted = true - const [sessionId] = params?.sessionIds ?? [] return { - continued: [{ sessionId, outcome: sessionId === 'b' ? 'refused' : 'continued' }], + continued: (params?.sessionIds ?? []).map((sessionId) => ({ + sessionId, + outcome: sessionId === 'b' ? 'refused' : 'continued' + })), sessions: [], failed: [failure] } diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 5c65b4752d96..79b1def2f474 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -5,6 +5,7 @@ import { useAppStore } from '../store' import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' import { announceRestartResults, + restartContinuationHistory, type RestartContinuationOutcome } from './native-chat-restart-action-notifications' import { @@ -29,6 +30,7 @@ import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch' +import { watchResumeRunStatus } from './native-chat-resume-run-status-watch' /** * Which interrupted chats the host is still offering to resume, and every action that moves that. @@ -64,6 +66,7 @@ let actionsSettled = 0 /** The resume this window ran, so the status bar and a reopened dialog can follow it after the * dialog that started it closed. In memory only; replaced by the next run once it has finished. */ let run: ResumeRun | null = null +let releaseRunStatus: (() => void) | undefined let resuming: readonly string[] = resumeRunPendingIds(null) const listeners = new Set<() => void>() const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const @@ -212,17 +215,27 @@ export async function continueNativeChatRestartOffer(sessionIds: readonly string actionsBegun += 1 forgetUnsentResumes(requested) const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row])) - const actionRun = beginResumeRun( + let actionRun = beginResumeRun( requested.flatMap((sessionId) => rows.get(sessionId) ?? []), Date.now() ) setRun(actionRun) + releaseRunStatus?.() + const release = watchResumeRunStatus( + () => (run === actionRun ? actionRun : null), + (next) => { + actionRun = next + setRun(next) + } + ) + releaseRunStatus = release markNativeChatLaunchResumeDecided() let outcome: Parameters let continued: readonly RestartContinuationOutcome[] | undefined + let skipped = new Set() try { const result = await callStructuredAgentSession< - HostOfferPayload & { continued?: RestartContinuationOutcome[] } + HostOfferPayload & { continued?: RestartContinuationOutcome[]; skipped?: string[] } >(LOCAL, 'agentSession.restartContinue', { sessionIds: requested }) const listed = Array.isArray(result.failed) ? failedFrom(result) : undefined if (Array.isArray(result.sessions)) { @@ -230,8 +243,10 @@ export async function continueNativeChatRestartOffer(sessionIds: readonly string } else { await refreshNativeChatRestartOffer() } - continued = result.continued - outcome = [requested, result.continued, listed, reopenNativeChatRestartOffer] + skipped = new Set(result.skipped) + const actedOn = requested.filter((sessionId) => !skipped.has(sessionId)) + continued = restartContinuationHistory(actedOn, result.continued, listed) + outcome = [actedOn, continued, listed, reopenNativeChatRestartOffer] } catch (error) { console.warn('[native-chat-resume] resume request failed before reaching the chats', error) markUnsentResumes(requested, Date.now()) @@ -243,12 +258,21 @@ export async function continueNativeChatRestartOffer(sessionIds: readonly string reopenNativeChatRestartOffer ] } finally { + release() + if (releaseRunStatus === release) { + releaseRunStatus = undefined + } actionsSettled += 1 // Publish the offer first: reopening must never expose the old selection as actionable. if (run === actionRun) { setRun( getNativeChatResumeOnRestartDialogRequest() - ? { ...actionRun, inFlight: false, continued } + ? { + ...actionRun, + entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), + inFlight: false, + continued + } : null ) } @@ -354,6 +378,8 @@ export function useNativeChatRestartRun(): ResumeRun | null { /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { + releaseRunStatus?.() + releaseRunStatus = undefined offeredChatWatch.release() offer = EMPTY forgetUnsentResumes(undefined) diff --git a/src/renderer/src/components/native-chat-resume-run-status-watch.ts b/src/renderer/src/components/native-chat-resume-run-status-watch.ts new file mode 100644 index 000000000000..08e2d28763ff --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run-status-watch.ts @@ -0,0 +1,27 @@ +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import { observeResumeRun, type ResumeRun } from './native-chat-resume-run' + +/** Capture every frame, even while the dialog is closed or React batches renders. */ +export function watchResumeRunStatus( + current: () => ResumeRun | null, + publish: (run: ResumeRun) => void +): () => void { + const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const notice = () => { + const run = current() + if (!run) { + return + } + const snapshot = feed.getSnapshot() + const next = observeResumeRun(run, (sessionId) => snapshot.get(sessionId)) + if (next !== run) { + publish(next) + } + } + const unsubscribe = feed.subscribe(notice) + const deactivate = feed.activate() + return () => { + unsubscribe() + deactivate() + } +} diff --git a/src/renderer/src/components/native-chat-resume-run-view.ts b/src/renderer/src/components/native-chat-resume-run-view.ts index 3a292aa66628..4b879a279d7c 100644 --- a/src/renderer/src/components/native-chat-resume-run-view.ts +++ b/src/renderer/src/components/native-chat-resume-run-view.ts @@ -1,13 +1,7 @@ import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' -import type { ResumeRun } from './native-chat-resume-run' -import type { ResumeRunHostStatus } from './use-resume-run-status-feed' +import type { ResumeRun, ResumeRunHostStatus } from './native-chat-resume-run' -/** - * What the dialog shows while it follows a run: one row per chat, each with where it stands. - * - * Pure, so the ordering and the counts are tested without a store. A chat the host already lists as - * failed is shown as that failure row, with its guidance and Retry, rather than as a bare status. - */ +/** Only current host failures create attention obligations after a run. */ /** Where one chat of the run stands, as the leading icon shows it in place of the checkbox. */ export type ResumeRunRowStatus = @@ -53,13 +47,18 @@ export function resumeRunView( const statusBySession = new Map() const placed: { row: ResumeCandidate; category: Category; index: number }[] = [] const inRun = new Set() + let inRunCount = 0 for (const entry of run.entries) { const { sessionId } = entry.candidate inRun.add(sessionId) const row = listedById.get(sessionId) ?? entry.candidate const failure = failureFor(sessionId) - const hostStatus = run.inFlight ? hostStatusFor(sessionId) : undefined + const hostStatus = run.inFlight ? (entry.observedStatus ?? hostStatusFor(sessionId)) : undefined const phase = hostStatus?.restartResume?.phase + if (entry.observedStatus?.restartResume?.phase === 'skipped' || phase === 'skipped') { + continue + } + inRunCount += 1 if (run.inFlight && (phase === undefined || phase === 'queued' || phase === 'starting')) { const hostPhase = hostStatus?.hostExecutionPhase statusBySession.set(sessionId, { @@ -90,7 +89,6 @@ export function resumeRunView( } } const count = (category: Category) => placed.filter((entry) => entry.category === category).length - const inRunCount = run.entries.length const inProgress = count('in-progress') return { rows: placed diff --git a/src/renderer/src/components/native-chat-resume-run.test.ts b/src/renderer/src/components/native-chat-resume-run.test.ts index c10f354cdc91..c4e3d82b7c6d 100644 --- a/src/renderer/src/components/native-chat-resume-run.test.ts +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from 'vitest' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' -import { beginResumeRun, resumeRunInFlight, resumeRunPendingIds } from './native-chat-resume-run' +import { + beginResumeRun, + observeResumeRun, + resumeRunInFlight, + resumeRunPendingIds, + type ResumeRunHostStatus +} from './native-chat-resume-run' import { resumeRunView } from './native-chat-resume-run-view' -import type { ResumeRunHostStatus } from './use-resume-run-status-feed' const row = (sessionId: string): ResumeCandidate => ({ sessionId, @@ -16,6 +21,41 @@ const failure = { ...row('a'), failedAt: 2, outcome: 'unconfirmed' as const, rea const run = beginResumeRun([row('a'), row('b')], 10) describe('the dialog over a bulk action', () => { + it('never regresses a seen phase or verdict when live progress clears', () => { + let observed = run + for (const phase of ['queued', 'starting', 'continued'] as const) { + observed = observeResumeRun(observed, () => ({ restartResume: { phase } })) + const before = resumeRunView(observed, [], () => undefined, 'all') + expect(observeResumeRun(observed, () => undefined)).toBe(observed) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'queued' } }))).toBe( + observed + ) + expect(resumeRunView(observed, [], () => undefined, 'all')).toEqual(before) + } + expect(resumeRunView(observed, [], () => undefined, 'all').counts.done).toBe(2) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'refused' } }))).toBe( + observed + ) + expect(observeResumeRun({ ...observed, inFlight: false }, () => undefined).inFlight).toBe(false) + }) + + it('remembers readiness and excludes skipped chats from rows and the denominator', () => { + const observed = observeResumeRun(run, (id) => ({ + restartResume: { phase: id === 'a' ? 'skipped' : 'starting' }, + hostExecutionPhase: 'ready' + })) + const view = resumeRunView(observed, [row('a')], () => undefined, 'all') + expect(view.rows).toEqual([row('b')]) + expect(view.counts).toMatchObject({ all: 1, total: 1, done: 0, inProgress: 1 }) + expect(view.statusBySession.get('b')).toEqual({ + kind: 'in-flight', + phase: 'ready', + startedAt: 10 + }) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'starting' } }))).toBe( + observed + ) + }) it('settles a chat from its host verdict while the request remains in flight', () => { const statusFor = (id: string): ResumeRunHostStatus => ({ restartResume: { phase: id === 'a' ? 'continued' : 'queued' } diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts index a8be517d1cf5..278fb8d2d067 100644 --- a/src/renderer/src/components/native-chat-resume-run.ts +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -1,14 +1,62 @@ import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' import type { RestartContinuationOutcome } from './native-chat-restart-action-notifications' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +export type ResumeRunHostStatus = Pick< + AgentSessionStatusSummary, + 'restartResume' | 'hostExecutionPhase' +> /** Selection and history belong to this window; live progress and failures belong to the host. */ export type ResumeRun = Readonly<{ startedAt: number - entries: readonly Readonly<{ candidate: ResumeCandidate }>[] + entries: readonly Readonly<{ candidate: ResumeCandidate; observedStatus?: ResumeRunHostStatus }>[] inFlight: boolean continued?: readonly RestartContinuationOutcome[] }> +function progressRank(status: ResumeRunHostStatus | undefined): number { + const phase = status?.restartResume?.phase + if (phase === 'starting') { + return status?.hostExecutionPhase === 'ready' ? 3 : 2 + } + if (phase === 'queued') { + return 1 + } + return phase === 'continued' || + phase === 'refused' || + phase === 'unconfirmed' || + phase === 'skipped' + ? 4 + : 0 +} + +/** Clearing live fields cannot undo progress this action already showed. */ +export function observeResumeRun( + run: ResumeRun, + statusFor: (sessionId: string) => ResumeRunHostStatus | undefined +): ResumeRun { + if (!run.inFlight) { + return run + } + let changed = false + const entries = run.entries.map((entry) => { + const status = statusFor(entry.candidate.sessionId) + if (progressRank(status) <= progressRank(entry.observedStatus)) { + return entry + } + changed = true + return { + ...entry, + observedStatus: { + restartResume: status?.restartResume, + hostExecutionPhase: status?.hostExecutionPhase + } + } + }) + return changed ? { ...run, entries } : run +} + export function resumeRunInFlight(run: ResumeRun | null): boolean { return run?.inFlight ?? false } diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index 84140c4a1887..f51ab5fe2078 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -9,7 +9,6 @@ import { } from '../native-chat-resume-on-restart-store' import { resumeRunInFlight } from '../native-chat-resume-run' import { resumeRunView } from '../native-chat-resume-run-view' -import { useResumeRunStatusFeed } from '../use-resume-run-status-feed' // Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is // then the only surface left carrying it, so it is always rendered rather than gated by @@ -145,13 +144,8 @@ export function NativeChatResumeStatusSegment({ const offerEnabled = useNativeChatRestartOfferEnabled() const { candidates, failed } = useNativeChatRestartOffer(offerEnabled) const run = useNativeChatRestartRun() - const hostStatusFor = useResumeRunStatusFeed( - run?.inFlight ? run.entries.map((entry) => entry.candidate.sessionId) : [] - ) const failureBySession = new Map(failed.map((entry) => [entry.sessionId, entry])) - const view = run - ? resumeRunView(run, candidates, (id) => failureBySession.get(id), 'all', hostStatusFor) - : null + const view = run ? resumeRunView(run, candidates, (id) => failureBySession.get(id), 'all') : null if (!offerEnabled) { return null } @@ -169,9 +163,9 @@ export function NativeChatResumeStatusSegment({ {running && ( } - {...resumingText(view?.counts.done ?? 0, run.entries.length)} + {...resumingText(view?.counts.done ?? 0, view?.counts.total ?? 0)} /> )} {pending > 0 && ( diff --git a/src/renderer/src/components/use-resume-run-status-feed.ts b/src/renderer/src/components/use-resume-run-status-feed.ts deleted file mode 100644 index 7af81c3d76e0..000000000000 --- a/src/renderer/src/components/use-resume-run-status-feed.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' - -export type ResumeRunHostStatus = Pick< - AgentSessionStatusSummary, - 'restartResume' | 'hostExecutionPhase' -> -const LOCAL = { kind: 'local' } as const - -/** Subscribe only to the selected chats' progress, so unrelated status ticks cost no render. */ -export function useResumeRunStatusFeed( - sessionIds: readonly string[] -): (sessionId: string) => ResumeRunHostStatus | undefined { - const feed = useMemo(() => getStructuredAgentSessionStatusFeed(LOCAL), []) - const active = sessionIds.length > 0 - useEffect(() => (active ? feed.activate() : undefined), [feed, active]) - const key = sessionIds.join('\0') - const joined = useSyncExternalStore(feed.subscribe, () => - key === '' - ? '' - : key - .split('\0') - .map((sessionId) => { - const summary = feed.getSnapshot().get(sessionId) - return summary?.restartResume - ? `${summary.restartResume.phase}:${summary.hostExecutionPhase ?? ''}` - : '' - }) - .join('\0') - ) - const statuses = joined.split('\0') - return (sessionId) => { - const [phase, hostPhase] = (statuses[sessionIds.indexOf(sessionId)] ?? '').split(':') - if ( - phase !== 'queued' && - phase !== 'starting' && - phase !== 'continued' && - phase !== 'refused' && - phase !== 'unconfirmed' - ) { - return undefined - } - return { - restartResume: { phase }, - ...(hostPhase === 'starting' || hostPhase === 'ready' - ? { hostExecutionPhase: hostPhase } - : {}) - } - } -} diff --git a/src/shared/agent-session-status-snapshot-fold.test.ts b/src/shared/agent-session-status-snapshot-fold.test.ts index dc08c809d451..916728490f82 100644 --- a/src/shared/agent-session-status-snapshot-fold.test.ts +++ b/src/shared/agent-session-status-snapshot-fold.test.ts @@ -14,7 +14,7 @@ const summary: AgentSessionStatusSummary = { updatedAt: 1 } -it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed'] as const)( +it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed', 'skipped'] as const)( 'folds %s progress and revokes it on contact loss, even without a provider child', (phase) => { const snapshot = foldAgentSessionStatusEvent(new Map(), { diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 09f5c1b57e72..0bd672fdf7a6 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -241,7 +241,9 @@ export type AgentSessionStatusSummary = { hostExecutionPhase?: 'starting' | 'ready' /** This restart action's progress, derived by the live host and never persisted. * Cleared when the action returns; absent on older hosts. */ - restartResume?: { phase: 'queued' | 'starting' | 'continued' | 'refused' | 'unconfirmed' } + restartResume?: { + phase: 'queued' | 'starting' | 'continued' | 'refused' | 'unconfirmed' | 'skipped' + } latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string From 35b01d9fbe67ab50a57851312c23050b42ef2ae5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:18:39 -0700 Subject: [PATCH 4/6] Fix restart progress ownership after skipped requests --- ...nt-session-restart-resume-progress.test.ts | 99 +++++++++++ ...d-agent-session-restart-resume-progress.ts | 6 +- .../components/native-chat-resume-run-view.ts | 2 +- .../components/native-chat-resume-run.test.ts | 21 ++- .../src/components/native-chat-resume-run.ts | 12 +- ...tive-chat-resume-skipped-progress.test.tsx | 160 ++++++++++++++++++ 6 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts index 50efad841257..cb38b4fdce06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -8,9 +8,108 @@ import { throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { createRestartResumeProgress } from './structured-agent-session-restart-resume-progress' +import { journal } from './structured-agent-session-restart-resume-test-harness' +import type { StructuredAgentSessionRestartOfferSession } from './structured-agent-session-restart-offer-withdrawal' afterEach(() => vi.restoreAllMocks()) +it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed'] as const)( + "keeps %s owned by the admitted action through another action's skip and cleanup", + (phase) => { + const session: StructuredAgentSessionRestartOfferSession = { journal: journal([]), child: null } + const sessions = new Map([[SESSION, session]]) + const publish = vi.fn() + const excluded = createRestartResumeProgress(sessions, publish) + const admitted = createRestartResumeProgress(sessions, publish) + const other = createRestartResumeProgress(sessions, publish) + excluded.set(SESSION, 'skipped') + const skipped = session.restartResume + other.set(SESSION, 'skipped') + expect(session.restartResume).toBe(skipped) + admitted.set(SESSION, 'queued') + admitted.set(SESSION, phase) + const owned = session.restartResume + expect(owned?.phase).toBe(phase) + expect(owned?.operationId).not.toBe(skipped?.operationId) + excluded.set(SESSION, 'skipped') + other.set(SESSION, 'queued') + other.set(SESSION, 'refused') + excluded.clear() + other.clear() + expect(session.restartResume).toBe(owned) + admitted.clear() + expect(session.restartResume).toBeUndefined() + expect(publish).toHaveBeenCalledTimes(phase === 'queued' ? 3 : 4) + } +) + +it('publishes a real continuation through an overlapping audience exclusion', async () => { + const { host, dispatch } = await interruptedRestart() + await host.restartResume.list() + const phases: (string | undefined)[] = [] + const release = host.subscribeStatus({ + id: 'overlap', + emit: (event) => { + if (event.type === 'status') { + phases.push(event.session.restartResume?.phase) + } + } + }) + const excludedAtReply = Promise.withResolvers() + const releaseExcluded = Promise.withResolvers() + const list = AgentSessionRecoveryCapsule.prototype.list + let reads = 0 + vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'list').mockImplementation(async function ( + this: AgentSessionRecoveryCapsule, + ...args + ) { + if (++reads === 2) { + excludedAtReply.resolve() + await releaseExcluded.promise + } + return list.apply(this, args) + }) + const releaseDispatch = Promise.withResolvers() + const dispatchNormally = dispatch.getMockImplementation() + dispatch.mockImplementationOnce(async (input) => { + await releaseDispatch.promise + if (!dispatchNormally) { + throw new Error('missing provider dispatch') + } + return dispatchNormally(input) + }) + const excluded = host.restartResume.continueAfterRestart( + [SESSION], + 'excluded-caller', + (agent) => agent !== 'codex' + ) + let admitted: ReturnType | undefined + try { + await excludedAtReply.promise + expect(phases).toEqual(['skipped']) + admitted = host.restartResume.continueAfterRestart([SESSION], 'admitted-caller') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(phases).toContain('queued') + expect(phases.at(-1)).toBe('starting') + releaseExcluded.resolve() + expect(await excluded).toMatchObject({ skipped: [SESSION], continued: [] }) + expect(phases.at(-1)).toBe('starting') + releaseDispatch.resolve() + expect(await admitted).toMatchObject({ + continued: [{ sessionId: SESSION, outcome: 'continued' }] + }) + expect(phases).toContain('continued') + expect(phases.at(-1)).toBeUndefined() + expect(dispatch).toHaveBeenCalledOnce() + } finally { + releaseExcluded.resolve() + releaseDispatch.resolve() + await Promise.allSettled([excluded, admitted]) + release() + } +}) + it('publishes skipped when an offer disappears during reservation without starting its agent', async () => { const { host, acquire, dispatch } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts index eb4a3fe89faa..1989ecc42114 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts @@ -34,7 +34,11 @@ export function createRestartResumeProgress( return } if (phase === 'queued' || phase === 'skipped') { - if (session.restartResume) { + // Excluding a request cannot take progress away from an admitted action. + if ( + session.restartResume && + (phase === 'skipped' || session.restartResume.phase !== 'skipped') + ) { return } owned.set(sessionId, session) diff --git a/src/renderer/src/components/native-chat-resume-run-view.ts b/src/renderer/src/components/native-chat-resume-run-view.ts index 4b879a279d7c..07223a63a869 100644 --- a/src/renderer/src/components/native-chat-resume-run-view.ts +++ b/src/renderer/src/components/native-chat-resume-run-view.ts @@ -55,7 +55,7 @@ export function resumeRunView( const failure = failureFor(sessionId) const hostStatus = run.inFlight ? (entry.observedStatus ?? hostStatusFor(sessionId)) : undefined const phase = hostStatus?.restartResume?.phase - if (entry.observedStatus?.restartResume?.phase === 'skipped' || phase === 'skipped') { + if (run.inFlight && phase === 'skipped') { continue } inRunCount += 1 diff --git a/src/renderer/src/components/native-chat-resume-run.test.ts b/src/renderer/src/components/native-chat-resume-run.test.ts index c4e3d82b7c6d..21badcab4d7f 100644 --- a/src/renderer/src/components/native-chat-resume-run.test.ts +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -52,9 +52,24 @@ describe('the dialog over a bulk action', () => { phase: 'ready', startedAt: 10 }) - expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'starting' } }))).toBe( - observed - ) + const admitted = observeResumeRun(observed, () => ({ restartResume: { phase: 'starting' } })) + expect(admitted.entries[1]).toBe(observed.entries[1]) + expect(resumeRunView(admitted, [], () => undefined, 'all').counts.total).toBe(2) + }) + + it("real action progress supersedes another request's provisional skip", () => { + let observed = observeResumeRun(run, () => ({ restartResume: { phase: 'skipped' } })) + for (const phase of ['queued', 'starting', 'continued'] as const) { + observed = observeResumeRun(observed, () => ({ restartResume: { phase } })) + expect(observed.entries[0]?.observedStatus?.restartResume?.phase).toBe(phase) + expect(observeResumeRun(observed, () => ({ restartResume: { phase: 'skipped' } }))).toBe( + observed + ) + } + expect(resumeRunView(observed, [], () => undefined, 'all').counts).toMatchObject({ + total: 2, + resumed: 2 + }) }) it('settles a chat from its host verdict while the request remains in flight', () => { const statusFor = (id: string): ResumeRunHostStatus => ({ diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts index 278fb8d2d067..dad05c38a1df 100644 --- a/src/renderer/src/components/native-chat-resume-run.ts +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -18,17 +18,15 @@ export type ResumeRun = Readonly<{ function progressRank(status: ResumeRunHostStatus | undefined): number { const phase = status?.restartResume?.phase if (phase === 'starting') { - return status?.hostExecutionPhase === 'ready' ? 3 : 2 + return status?.hostExecutionPhase === 'ready' ? 4 : 3 } if (phase === 'queued') { + return 2 + } + if (phase === 'skipped') { return 1 } - return phase === 'continued' || - phase === 'refused' || - phase === 'unconfirmed' || - phase === 'skipped' - ? 4 - : 0 + return phase === 'continued' || phase === 'refused' || phase === 'unconfirmed' ? 5 : 0 } /** Clearing live fields cannot undo progress this action already showed. */ diff --git a/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx b/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx new file mode 100644 index 000000000000..acdc13bda73e --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { + createResumeModalFixture, + offered, + failure, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { getNativeChatRestartOffer } from './native-chat-resume-on-restart-store' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: ResumeStatusStream['emit'] + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: vi.fn(async () => true) +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, toasts, runStatus } = createResumeModalFixture(rpc, statusStream) +const dialog = () => document.querySelector('[role="dialog"]')?.textContent + +function emitPhase(phase?: NonNullable['phase']) { + statusStream.emit({ + type: 'status', + session: { + sessionId: 'a', + workspaceId: 'workspace', + agent: 'codex', + status: 'idle', + latestPrompt: '', + updatedAt: 1, + ...(phase ? { restartResume: { phase } } : {}) + } + }) +} + +it.each(['received', 'missed'] as const)( + "resumes 1 of 1 after another action's skip when real progress frames are %s", + async (frames) => { + const reply = Promise.withResolvers() + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [offered[0]], failed: [] } + : reply.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 1 chat').click()) + await act(async () => emitPhase('skipped')) + await act(async () => button('Resuming chats 0/0').click()) + expect(dialog()).not.toContain('Prompt a') + if (frames === 'received') { + await act(async () => emitPhase('queued')) + expect(button('Resuming chats 0/1')).toBeTruthy() + expect(runStatus('Prompt a')).toMatch(/Waiting to start/) + await act(async () => { + emitPhase('starting') + emitPhase('continued') + emitPhase() + }) + expect(button('Resuming chats 1/1')).toBeTruthy() + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + } + await act(async () => + reply.resolve({ + sessions: [], + failed: [], + continued: [{ sessionId: 'a', outcome: 'continued' }] + }) + ) + expect(dialog()).toContain('Resumed 1 of 1 chat') + expect(dialog()).toContain('All1') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(toasts()).toEqual([['Resumed 1 chat']]) + } +) + +it.each(['returned', 'lost'] as const)( + 'keeps current failure rows and counts after a skipped retry with a %s reply', + async (delivery) => { + const reply = Promise.withResolvers() + const initial = [ + { ...failure('a', 'agent_session_conflict'), retryable: true }, + { ...failure('b'), outcome: 'unconfirmed' as const } + ] + const reconciled = [{ ...initial[0], retryable: false }, initial[1]] + let reads = 0 + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartContinue' + ? reply.promise + : { sessions: [], failed: reads++ === 0 ? initial : reconciled } + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await mount( + <> + + + + ) + await act(async () => requestNativeChatResumeOnRestartDialog()) + await act(async () => button('Retry').click()) + await act(async () => { + emitPhase('skipped') + emitPhase() + }) + await act(async () => { + if (delivery === 'lost') { + reply.reject(new Error('lost reply after skipped retry')) + } else { + reply.resolve({ sessions: [], failed: reconciled, skipped: ['a'], continued: [] }) + } + }) + expect(getNativeChatRestartOffer().failed.map((row) => row.sessionId)).toEqual(['a', 'b']) + expect(dialog()).toContain('Prompt a') + expect(dialog()).toContain('Prompt b') + expect(dialog()).toContain('All2') + expect(dialog()).toContain('Need you2') + expect(dialog()).toContain( + delivery === 'lost' ? 'Resumed 0 of 1 chat' : 'Resumed 0 of 0 chats' + ) + expect(button('2 chats to check')).toBeTruthy() + expect( + [...document.querySelectorAll('button')].filter( + (entry) => entry.textContent === 'Open chat' + ) + ).toHaveLength(2) + expect(document.querySelectorAll('button[aria-label^="Dismiss"]')).toHaveLength(2) + expect(runStatus('Prompt a')).toBe('Prompt a: Couldn’t resume') + if (delivery === 'lost') { + expect(toasts()).toEqual([['1 chat couldn’t be resumed']]) + } + } finally { + warn.mockRestore() + } + } +) From bf793ec27bb1746c0ca33859b15d288e85e7e0b4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:33:49 -0700 Subject: [PATCH 5/6] Keep completed chat resume summaries until viewed and closed --- .../NativeChatResumeOnRestartModal.test.tsx | 19 +- .../NativeChatResumeOnRestartModal.tsx | 11 +- ...-chat-restart-action-notifications.test.ts | 7 +- ...ative-chat-restart-action-notifications.ts | 29 +-- .../native-chat-resume-finished-run.test.tsx | 175 ++++++++++++++++++ ...at-resume-on-restart-outcome-toast.test.ts | 7 +- .../native-chat-resume-on-restart-store.ts | 30 +-- .../src/components/native-chat-resume-run.ts | 1 + 8 files changed, 233 insertions(+), 46 deletions(-) create mode 100644 src/renderer/src/components/native-chat-resume-finished-run.test.tsx diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 00d917380f88..524e409ced3c 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -185,8 +185,9 @@ it('never re-offers a resumed chat when the status entry reopens the dialog', as await act(async () => button('1 chat to resume').click()) expect(document.querySelector('[role="dialog"]')).not.toBeNull() expect(offerIds()).toEqual(['b']) - // One offered row plus the preference box — never the resumed chat again. + // The resumed chat is history, while only the offered chat remains selectable. expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') }) // The resume outlives the dialog, as a skill update does: the status bar carries it while in flight. @@ -505,8 +506,9 @@ it('answers a mixed Resume with one toast whose Show opens the dialog', async () // Show opens the dialog the click closed, over a fresh read of the list. await act(async () => lastToastShow()?.()) const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('Resumed 1 of 2 chats') expect(dialog?.textContent).toContain('Prompt b') - expect(dialog?.textContent).not.toContain('Prompt a') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') }) // A Dismiss the host never took leaves the chat shown as failed, not back as a plain offer. @@ -543,8 +545,7 @@ function dialogControls(): (string | null)[] { ) } -// A chat the resume could not carry on stays in the same dialog — same title, checkboxes and -// footer — with its row saying what went wrong and what to do. +// The finished summary keeps successful chats alongside current failures and their actions. it('lists a chat the resume could not carry on when the dialog reopens, with what to do', async () => { fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued')) await mount() @@ -554,17 +555,19 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha await act(async () => requestNativeChatResumeOnRestartDialog()) const dialog = document.querySelector('[role="dialog"]') expect(dialog).not.toBeNull() - // Unchanged chrome: the title, the preference box, and the two footer actions. - expect(dialog?.textContent).toContain('Resume interrupted chats?') + expect(dialog?.textContent).toContain('Resumed 1 of 2 chats') expect(dialog?.textContent).toContain("Don't ask again (resume automatically)") expect(dialog?.textContent).not.toContain('Dismiss failed') - // The resumed chat left the list as it always did; the failed one is a row with a checkbox. - expect(dialog?.textContent).not.toContain('Prompt a') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) expect(document.querySelector('[aria-label="Prompt b: Couldn’t resume"]')).not.toBeNull() expect(dialog?.textContent).toContain('To resume:') expect(dialog?.textContent).toContain('Open the chat and reply.') expect(dialogControls()).toEqual([ + 'All2', + 'In progress0', + 'Resumed1', + 'Need you1', 'Dismiss "Prompt b" in workspace', 'Open chat', 'Dismiss all', diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 0ee6ee6b41d1..8c021e273d6a 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' @@ -29,6 +29,7 @@ import { import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, + markFinishedNativeChatRestartRunShown, releaseFinishedNativeChatRestartRun, useNativeChatRestartOffer, useNativeChatRestartResuming, @@ -124,12 +125,18 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { (sessionId: string) => failureBySession.get(sessionId), [failureBySession] ) + const run = useNativeChatRestartRun() const runPanel = useResumeRunPanel({ - run: useNativeChatRestartRun(), + run, rows, failureFor, open }) + useEffect(() => { + if (offerEnabled && open && run) { + markFinishedNativeChatRestartRunShown(run) + } + }, [offerEnabled, open, run]) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { setOverrides((current) => new Map(current).set(sessionId, checked)) diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.test.ts b/src/renderer/src/components/native-chat-restart-action-notifications.test.ts index a9b4ccb562de..b6d0b305fee4 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.test.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.test.ts @@ -19,17 +19,18 @@ function titles(): unknown[] { return vi.mocked(toast).mock.calls.map(([text]) => text) } -it('says how many chats were resumed', () => { +it.each([true, false])('offers Show for successful chats with a failure list: %s', (listed) => { announceRestartResults( ['a', 'b'], [ { sessionId: 'a', outcome: 'continued' }, { sessionId: 'b', outcome: 'continued' } ], - [], + listed ? [] : undefined, show ) - expect(vi.mocked(toast).mock.calls).toEqual([['Resumed 2 chats']]) + expect(titles()).toEqual(['Resumed 2 chats']) + expect(lastToastShow()).toBeDefined() }) it('offers Show for chats a resume could not carry on', () => { diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.ts b/src/renderer/src/components/native-chat-restart-action-notifications.ts index da34606a84c5..5b6c3356124d 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.ts @@ -75,18 +75,25 @@ function descriptionFrom(lines: readonly string[]): ReactNode { ) } -/** One toast per resume. No names: the dialog behind Show has the list. Unconfirmed chats get their - * own count because the agent may well be working; "couldn't be resumed" would invite a second - * send. Without a failure list there is nothing for Show to open. */ +/** One toast per resume; Show opens the run summary. Unconfirmed chats may already be working. */ function announceResume( continued: number, refused: number, unconfirmed: number, show: (() => Promise) | undefined ): void { + const action = + show === undefined + ? {} + : { + action: { + label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'), + onClick: () => void show() + } + } if (refused === 0 && unconfirmed === 0) { if (continued > 0) { - toast(continuedCountText(continued)) + toast(continuedCountText(continued), action) } return } @@ -97,14 +104,7 @@ function announceResume( ] toast(title, { ...(lines.length === 0 ? {} : { description: descriptionFrom(lines) }), - ...(show === undefined - ? {} - : { - action: { - label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'), - onClick: () => void show() - } - }) + ...action }) } @@ -163,10 +163,11 @@ export function announceRestartResults( (failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) === 'unconfirmed' const unconfirmedCount = reported.filter(unconfirmed).length + const continuedCount = new Set(requested).size - notContinued.length announceResume( - new Set(requested).size - notContinued.length, + continuedCount, reported.length - unconfirmedCount, unconfirmedCount, - hostFailed === undefined ? undefined : show + hostFailed === undefined && continuedCount === 0 ? undefined : show ) } diff --git a/src/renderer/src/components/native-chat-resume-finished-run.test.tsx b/src/renderer/src/components/native-chat-resume-finished-run.test.tsx new file mode 100644 index 000000000000..87783927ae61 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-finished-run.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import { + createResumeModalFixture, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { lastToastShow } from './native-chat-resume-toast.test-support' +import { + _resetNativeChatRestartOffer, + getNativeChatRestartRun, + refreshNativeChatRestartOffer, + releaseFinishedNativeChatRestartRun +} from './native-chat-resume-on-restart-store' +import { + consumeNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { useAppStore } from '../store' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: ResumeStatusStream['emit'] + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, fakeHost, runStatus, toasts } = createResumeModalFixture(rpc, statusStream) +const dialog = () => document.querySelector('[role="dialog"]')?.textContent + +async function mountSurfaces(): Promise { + await mount( + <> + + + + ) +} + +it.each(['status entry', 'toast Show'] as const)( + 'opens every finished result from %s after Resume closed the dialog', + async (source) => { + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused'), 'unknown') + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + expect(dialog()).toBeUndefined() + await act(async () => + source === 'status entry' ? button('1 chat failed to resume').click() : lastToastShow()?.() + ) + expect(dialog()).toContain('Resumed 1 of 2 chats') + expect(dialog()).toContain('All2') + expect(dialog()).toContain('Need you1') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Couldn’t resume') + expect(button('Retry')).toBeTruthy() + expect(button('Open chat')).toBeTruthy() + expect(button('Dismiss "Prompt b" in workspace')).toBeTruthy() + expect(toasts()).toEqual([['1 chat couldn’t be resumed', 'Resumed 1 chat']]) + } +) + +it.each(['status entry', 'toast Show'] as const)( + 'returns to the host failure offer from %s after the finished view is closed', + async (source) => { + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused')) + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('1 chat failed to resume').click()) + expect(dialog()).toContain('Resumed 1 of 2 chats') + await act(async () => button('Close').click()) + await act(async () => + source === 'status entry' ? button('1 chat failed to resume').click() : lastToastShow()?.() + ) + expect(dialog()).toContain('Resume interrupted chats?') + expect(dialog()).toContain('Prompt b') + expect(dialog()).not.toContain('Prompt a') + } +) + +it('keeps the unseen summary when a view was opened and closed only during the run', async () => { + const host = fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'unknown')) + host.hold('a', 'b') + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) + expect(dialog()).toContain('Resuming 2 chats') + await act(async () => button('Close').click()) + await host.release('a', 'b') + await act(async () => button('1 chat to check').click()) + expect(dialog()).toContain('Resumed 1 of 2 chats') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Couldn’t confirm the chat was resumed') +}) + +it('shows the all-success summary from its toast without a status entry', async () => { + fakeHost() + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + expect(dialog()).toBeUndefined() + expect(document.body.textContent).not.toContain('chat') + expect(lastToastShow()).toBeDefined() + await act(async () => lastToastShow()?.()) + expect(dialog()).toContain('Resumed 2 of 2 chats') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + await act(async () => button('Done').click()) + await act(async () => lastToastShow()?.()) + expect(dialog()).toBeUndefined() +}) + +it('replaces the finished summary when Retry starts a new run', async () => { + const host = fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused'), 'unknown') + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('1 chat failed to resume').click()) + host.hold('b') + await act(async () => button('Retry').click()) + expect(dialog()).toContain('Resuming 1 chat') + expect(dialog()).not.toContain('Prompt a') + await host.release('b') + expect(dialog()).toContain('Resumed 0 of 1 chat') +}) + +it('does not consume a finished run if its requested view never rendered', async () => { + fakeHost() + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => { + requestNativeChatResumeOnRestartDialog() + consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() + }) + expect(dialog()).toBeUndefined() + await act(async () => lastToastShow()?.()) + expect(dialog()).toContain('Resumed 2 of 2 chats') +}) + +it('keeps the run out of settings and storage and loses it on a fresh renderer store', async () => { + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'continued' : 'refused')) + await mountSurfaces() + const settings = useAppStore.getState().settings + const saveSettings = vi.fn(async () => {}) + useAppStore.setState({ updateSettings: saveSettings }) + const storage = vi.spyOn(Storage.prototype, 'setItem') + try { + await act(async () => button('Resume 2 chats').click()) + expect(saveSettings).not.toHaveBeenCalled() + expect(useAppStore.getState().settings).toBe(settings) + expect(storage).not.toHaveBeenCalled() + await mount(null) + _resetNativeChatRestartOffer() + expect(getNativeChatRestartRun()).toBeNull() + await refreshNativeChatRestartOffer() + await mountSurfaces() + await act(async () => button('1 chat failed to resume').click()) + expect(dialog()).toContain('Resume interrupted chats?') + expect(dialog()).not.toContain('Prompt a') + } finally { + storage.mockRestore() + } +}) diff --git a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts index cf30530c453b..068e9ea95a78 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-outcome-toast.test.ts @@ -129,9 +129,8 @@ it('raises no toast when the host had nothing left for an opted-in launch to res expect(toast).not.toHaveBeenCalled() }) -// The agent was seen carrying on while the toast was up, so the host retired the failure: Show -// re-reads and opens nothing, rather than latching a request for a dialog with no rows to draw. -it('opens nothing from Show once the host no longer lists the chat', async () => { +// A withdrawn failure cannot recreate attention, but the unseen run summary stays available. +it('opens the retained summary from Show after the host withdraws a failure', async () => { let failed = [{ ...offered[0]!, failedAt: 1, outcome: 'unconfirmed', reason: 'unknown' }] rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' @@ -148,5 +147,5 @@ it('opens nothing from Show once the host no longer lists the chat', async () => await new Promise((resolve) => setTimeout(resolve, 0)) expect(rpc.mock.calls.at(-1)?.[1]).toBe('agentSession.restartResumable') expect(getNativeChatRestartOffer().failed).toEqual([]) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) }) diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 79b1def2f474..33e5faa58791 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -26,7 +26,6 @@ import { } from './native-chat-resume-unsent-requests' import { consumeNativeChatResumeOnRestartDialogRequest, - getNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch' @@ -63,8 +62,7 @@ let launch: Promise | undefined * a re-read that raced it must neither pre-empt nor undo. */ let actionsBegun = 0 let actionsSettled = 0 -/** The resume this window ran, so the status bar and a reopened dialog can follow it after the - * dialog that started it closed. In memory only; replaced by the next run once it has finished. */ +/** Memory-only history: retires after viewing and closing its finished summary, or on replacement. */ let run: ResumeRun | null = null let releaseRunStatus: (() => void) | undefined let resuming: readonly string[] = resumeRunPendingIds(null) @@ -130,9 +128,15 @@ export function getNativeChatRestartRun(): ResumeRun | null { return run } -/** Closing the dialog after a run finished drops its rows; the host's list carries what is left. */ +/** A finished summary must be shown before closing can retire it. */ +export function markFinishedNativeChatRestartRunShown(shown: ResumeRun): void { + if (run === shown && !shown.inFlight && !shown.finishedViewShown) { + setRun({ ...shown, finishedViewShown: true }) + } +} + export function releaseFinishedNativeChatRestartRun(): void { - if (run && !resumeRunInFlight(run)) { + if (run?.finishedViewShown && !resumeRunInFlight(run)) { setRun(null) } } @@ -265,16 +269,12 @@ export async function continueNativeChatRestartOffer(sessionIds: readonly string actionsSettled += 1 // Publish the offer first: reopening must never expose the old selection as actionable. if (run === actionRun) { - setRun( - getNativeChatResumeOnRestartDialogRequest() - ? { - ...actionRun, - entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), - inFlight: false, - continued - } - : null - ) + setRun({ + ...actionRun, + entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), + inFlight: false, + continued + }) } } announceRestartResults(...outcome) diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts index dad05c38a1df..5f7cc7669dcc 100644 --- a/src/renderer/src/components/native-chat-resume-run.ts +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -13,6 +13,7 @@ export type ResumeRun = Readonly<{ entries: readonly Readonly<{ candidate: ResumeCandidate; observedStatus?: ResumeRunHostStatus }>[] inFlight: boolean continued?: readonly RestartContinuationOutcome[] + finishedViewShown?: boolean }> function progressRank(status: ResumeRunHostStatus | undefined): number { From 26484ecd92833aba9a21f76545a320ff7ef4374c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:28:55 -0700 Subject: [PATCH 6/6] chore(i18n): drop status-bar keys the merge restored --- src/renderer/src/i18n/en-runtime-required.json | 6 ------ src/renderer/src/i18n/locales/en.json | 4 ---- src/renderer/src/i18n/locales/es.json | 4 ---- src/renderer/src/i18n/locales/fr.json | 4 ---- src/renderer/src/i18n/locales/ja.json | 4 ---- src/renderer/src/i18n/locales/ko.json | 4 ---- src/renderer/src/i18n/locales/zh.json | 4 ---- 7 files changed, 30 deletions(-) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index c2d8dcd8fd9c..c2d354195b9e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2390,12 +2390,6 @@ "tooltip": { "7567cd1c6b": "Usage unavailable", "cedb7b99e3": "% used" - }, - "NativeChatResumeStatusSegment": { - "resumingAria": "Resuming {{value0}} chats. Click to open details.", - "resumingAriaOne": "Resuming 1 chat. Click to open details.", - "resumingLabel": "Resuming {{value0}} chats", - "resumingLabelOne": "Resuming 1 chat" } } }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ba909a2ff7ff..308ffc157ec6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4316,10 +4316,6 @@ "checkAriaOne": "1 chat to check after resuming. Click for details.", "checkAria": "{{value0}} chats to check after resuming. Click for details.", "checkTooltip": "Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details.", - "resumingLabelOne": "Resuming 1 chat", - "resumingLabel": "Resuming {{value0}} chats", - "resumingAriaOne": "Resuming 1 chat. Click to open details.", - "resumingAria": "Resuming {{value0}} chats. Click to open details.", "resumingTooltip": "Restoring interrupted chats and asking them to carry on…", "resumingProgressLabel": "Resuming chats {{value0}}/{{value1}}", "resumingProgressAria": "Resuming chats, {{value0}} of {{value1}} done. Click to open details." diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cca211419a71..3b0c30b0da9f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4055,10 +4055,6 @@ "checkAriaOne": "1 chat por revisar tras la reanudación. Haz clic para ver los detalles.", "checkAria": "{{value0}} chats por revisar tras la reanudación. Haz clic para ver los detalles.", "checkTooltip": "Chats que Orca no pudo reanudar, o no pudo confirmar que reanudó, tras el reinicio. Haz clic para ver los detalles.", - "resumingLabelOne": "Reanudando 1 chat", - "resumingLabel": "Reanudando {{value0}} chats", - "resumingAriaOne": "Reanudando 1 chat. Haz clic para ver los detalles.", - "resumingAria": "Reanudando {{value0}} chats. Haz clic para ver los detalles.", "resumingTooltip": "Restaurando los chats interrumpidos y pidiéndoles que continúen…", "labelOne": "1 chat para reanudar", "label": "{{value0}} chats para reanudar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9b26ee83ed71..94fd09e50e14 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4177,10 +4177,6 @@ "checkAriaOne": "1 chat à vérifier après la reprise. Cliquez pour plus de détails.", "checkAria": "{{value0}} chats à vérifier après la reprise. Cliquez pour plus de détails.", "checkTooltip": "Chats qu'Orca n'a pas pu reprendre, ou dont il n'a pas pu confirmer la reprise, après le redémarrage. Cliquez pour plus de détails.", - "resumingLabelOne": "Reprise d’1 conversation", - "resumingLabel": "Reprise de {{value0}} conversations", - "resumingAriaOne": "Reprise d’1 conversation. Cliquez pour voir les détails.", - "resumingAria": "Reprise de {{value0}} conversations. Cliquez pour voir les détails.", "resumingTooltip": "Restauration des conversations interrompues et demande de poursuite…", "resumingProgressLabel": "Reprise des discussions {{value0}}/{{value1}}", "resumingProgressAria": "Reprise des discussions, {{value0}} sur {{value1}} terminées. Cliquez pour voir les détails." diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c337915f91b4..476ef2b36f9e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4072,10 +4072,6 @@ "checkAriaOne": "再開後に確認が必要なチャット 1 件。詳細はクリックしてください。", "checkAria": "再開後に確認が必要なチャット {{value0}} 件。詳細はクリックしてください。", "checkTooltip": "再起動後に Orca が再開できなかった、または再開を確認できなかったチャット。詳細はクリックしてください。", - "resumingLabelOne": "1 件のチャットを再開中", - "resumingLabel": "{{value0}} 件のチャットを再開中", - "resumingAriaOne": "1 件のチャットを再開中。詳細はクリックしてください。", - "resumingAria": "{{value0}} 件のチャットを再開中。詳細はクリックしてください。", "resumingTooltip": "中断されたチャットを復元し、続行を依頼しています…", "resumingProgressLabel": "チャットを再開中 {{value0}}/{{value1}}", "resumingProgressAria": "チャットを再開中。{{value1}} 件中 {{value0}} 件完了。クリックすると詳細を表示します。" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e5ef827a16a6..df627ff910cc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4072,10 +4072,6 @@ "checkAriaOne": "재개한 후 확인할 채팅 1개. 자세히 보려면 클릭하세요.", "checkAria": "재개한 후 확인할 채팅 {{value0}}개. 자세히 보려면 클릭하세요.", "checkTooltip": "다시 시작한 후 Orca가 재개하지 못했거나 재개했는지 확인하지 못한 채팅. 자세히 보려면 클릭하세요.", - "resumingLabelOne": "채팅 1개 재개 중", - "resumingLabel": "채팅 {{value0}}개 재개 중", - "resumingAriaOne": "채팅 1개 재개 중. 자세히 보려면 클릭하세요.", - "resumingAria": "채팅 {{value0}}개 재개 중. 자세히 보려면 클릭하세요.", "resumingTooltip": "중단된 채팅을 복원하고 계속하도록 요청하는 중…", "resumingProgressLabel": "채팅 재개 중 {{value0}}/{{value1}}", "resumingProgressAria": "채팅 재개 중, {{value1}}개 중 {{value0}}개 완료. 자세히 보려면 클릭하세요." diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ce8255376284..46adc7e7c0cc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4072,10 +4072,6 @@ "checkAriaOne": "恢复后有 1 个待检查的聊天。点击查看详情。", "checkAria": "恢复后有 {{value0}} 个待检查的聊天。点击查看详情。", "checkTooltip": "重启后 Orca 未能恢复或未能确认已恢复的聊天。点击查看详情。", - "resumingLabelOne": "正在恢复 1 个聊天", - "resumingLabel": "正在恢复 {{value0}} 个聊天", - "resumingAriaOne": "正在恢复 1 个聊天。点击查看详情。", - "resumingAria": "正在恢复 {{value0}} 个聊天。点击查看详情。", "resumingTooltip": "正在恢复中断的聊天并请它们继续…", "resumingProgressLabel": "正在恢复聊天 {{value0}}/{{value1}}", "resumingProgressAria": "正在恢复聊天,已完成 {{value0}}/{{value1}}。点击查看详情。"