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 2230efa15c9..34fe554d31c 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 e0f193ba0b4..9a033b5f831 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-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts index f468d0bd12e..c53f9b65951 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-cap.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts index 155785ae35e..59adcf21a68 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,45 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l 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 @@ -42,7 +80,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 +103,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 +116,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-offer-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.ts index 4924860b05f..96aabe1483b 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-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 0d73656b347..8614f435320 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 1cbbd8236ad..d15ab6c874c 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 120aefb6f20..e1a4e14cd0d 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 @@ -139,13 +140,14 @@ 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, 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. @@ -159,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 } @@ -178,10 +181,16 @@ 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( { admission, + onPhase: progress.set, + onRefused: (sessionId) => progress.set(sessionId, 'refused'), consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) return ( @@ -219,55 +228,75 @@ 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, + ...(progress.skipped.length > 0 ? { skipped: progress.skipped } : {}), + ...(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 00000000000..cb38b4fdce0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -0,0 +1,249 @@ +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, + startAgent, + 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) + 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) => { + 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 00000000000..1989ecc4211 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.ts @@ -0,0 +1,73 @@ +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() + 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 + } + const session = sessions.get(sessionId) + if (!session) { + return + } + if (phase === 'queued' || phase === 'skipped') { + // 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) + } 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 a298e23f454..14471e808dc 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,27 +55,47 @@ 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 spans callers, so simultaneous actions share the same host budget. */ 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 } - 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) } + // 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() + onPhase?.('queued') + const release = await this.starts.acquire(0) + try { + onPhase?.('starting') + return await task() + } finally { + release() + } } finally { this.owners.delete(sessionId) } @@ -83,11 +104,12 @@ 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. */ resume: (sessionId: string) => Promise - concurrency?: number } export async function resumeStructuredAgentSessionsFromRestart( @@ -95,15 +117,16 @@ 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)) - } + // The admission's start limit staggers these, shared with every other resume on this host. + 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 + }) ) - return outcomes } async function resumeOne( @@ -112,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 1c17dc11e30..4a35bee8977 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-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index b16c7277fc1..bc88fe1c415 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) }) + + // Different callers share one host-wide start limit. + 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/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 e053d401200..2a26af994dc 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 f5c3aba1978..ed9371e8e42 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 1b5c8d2b092..70980484b77 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 0483b0df5a7..47c0c446679 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -45,7 +45,8 @@ export function ResumeCandidateRow({ disabled, onCheckedChange, failure, - onFailureAction + onFailureAction, + renderStatus }: { candidate: ResumeCandidate /** Named in the checkbox's accessible name: several rows otherwise read identically. */ @@ -57,6 +58,7 @@ export function ResumeCandidateRow({ /** Present when an earlier resume of this chat did not carry on. */ failure?: ResumeFailure onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void + renderStatus?: (sessionId: string, title: string) => React.ReactNode }): React.JSX.Element { const agentLabel = formatAgentTypeLabel(candidate.agent) const title = @@ -65,6 +67,7 @@ export function ResumeCandidateRow({ const model = candidate.model?.trim() ?? '' const activity = resumeActivityLabel(candidate.activity) const depth = useContext(ResumeTreeDepthContext) + const status = renderStatus?.(candidate.sessionId, title) const act = (action: ResumeFailureAction) => onFailureAction?.(action, candidate.sessionId) return ( ) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index 0444159c26b..60497e8a7b7 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -160,6 +160,7 @@ function WorkspaceNode({ onCheckedChange={chat.onCheckedChange} failure={chat.failure} onFailureAction={tree.onFailureAction} + renderStatus={chat.renderStatus} /> ) })} @@ -343,7 +344,9 @@ export function ResumeOnRestartGroups({ selected, onToggle, failureFor, - onFailureAction + onFailureAction, + renderStatus, + selectableIds }: { candidates: readonly ResumeCandidate[] listedAt: number @@ -377,7 +380,9 @@ export function ResumeOnRestartGroups({ repoIdOf, ancestorsOf, failureFor, - onFailureAction + onFailureAction, + renderStatus, + selectableIds } // Why: the machine is worth a level only when it is not obvious. const showMachines = diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx index bac1011793e..1d778c69746 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx @@ -148,7 +148,7 @@ it('keeps initial focus inside the dialog when reopened during resume', async () ) await act(async () => button('Resume 2 chats').click()) - const opener = button('Resuming 2 chats') + const opener = button('Resuming chats 0/2') await act(async () => { opener.focus() opener.click() @@ -214,10 +214,10 @@ it('disables Select all and the workspace checkbox while a resume runs', async ( ) await act(async () => button('Resume 2 chats').click()) - await act(async () => button('Resuming 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) const list = document.querySelector('[aria-label="Chats that would be resumed"]')! const boxes = [...list.querySelectorAll('[role="checkbox"]')] - expect(boxes).toHaveLength(4) + expect(boxes).toHaveLength(2) for (const box of boxes) { expect(box.hasAttribute('disabled')).toBe(true) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 2ed87aa5f31..5c419087020 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -1,121 +1,75 @@ // @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 } from './native-chat-resume-on-restart-grouping' import { - button, chatBox, chatBoxes, - dontAskAgain, - failure, - offered + dontAskAgain } from './native-chat-resume-on-restart-modal.test-support' 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 - -/** 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 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] - ) -} - -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, offerIds, toasts, fakeHost, runStatus, calls } = createResumeModalFixture( + rpc, + statusStream +) it('keeps next-launch preference out of the current resume action', async () => { - const action = Promise.withResolvers() - rpc.mockImplementation(async (_target, calledMethod) => { - if (calledMethod === 'agentSession.restartResumable') { - return { sessions: offered } - } - return action.promise - }) + const host = fakeHost() + host.hold('a') await mount() await act(async () => chatBox('b').click()) await act(async () => dontAskAgain().click()) await act(async () => button('Resume 1 chat').click()) expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) - expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], ['agentSession.restartContinue', { sessionIds: ['a'] }] ]) - await act(async () => - action.resolve({ - resumed: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }], - sessions: [] - }) - ) - expect(rpc).toHaveBeenCalledTimes(2) + await host.release('a') + // 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. @@ -240,18 +194,15 @@ 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 — never the resumed chat again. + // The resumed chat is history, while only the offered chat remains selectable. expect(chatBoxes()).toHaveLength(1) + 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. it('closes on Resume and shows the resume in the status bar until the host answers', async () => { - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ sessions: offered }) - : continued.promise - ) + const host = fakeHost() + host.hold('a', 'b') await mount( <> @@ -260,42 +211,53 @@ it('closes on Resume and shows the resume in the status bar until the host answe ) await act(async () => button('Resume 2 chats').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() - expect(button('Resuming 2 chats. Click to open details.').textContent).toBe('Resuming 2 chats') + expect(button('Resuming chats, 0 of 2 done. Click to open details.').textContent).toBe( + 'Resuming chats 0/2' + ) // Counted once, as in flight, not also as still to resume. expect(document.body.textContent).not.toContain('chats to resume') // Reopening mid-run shows the run, without a re-read that could race the host's answer. const reads = rpc.mock.calls.length - await act(async () => button('Resuming 2 chats').click()) + await act(async () => button('Resuming chats 0/2').click()) expect(rpc.mock.calls.length).toBe(reads) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resuming 2 chats') expect(button('Resuming…').disabled).toBe(true) + // Each chat being resumed shows a spinner where its checkbox was; tree nodes stay disabled. + expect(runStatus('Prompt a')).toMatch(/^Prompt a: Waiting to start · \d+s$/) + expect(runStatus('Prompt b')).toMatch(/^Prompt b: Waiting to start · \d+s$/) + expect(chatBoxes()).toHaveLength(0) + const status = document.querySelector('[role="img"][aria-label^="Prompt a: "]') + expect(status?.parentElement?.classList.contains('w-7')).toBe(true) + expect(status?.parentElement?.parentElement?.firstElementChild?.contains(status)).toBe(true) + expect(status?.closest('[role="treeitem"]')?.getAttribute('aria-level')).toBe('2') + expect(status?.closest('label')).toBeNull() + + // Each chat settles on its own answer, not when the slowest one does. + await host.release('a') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + expect(runStatus('Prompt b')).toMatch(/^Prompt b: Waiting to start/) + expect(button('Resuming chats 1/2')).toBeTruthy() expect(toast).not.toHaveBeenCalled() - await act(async () => - continued.resolve({ - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [] - }) - ) - expect(document.body.textContent).not.toContain('Resuming') + await host.release('b') + expect(document.body.textContent).not.toContain('Resuming chats') + // The dialog the user reopened stays on the run it was following until they close it. + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 2 of 2 chats') + // The click is answered once, when the run settles, across chats that are off-screen. + expect(toasts()).toEqual([['Resumed 2 chats']]) + await act(async () => button('Done').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() // Nothing is left to show, so the reopen request is retired rather than left to latch. expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) - // The click is answered once, when the run settles, across chats that are off-screen. - expect(toasts()).toEqual([['Resumed 2 chats']]) }) // A dialog the user reopened mid-run is theirs: the run's answer must not close it over a chat // they left out of the resume and can now act on. it('keeps a dialog reopened mid-resume open over the chats still offered', async () => { const third = { ...offered[1]!, sessionId: 'c', latestPrompt: 'Prompt c' } - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ sessions: [...offered, third] }) - : continued.promise - ) + const host = fakeHost({ sessions: [...offered, third] }) + host.hold('a', 'b') await mount( <> @@ -307,23 +269,18 @@ it('keeps a dialog reopened mid-resume open over the chats still offered', async expect(document.querySelector('[role="dialog"]')).toBeNull() await act(async () => button('1 chat to resume').click()) expect(document.querySelector('[role="dialog"]')).not.toBeNull() - // Mid-run the ticks say what is running, so the chat left out reads as left out. + // Mid-run the chats being resumed show where they stand; the chat left out reads as left out. const rowC = () => document.querySelector('[role="checkbox"][aria-label*="Prompt c"]') - expect(chatBox('a').getAttribute('data-state')).toBe('checked') - expect(chatBox('b').getAttribute('data-state')).toBe('checked') + expect(runStatus('Prompt a')).toMatch(/Waiting to start/) + expect(runStatus('Prompt b')).toMatch(/Waiting to start/) expect(rowC()?.getAttribute('data-state')).toBe('unchecked') - await act(async () => - continued.resolve({ - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [third] - }) - ) + await host.release('a', 'b') expect(rowC()?.getAttribute('data-state')).toBe('checked') const dialog = document.querySelector('[role="dialog"]') expect(dialog?.textContent).toContain('Prompt c') - expect(dialog?.textContent).not.toContain('Prompt a') + expect(dialog?.textContent).toContain('Resumed 2 of 2 chats') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') // The run is over, so the chat left out is actionable again, and this opening ticks it afresh. expect(button('Dismiss all').disabled).toBe(false) expect(chatBox('c').getAttribute('data-state')).toBe('checked') @@ -347,15 +304,7 @@ it('starts each opening from the default ticks, not the ones left at the last cl // chats the host already handed back sends the user to a status entry that re-reads, finds nothing, // and does nothing. it('settles the offer for the chats a resume reattached', async () => { - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }], - sessions: [offered[1]!] - } - ) + fakeHost() await mount() await act(async () => chatBox('b').click()) await act(async () => button('Resume 1 chat').click()) @@ -372,15 +321,7 @@ it('resumes and continues once when the launch begins opted in', async () => { nativeChatResumeWorkOnRestart: true } }) - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [] - } - ) + fakeHost() await mount( @@ -398,9 +339,10 @@ it('resumes and continues once when the launch begins opted in', async () => { await act(async () => useAppStore.getState().updateSettings({ experimentalStructuredNativeChat: true }) ) - expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + // One bulk action, with its remaining list in the reply. + expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], - ['agentSession.restartContinue', {}] + ['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']]) @@ -417,23 +359,15 @@ it('shows an opted-in launch resume in the status bar while it runs', async () = nativeChatResumeWorkOnRestart: true } }) - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ sessions: offered }) - : continued.promise - ) + const host = fakeHost() + host.hold('a', 'b') await mount() - expect(button('Resuming 2 chats').getAttribute('aria-label')).toBe( - 'Resuming 2 chats. Click to open details.' - ) - await act(async () => - continued.resolve({ - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), - sessions: [] - }) + expect(button('Resuming chats 0/2').getAttribute('aria-label')).toBe( + 'Resuming chats, 0 of 2 done. Click to open details.' ) + await host.release('b') + expect(button('Resuming chats 1/2')).toBeTruthy() + await host.release('a') expect(document.body.textContent).not.toContain('Resuming') }) @@ -446,16 +380,8 @@ it('reports chats an opted-in launch could not carry on in one toast and the sta nativeChatResumeWorkOnRestart: true } }) - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: [], - continued: [{ sessionId: 'a', outcome: 'refused' }], - sessions: [offered[1]!], - failed: [failure('a')] - } - ) + // The host carries neither chat on: it refuses one and never reaches the other. + fakeHost({}, (sessionId) => (sessionId === 'a' ? 'refused' : 'skipped')) await mount( <> @@ -472,23 +398,12 @@ it('reports chats an opted-in launch could not carry on in one toast and the sta it('dispatches the selected action while a future preference save is still pending', async () => { const saved = Promise.withResolvers() useAppStore.setState({ updateSettings: () => saved.promise }) - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - resumed: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }], - sessions: [] - } - ) + fakeHost() await mount() await act(async () => chatBox('b').click()) await act(async () => dontAskAgain().click()) await act(async () => button('Resume 1 chat').click()) - expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartContinue', - { sessionIds: ['a'] } - ]) + expect(calls()).toContainEqual(['agentSession.restartContinue', { sessionIds: ['a'] }]) await act(async () => saved.reject(new Error('settings write failed'))) expect(rpc).toHaveBeenCalledTimes(2) }) @@ -501,18 +416,7 @@ it.each([ ] as const)( 'reports a %s continuation once in a toast and in the status bar', async (outcome, label, said) => { - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { - continued: offered.map(({ sessionId }) => ({ - sessionId, - outcome: outcome === 'refused' ? 'refused' : 'unknown' - })), - sessions: [], - failed: offered.map(({ sessionId }) => ({ ...failure(sessionId), outcome })) - } - ) + fakeHost({}, () => (outcome === 'refused' ? 'refused' : 'unknown')) await mount( <> @@ -522,6 +426,7 @@ it.each([ await act(async () => button('Resume 2 chats').click()) expect(button(label)).toBeTruthy() expect(toasts()).toEqual([[said]]) + // One offer read and one bulk action. expect(rpc).toHaveBeenCalledTimes(2) } ) @@ -574,7 +479,7 @@ it('reports the chats a lost resume request named once, as failed, until it is r const show = lastToastShow() expect(show).toBeDefined() // A lost action response is followed by a read-only reconciliation, never a retry. - expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + expect(calls()).toEqual([ ['agentSession.restartResumable', undefined], ['agentSession.restartContinue', { sessionIds: ['a', 'b'] }], ['agentSession.restartResumable', undefined] @@ -601,34 +506,14 @@ 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(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartContinue', - { sessionIds: ['b'] } - ]) + expect(calls().at(-1)).toEqual(['agentSession.restartContinue', { sessionIds: ['b'] }]) expect(getNativeChatRestartOffer()).toMatchObject({ candidates: [], failed: [] }) expect(toasts().at(-1)).toEqual(['Resumed 1 chat']) }) // One click, one toast across chats mostly off-screen; its Show opens the list behind it. it('answers a mixed Resume with one toast whose Show opens the dialog', async () => { - let sessions: ResumeCandidate[] = offered - let failed: unknown[] = [] - rpc.mockImplementation(async (_target, method) => { - if (method === 'agentSession.restartResumable') { - return { sessions, failed } - } - sessions = [] - failed = [failure('b')] - return { - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ], - sessions, - failed - } - }) + fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued')) await mount() await act(async () => button('Resume 2 chats').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -636,8 +521,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. @@ -676,24 +562,9 @@ function dialogControls(): (string | null)[] { ].map((entry) => entry.textContent?.trim() || entry.getAttribute('aria-label')) } -// 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 () => { - let remaining: unknown[] = [] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered, failed: remaining } - : ((remaining = [failure('b')]), - { - resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), - continued: [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused', reason: 'agent_session_restart_work_superseded' } - ], - sessions: [], - failed: remaining - }) - ) + fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued')) await mount() await act(async () => button('Resume 2 chats').click()) // Resume hands off to the status bar; its failure entry reopens the list. @@ -701,17 +572,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(chatBoxes()).toHaveLength(1) 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', @@ -736,18 +609,7 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha it.each(['footer', 'row'] as const)( 'retries a failed chat by name from the %s when a retry can succeed', async (from) => { - let failed = [failure('b', 'agent_session_conflict')] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: [], failed } - : ((failed = []), - { - resumed: [{ sessionId: 'b', outcome: 'resumed' }], - continued: [{ sessionId: 'b', outcome: 'continued' }], - sessions: [], - failed - }) - ) + fakeHost({ sessions: [], failed: [failure('b', 'agent_session_conflict')] }) await mount() // Old failures never raise the launch dialog by themselves; the status entry does. expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -759,48 +621,40 @@ it.each(['footer', 'row'] as const)( expect(chatBox('b').getAttribute('data-state')).toBe('checked') await act(async () => button(from === 'footer' ? 'Resume 1 chat' : 'Retry').click()) - expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartContinue', - { sessionIds: ['b'] } - ]) + expect(calls()).toContainEqual(['agentSession.restartContinue', { sessionIds: ['b'] }]) expect(toasts()).toEqual([['Resumed 1 chat']]) - expect(document.querySelector('[role="dialog"]')).toBeNull() + if (from === 'footer') { + // Resume hands the run to the status bar. + expect(document.querySelector('[role="dialog"]')).toBeNull() + } 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 chat' + ) + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + } } ) // A row action acts on its row, as Dismiss does: a retry that clears the last failure must not close // the dialog over a chat still offered. it('keeps the dialog open over the chats still offered after a row Retry succeeds', async () => { - let failed = [failure('b', 'agent_session_conflict')] - rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: [offered[0]], failed } - : ((failed = []), - { - resumed: [{ sessionId: 'b', outcome: 'resumed' }], - continued: [{ sessionId: 'b', outcome: 'continued' }], - sessions: [offered[0]], - failed - }) - ) + fakeHost({ sessions: [offered[0]!], failed: [failure('b', 'agent_session_conflict')] }) await mount() await act(async () => button('Retry').click()) const dialog = document.querySelector('[role="dialog"]') expect(dialog?.textContent).toContain('Prompt a') - expect(dialog?.textContent).not.toContain('Prompt b') + expect(runStatus('Prompt b')).toBe('Prompt b: Resumed') + expect(document.querySelector('[role="checkbox"][aria-label*="Prompt a"]')).not.toBeNull() }) // A dialog closed and reopened mid-retry is the user's again: the retry settling must not close it. it('keeps a dialog reopened mid-retry open when the retry settles', async () => { - const continued = Promise.withResolvers() - rpc.mockImplementation((_target, method) => - method === 'agentSession.restartResumable' - ? Promise.resolve({ - sessions: [offered[0]], - failed: [failure('b', 'agent_session_conflict')] - }) - : continued.promise - ) + const host = fakeHost({ + sessions: [offered[0]!], + failed: [failure('b', 'agent_session_conflict')] + }) + host.hold('b') await mount( <> @@ -812,20 +666,13 @@ it('keeps a dialog reopened mid-retry open when the retry settles', async () => expect(document.querySelector('[role="dialog"]')).toBeNull() await act(async () => button('1 chat to resume').click()) expect(document.querySelector('[role="dialog"]')).not.toBeNull() - // Only the retried row is running, so only it reads as ticked until the retry settles. + // Only the retried row is running, so only it shows a spinner until the retry settles. const tick = (prompt: string) => document.querySelector(`[role="checkbox"][aria-label*="${prompt}"]`)?.getAttribute('data-state') expect(tick('Prompt a')).toBe('unchecked') - expect(tick('Prompt b')).toBe('checked') + expect(runStatus('Prompt b')).toMatch(/Waiting to start/) - await act(async () => - continued.resolve({ - resumed: [{ sessionId: 'b', outcome: 'resumed' }], - continued: [{ sessionId: 'b', outcome: 'continued' }], - sessions: [offered[0]], - failed: [] - }) - ) + await host.release('b') expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Prompt a') expect(tick('Prompt a')).toBe('checked') }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 640c62c9f3e..0394caa14d7 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' @@ -35,9 +35,13 @@ import { import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, + markFinishedNativeChatRestartRunShown, + releaseFinishedNativeChatRestartRun, useNativeChatRestartOffer, - useNativeChatRestartResuming + useNativeChatRestartResuming, + useNativeChatRestartRun } from './native-chat-resume-on-restart-store' +import { useResumeRunPanel } from './NativeChatResumeRunPanel' /** * What would be resumed, shown before anything runs. @@ -50,7 +54,9 @@ import { * the same RPC, which re-derives the same predicate and staggers the same way. * * Resume closes the dialog at once and the status-bar entry carries the run, then any chat it could - * not carry on. The run lives in the store, as a skill update's does, so the dialog is one view of it. + * not carry on. The run lives in the store, as a skill update's does, so the dialog is one view of it: + * reopened mid-run, each chat in it shows where it stands in its checkbox's place, and the dialog + * stays on that run until it is closed after the run has finished. * * A chat an earlier resume could not carry on is listed too, as the same row plus what went wrong * and what to do; selecting it and resuming is a retry, unless the host says a retry cannot run. @@ -175,9 +181,26 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { ), [selectable, overrides, failureBySession] ) + const selectableIds = useMemo(() => new Set(selectable), [selectable]) const selected = useMemo(() => new Set(chosen), [chosen]) // Mid-run the ticks show what is running; this opening's own ticks may name chats left out of it. const ticked = useMemo(() => (busy ? new Set(resuming) : selected), [busy, resuming, selected]) + const failureFor = useCallback( + (sessionId: string) => failureBySession.get(sessionId), + [failureBySession] + ) + const run = useNativeChatRestartRun() + const runPanel = useResumeRunPanel({ + run, + rows, + failureFor, + open + }) + useEffect(() => { + if (offerEnabled && open && run) { + markFinishedNativeChatRestartRunShown(run) + } + }, [offerEnabled, open, run]) const allSelection = resumeSelectionState(selectable, ticked) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { @@ -203,6 +226,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { /** Closing is a snooze: the host keeps the offer and the status bar keeps the way back to it. */ const snooze = useCallback((): void => { consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() void persistPreference() }, [persistPreference]) @@ -211,6 +235,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { // Bookkeeping never gates the user's own action: the dialog closes here whatever the host // answers, rather than being trapped open behind a rejected promise. consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() await dismissNativeChatRestartOffer() }, [persistPreference]) @@ -230,12 +255,13 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { // Opening is read-only and keeps the record: the user's own send in that chat settles it. The // dialog gets out of the way of the chat it just opened. consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRun() await activateAiVaultStructuredSession({ structuredSession: { workspaceId: failure.workspaceId, sessionId } }) } - if (!offerEnabled || !open || rows.length === 0) { + if (!offerEnabled || !open || (rows.length === 0 && !runPanel)) { return null } @@ -273,25 +299,29 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { {/* Plain wrapper owns the icon spacing; DialogTitle owns its own. */} - - - {translate( - 'auto.components.NativeChatResumeOnRestartModal.title', - 'Resume interrupted chats?' - )} - + {runPanel?.title ?? ( + + + {translate( + 'auto.components.NativeChatResumeOnRestartModal.title', + 'Resume interrupted chats?' + )} + + )} - {interruptedByUpdate - ? translate( - 'auto.components.NativeChatResumeOnRestartModal.updateBody', - 'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' - ) - : translate( - 'auto.components.NativeChatResumeOnRestartModal.body', - 'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' - )} + {runPanel?.description ?? + (interruptedByUpdate + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.updateBody', + 'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.body', + 'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.' + ))} + {runPanel?.summary}
failureBySession.get(sessionId)} + failureFor={failureFor} onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} + renderStatus={runPanel?.renderStatus} + selectableIds={selectableIds} /> @@ -370,34 +402,48 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { - {/* Quiet, explicit cleanup of the durable records. */} - - + {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 00000000000..83746c6b055 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -0,0 +1,116 @@ +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' + +/** + * 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)) { + 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', + { 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 = (sessionId: string) => { + const status = view?.statusBySession.get(sessionId) + return status?.kind === 'in-flight' ? status.phase : null + } + 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 00000000000..9a9170fd34e --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunStatusIcon.tsx @@ -0,0 +1,98 @@ +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' + +/** Tooltips keep per-chat progress from adding another label to each row. */ + +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 00000000000..f7ed32622ac --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeRunSummary.tsx @@ -0,0 +1,123 @@ +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' + +/** Counts and filters use the same categories so narrowing the list preserves its meaning. */ + +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/NativeChatResumeTreeRow.tsx b/src/renderer/src/components/NativeChatResumeTreeRow.tsx index fe5df6e9d9c..56f637e805c 100644 --- a/src/renderer/src/components/NativeChatResumeTreeRow.tsx +++ b/src/renderer/src/components/NativeChatResumeTreeRow.tsx @@ -27,6 +27,7 @@ export function ResumeTreeRow({ disabled, onCheckedChange, checkboxLabel, + checkboxSlot, compact = false, trailing, below, @@ -42,6 +43,8 @@ export function ResumeTreeRow({ disabled: boolean onCheckedChange: (checked: boolean) => void checkboxLabel: string + /** A run status replaces this leaf's checkbox in the same column. */ + checkboxSlot?: React.ReactNode /** A chat row sits a little tighter than a group row. */ compact?: boolean /** Controls beside the row that must not toggle its checkbox. */ @@ -51,26 +54,30 @@ export function ResumeTreeRow({ children: React.ReactNode }): React.JSX.Element { const indent = depth * RESUME_TREE_INDENT_PX + const Content = checkboxSlot ? 'div' : 'label' return (
- + {expanded !== undefined && ( // Laid over its slot rather than inside the label, so pressing it never toggles the // checkbox. Off the tab order: Left/Right on the row's checkbox do the same. 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 a9b4ccb562d..b6d0b305fee 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 f2d41b29abd..5b6c3356124 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 }) } @@ -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,20 +158,16 @@ 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 + const continuedCount = new Set(requested).size - notContinued.length announceResume( - new Set(requested).size - notContinued.length + seenCarryingOn.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-bulk-progress.test.tsx b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx new file mode 100644 index 00000000000..86943047651 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx @@ -0,0 +1,341 @@ +// @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 { chatBox } from './native-chat-resume-on-restart-modal.test-support' +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, + 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, offerIds, toasts, fakeHost, runStatus } = createResumeModalFixture( + rpc, + 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]!, + 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 () => chatBox('b').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() + 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: [] })) + expect(offerIds()).toEqual([]) + expect(button('Done').disabled).toBe(false) + 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')] }, + () => '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 4e0697f228b..81e90f10c91 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 action reply carries the remaining list, so no extra read is needed. + failed = [] acting.resolve({ sessions: [], failed: [], continued: [{ sessionId: 'a', outcome: 'continued' }] }) await retry + expect(offerReads()).toBe(1) expect(getNativeChatRestartOffer().failed).toEqual([]) }) 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 00000000000..9f1b4c126ae --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-finished-run.test.tsx @@ -0,0 +1,195 @@ +// @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' +import { chatBox, namedBox } from './native-chat-resume-on-restart-modal.test-support' + +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('selects only current offers from a workspace that also contains finished run history', async () => { + const host = fakeHost() + await mountSurfaces() + await act(async () => chatBox('b').click()) + await act(async () => button('Resume 1 chat').click()) + await act(async () => lastToastShow()?.()) + const workspace = namedBox('Select all chats in workspace') + expect(workspace.closest('label')?.textContent).toContain('1 of 1') + expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') + await act(async () => workspace.click()) + expect(chatBox('b').getAttribute('aria-checked')).toBe('false') + await act(async () => workspace.click()) + expect(button('Resume 1 chat').disabled).toBe(false) + host.hold('b') + await act(async () => button('Resume 1 chat').click()) + expect(rpc.mock.calls.at(-1)?.[2]).toEqual({ sessionIds: ['b'] }) + await host.release('b') +}) + +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-modal.test-support.tsx b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx new file mode 100644 index 00000000000..0bbc9dcdf31 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx @@ -0,0 +1,234 @@ +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') { + 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) + 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 }] + }) + ) + 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 + } + } + ) + 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-offered-chat-watch.ts b/src/renderer/src/components/native-chat-resume-offered-chat-watch.ts new file mode 100644 index 00000000000..679fe312bcb --- /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 9b8c90eb433..068e9ea95a7 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,10 @@ 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. */ +/** Clicks and opted-in launches use the same bulk action over their selection. */ 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 +92,23 @@ 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 + return { + continued: (params?.sessionIds ?? []).map((sessionId) => ({ + sessionId, + outcome: sessionId === 'b' ? 'refused' : 'continued' + })), + sessions: [], + failed: [failure] + } + }) await refreshNativeChatRestartOffer() await resumes.launch() expect(vi.mocked(toast).mock.calls).toEqual([ @@ -126,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' @@ -145,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 9111eae70db..33e5faa5879 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,14 +1,11 @@ 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 { announceRestartResults, + restartContinuationHistory, type RestartContinuationOutcome } from './native-chat-restart-action-notifications' import { @@ -16,6 +13,12 @@ import { type ResumeCandidate, type ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + beginResumeRun, + resumeRunInFlight, + resumeRunPendingIds, + type ResumeRun +} from './native-chat-resume-run' import { forgetUnsentResumes, markUnsentResumes, @@ -25,6 +28,8 @@ import { consumeNativeChatResumeOnRestartDialogRequest, 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. @@ -57,11 +62,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 +/** 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) const listeners = new Set<() => void>() const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const @@ -76,130 +80,65 @@ 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 +// 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) + } + } +}) -function offeredChatIds(): Set { - return new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)) +export function getNativeChatRestartOffer(): NativeChatRestartOffer { + return offer } -function offeredChatActivityKey(summary: AgentSessionStatusSummary): string { - return `${summary.status ?? ''}\u0000${summary.latestPrompt}` +export function getNativeChatRestartResuming(): readonly string[] { + return resuming } -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() - } - } - } - 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)) - } - } +export function getNativeChatRestartRun(): ResumeRun | null { + return run } -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) +/** 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 }) } } -function releaseOfferedChatWatch(): void { - if (offeredChatRefresh !== null) { - clearTimeout(offeredChatRefresh) - offeredChatRefresh = null +export function releaseFinishedNativeChatRestartRun(): void { + if (run?.finishedViewShown && !resumeRunInFlight(run)) { + setRun(null) } - offeredChatWatch?.release() - offeredChatWatch = null -} - -export function getNativeChatRestartOffer(): NativeChatRestartOffer { - return offer -} - -export function getNativeChatRestartResuming(): readonly string[] { - return resuming } function subscribe(listener: () => void): () => void { @@ -266,60 +205,77 @@ 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() } } -/** - * Reattach the offered 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. - * - * 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. - */ -export async function continueNativeChatRestartOffer( - sessionIds: readonly string[] | undefined, - reported: readonly string[] = sessionIds ?? [] -): Promise { +/** 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) { + return + } actionsBegun += 1 - forgetUnsentResumes(sessionIds) - const batch = [...reported] - let outcome: Parameters - resumeBatches.add(batch) - syncResuming() - // `resuming` now names the chats, so the launch's one resume decision is made. + forgetUnsentResumes(requested) + const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row])) + 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[] } - >(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {}) + HostOfferPayload & { continued?: RestartContinuationOutcome[]; skipped?: string[] } + >(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() } - outcome = [batch, 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) { - // 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()) + markUnsentResumes(requested, 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] + outcome = [ + requested, + [], + read.available ? offer.failed : undefined, + reopenNativeChatRestartOffer + ] } finally { + release() + if (releaseRunStatus === release) { + releaseRunStatus = undefined + } actionsSettled += 1 - resumeBatches.delete(batch) - syncResuming() + // Publish the offer first: reopening must never expose the old selection as actionable. + if (run === actionRun) { + setRun({ + ...actionRun, + entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), + inFlight: false, + continued + }) + } } announceRestartResults(...outcome) } @@ -391,7 +347,7 @@ async function loadLaunchOffer(): Promise { requestNativeChatResumeOnRestartDialog() return } - await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) + await continueNativeChatRestartOffer(allResumeSessionIds(offered)) } /** @@ -415,13 +371,20 @@ export function useNativeChatRestartResuming(): readonly string[] { return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming) } +/** The selection and reply history a reopened progress dialog follows. */ +export function useNativeChatRestartRun(): ResumeRun | null { + return useSyncExternalStore(subscribe, getNativeChatRestartRun, getNativeChatRestartRun) +} + /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { - releaseOfferedChatWatch() + releaseRunStatus?.() + releaseRunStatus = undefined + 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-status-watch.ts b/src/renderer/src/components/native-chat-resume-run-status-watch.ts new file mode 100644 index 00000000000..08e2d28763f --- /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 new file mode 100644 index 00000000000..07223a63a86 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run-view.ts @@ -0,0 +1,108 @@ +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { ResumeRun, ResumeRunHostStatus } from './native-chat-resume-run' + +/** 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 = + | { kind: 'in-flight'; startedAt: number; phase: 'starting' | 'ready' | null } + | { 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, + 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() + 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 ? (entry.observedStatus ?? hostStatusFor(sessionId)) : undefined + const phase = hostStatus?.restartResume?.phase + if (run.inFlight && phase === 'skipped') { + continue + } + inRunCount += 1 + 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 ( + phase === 'continued' || + (!run.inFlight && !failure && continuedBySession.get(sessionId) === 'continued') + ) { + statusBySession.set(sessionId, { kind: 'resumed' }) + placed.push({ row, category: 'resumed', index: placed.length }) + } 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) { + 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 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 00000000000..21badcab4d7 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + beginResumeRun, + observeResumeRun, + resumeRunInFlight, + resumeRunPendingIds, + type ResumeRunHostStatus +} from './native-chat-resume-run' +import { resumeRunView } from './native-chat-resume-run-view' + +const row = (sessionId: string): ResumeCandidate => ({ + sessionId, + 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) + +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 + }) + 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 => ({ + 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) + expect(resumeRunPendingIds(run)).toEqual(['a', 'b']) + }) + + 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' }) + ) + expect(view.counts.inProgress).toBe(2) + expect(view.statusBySession.get('a')).toEqual({ kind: 'in-flight', phase: null, startedAt: 10 }) + }) + + 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 }) + ) + expect(view.statusBySession.get('a')).toEqual({ + kind: 'in-flight', + phase: hostExecutionPhase, + startedAt: 10 + }) + } + }) + + 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') + }) + + 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) + }) + + 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(failed.rows).toEqual([failure]) + expect(failed.statusBySession.has('a')).toBe(false) + expect(resumeRunPendingIds(finished)).toBe(resumeRunPendingIds(null)) + }) + + 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 new file mode 100644 index 00000000000..5f7cc7669dc --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -0,0 +1,71 @@ +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; observedStatus?: ResumeRunHostStatus }>[] + inFlight: boolean + continued?: readonly RestartContinuationOutcome[] + finishedViewShown?: boolean +}> + +function progressRank(status: ResumeRunHostStatus | undefined): number { + const phase = status?.restartResume?.phase + if (phase === 'starting') { + return status?.hostExecutionPhase === 'ready' ? 4 : 3 + } + if (phase === 'queued') { + return 2 + } + if (phase === 'skipped') { + return 1 + } + return phase === 'continued' || phase === 'refused' || phase === 'unconfirmed' ? 5 : 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 +} + +export function beginResumeRun(candidates: readonly ResumeCandidate[], now: number): ResumeRun { + return { startedAt: now, entries: candidates.map((candidate) => ({ candidate })), inFlight: true } +} + +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[] { + return run?.inFlight ? run.entries.map((entry) => entry.candidate.sessionId) : NONE +} 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 00000000000..acdc13bda73 --- /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() + } + } +) diff --git a/src/renderer/src/components/native-chat-resume-tree-state.ts b/src/renderer/src/components/native-chat-resume-tree-state.ts index cd99733cad2..74b809eb8d7 100644 --- a/src/renderer/src/components/native-chat-resume-tree-state.ts +++ b/src/renderer/src/components/native-chat-resume-tree-state.ts @@ -43,6 +43,9 @@ export const ResumeTreeDepthContext = createContext(0) export type FailureProps = { failureFor?: (sessionId: string) => ResumeFailure | undefined onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void + renderStatus?: (sessionId: string, title: string) => React.ReactNode + /** Current offer membership excludes completed run history from group selection. */ + selectableIds?: ReadonlySet } /** What every node needs from the tree as a whole. */ @@ -69,8 +72,12 @@ export function chatState(candidate: ResumeCandidate, tree: TreeProps) { checked: tree.selected.has(key), onCheckedChange: (checked: boolean) => tree.onToggle(key, checked), failure, + renderStatus: tree.renderStatus + ? (_sessionId: string, title: string) => tree.renderStatus?.(key, title) + : undefined, // A group checkbox never ticks a failure a retry cannot fix. - selectable: !failure || resumeFailureSelectable(failure) + selectable: + (tree.selectableIds?.has(key) ?? true) && (!failure || resumeFailureSelectable(failure)) } } diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index be3b743b424..f51ab5fe207 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -5,8 +5,10 @@ import { translate } from '@/i18n/i18n' import { reopenNativeChatRestartOffer, useNativeChatRestartOffer, - useNativeChatRestartResuming + useNativeChatRestartRun } from '../native-chat-resume-on-restart-store' +import { resumeRunInFlight } from '../native-chat-resume-run' +import { resumeRunView } from '../native-chat-resume-run-view' // 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 +53,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 +143,29 @@ export function NativeChatResumeStatusSegment({ }): React.JSX.Element | null { const offerEnabled = useNativeChatRestartOfferEnabled() const { candidates, failed } = useNativeChatRestartOffer(offerEnabled) - const resumingIds = useNativeChatRestartResuming() + const run = useNativeChatRestartRun() + const failureBySession = new Map(failed.map((entry) => [entry.sessionId, entry])) + const view = run ? resumeRunView(run, candidates, (id) => failureBySession.get(id), 'all') : null 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 + // 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)) + 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(view?.counts.done ?? 0, view?.counts.total ?? 0)} /> )} {pending > 0 && ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3c633c93410..308ffc157ec 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2140,7 +2140,8 @@ "collapseNode": "Collapse {{value0}}", "expandNode": "Expand {{value0}}", "selectMachine": "Select all chats on {{value0}}", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "Done" }, "StarNagCard": { "92b0f9d921": "is authenticated and try again.", @@ -4315,11 +4316,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." } } }, @@ -17834,6 +17833,32 @@ "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.", + "resumedTitleOne": "Resumed {{value0}} of 1 chat" + }, + "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 ca78deda08b..3b0c30b0da9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4055,16 +4055,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.", @@ -17544,7 +17542,8 @@ "collapseNode": "Contraer {{value0}}", "expandNode": "Expandir {{value0}}", "selectMachine": "Seleccionar todos los chats en {{value0}}", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "Listo" }, "NativeChatResumeOutcomeRow": { "openChat": "Abrir chat", @@ -17644,6 +17643,32 @@ "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", + "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." + }, + "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 a18e5d599a4..94fd09e50e1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -4177,11 +4177,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": { @@ -17664,7 +17662,8 @@ "collapseNode": "Réduire {{value0}}", "expandNode": "Développer {{value0}}", "selectMachine": "Sélectionner toutes les discussions sur {{value0}}", - "sshHost": "SSH" + "sshHost": "SSH", + "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.", @@ -17745,6 +17744,32 @@ "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", + "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." + }, + "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 72efe22af4f..476ef2b36f9 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4072,11 +4072,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": { @@ -17548,7 +17546,8 @@ "collapseNode": "{{value0}} を折りたたむ", "expandNode": "{{value0}} を展開します", "selectMachine": "{{value0}} のチャットをすべて選択", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "完了" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "{{value0}}/{{value1}} チームステータスをフィルタリングしています。残りのチームからの問題は含まれません。", @@ -17644,6 +17643,32 @@ "hostOnly": "Cursor のサインイン詳細は、Orca を実行しているコンピュータでのみ読み取れます。" } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "{{value1}} 件中 {{value0}} 件のチャットを再開しました", + "resumedTitleOne": "1 件中 {{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 1a66b828be6..df627ff910c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4072,11 +4072,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": { @@ -17548,7 +17546,8 @@ "collapseNode": "{{value0}} 접기", "expandNode": "{{value0}} 확장", "selectMachine": "{{value0}}의 채팅 모두 선택", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "완료" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "{{value1}} 팀 상태 중 {{value0}} 필터링 중 - 나머지 팀의 문제는 포함되지 않습니다.", @@ -17644,6 +17643,32 @@ "hostOnly": "Cursor 로그인 정보는 Orca가 실행 중인 컴퓨터에서만 읽을 수 있습니다." } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "채팅 {{value1}}개 중 {{value0}}개를 재개했습니다", + "resumedTitleOne": "채팅 1개 중 {{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 cccf45c2ed3..46adc7e7c0c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4072,11 +4072,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": { @@ -17548,7 +17546,8 @@ "collapseNode": "折叠 {{value0}}", "expandNode": "展开 {{value0}}", "selectMachine": "选择 {{value0}} 上的全部聊天", - "sshHost": "SSH" + "sshHost": "SSH", + "done": "完成" }, "linear-issue-attribute-filter-coverage-notice": { "statusPartialTeamCoverage": "过滤 {{value1}} 团队状态中的 {{value0}} — 不包括其余团队的问题。", @@ -17644,6 +17643,32 @@ "hostOnly": "Cursor 登录详情仅可在运行 Orca 的电脑上读取。" } } + }, + "NativeChatResumeRunPanel": { + "resumedTitle": "已恢复 {{value0}}/{{value1}} 个聊天", + "resumedTitleOne": "已恢复 {{value0}}/1 个聊天", + "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": { 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 00000000000..916728490f8 --- /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', 'skipped'] 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 ce88e1d2d82..2c1486b087b 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 a3235cd1292..0bd672fdf7a 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -239,6 +239,11 @@ 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' | 'skipped' + } latestPrompt: string /** Provider model in force for the next turn; absent until the host has read the options. */ model?: string