feat(native-chat): show each chat's resume as it happens, and start more at once (#26361)

* feat(native-chat): show each chat's resume as it happens, and start more at once

Resuming interrupted chats sent one request for the whole batch, and the
host answered only after every agent had replied, so "Resuming 21 chats"
stayed up until the slowest chat answered and the dialog could only say
"Resuming…" for every row. The host also started 3 agents at a time.

The desktop app now sends one request per chat, so each chat's result
lands as soon as it has one. The host keeps one start limit across all
of those requests, sized to the machine's cores instead of a fixed 3.

The status bar reads "Resuming chats 9/21". Reopening the dialog mid-run
shows a gray spinner where each resuming chat's checkbox was, with its
step (waiting to start, starting, waiting for a reply) and time in the
tooltip, then a check or the failure row when it settles. A line above
the list counts where the chats stand, with filters for In progress,
Resumed and Need you.

* fix(native-chat): publish restart progress from one host action

* Keep chat resume progress stable through reply handoff

* Fix restart progress ownership after skipped requests

* Keep completed chat resume summaries until viewed and closed

* chore(i18n): drop status-bar keys the merge restored
This commit is contained in:
Brennan Benson
2026-10-08 10:13:54 -07:00
committed by GitHub
parent 7bdb0b67e6
commit 884be9ad39
50 changed files with 3008 additions and 705 deletions
@@ -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)
})
@@ -35,7 +35,7 @@ export function revokeAgentSessionStatusLive(
): AgentSessionStatusSnapshot {
let next: Map<string, AgentSessionStatusSummary> | 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)
+5
View File
@@ -240,6 +240,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