diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 759075639593..cd429e91fcbb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -9,7 +9,7 @@ import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-re import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' -import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' +import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-contract' import { evictOwnedStructuredAgentSessions, type StructuredAgentSessionLifetimeContext diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts index 0319346a9ab1..a79b27f650d8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts @@ -45,6 +45,10 @@ export class TrackedTestRecoveryCapsule extends AgentSessionRecoveryCapsule { return this.track(super.dismiss(...args)) } + override dismissListed(...args: Parameters) { + return this.track(super.dismissListed(...args)) + } + override forgetSuperseded(...args: Parameters) { return this.track(super.forgetSuperseded(...args)) } 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 681dd3088a82..211fa405a296 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 @@ -41,10 +41,8 @@ import { StructuredAgentSessionEventRecovery } from './structured-agent-session- import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel' import { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' -import { - createStructuredAgentSessionRestartResume, - type StructuredAgentSessionRestartResume -} from './structured-agent-session-restart-resume-host' +import { createStructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' +import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-contract' import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import * as conversation from './structured-agent-session-host-delivery' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' 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 c53f9b659517..f5ecc59ba42e 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 @@ -34,6 +34,17 @@ it('neither lists nor dismisses an offer the caller cannot show', async () => { expect(await offersIn(root)).toEqual([]) }) +it('does not dismiss an offer the caller cannot show, even listed with its interruption', async () => { + const { host, root } = await interruptedRestart() + const [offer] = await host.restartResume.list() + const listed = [{ sessionId: SESSION, recordedAt: offer!.recordedAt }] + + expect(await host.restartResume.dismissListed(listed, cannotShowCodex)).toBe(0) + expect(await offersIn(root)).toHaveLength(1) + expect(await host.restartResume.dismissListed(listed, showsCodex)).toBe(1) + expect(await offersIn(root)).toEqual([]) +}) + it('neither continues nor reserves an offer the caller cannot show, named or not', async () => { const { host, root, acquire, dispatch } = await interruptedRestart() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 1b3269b7add8..584776d29721 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -10,7 +10,8 @@ import type { AgentSessionRecoveryCapsule, AgentSessionResumeFailureInput, - AgentSessionResumeFailureRecord + AgentSessionResumeFailureRecord, + ListedRestartOffer } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -34,7 +35,13 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo type FailureCapsule = Pick< AgentSessionRecoveryCapsule, - 'listFailed' | 'completeResume' | 'failResume' | 'rollbackResume' | 'dismiss' | 'clearAll' + | 'listFailed' + | 'completeResume' + | 'failResume' + | 'rollbackResume' + | 'dismiss' + | 'dismissListed' + | 'clearAll' > export type StructuredAgentSessionRestartFailureLedger = { @@ -65,6 +72,12 @@ export type StructuredAgentSessionRestartFailureLedger = { beforeClearAll: (audience?: StructuredAgentSessionRestartAudience) => void | Promise, audience?: StructuredAgentSessionRestartAudience ) => Promise + /** Forgets offers exactly as a client listed them; see the capsule's `dismissListed`. With an + * audience, only records of agents it sees go. */ + dismissListed: ( + listed: readonly ListedRestartOffer[], + audience?: StructuredAgentSessionRestartAudience + ) => Promise } /** Which continuation outcomes count as the agent not carrying on, and how each is filed. */ @@ -252,6 +265,17 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { // A newer Orca's offers and failures were never shown here, so "dismiss all" keeps them. const keep = (marker: AgentSessionResumeMarker) => deps.savedByNewerOrca(marker.sessionId) return (await deps.capsule?.clearAll(deps.now(), keep)) ?? 0 + }), + dismissListed: (listed, audience) => + deps.enqueue(async () => { + // Decided under the capsule lock, as `dismiss` does. + const hidden = audience + ? (marker: AgentSessionResumeMarker) => { + const record = deps.getRecord(marker.sessionId) + return record === null || !audience(record.provider) + } + : undefined + return (await deps.capsule?.dismissListed(listed, deps.now(), hidden)) ?? 0 }) } } 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 38391833ff25..f8a3ba00737f 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 @@ -518,3 +518,24 @@ it('files a restart refused by a conflicted claim under its code, with its detai ]) ) }) + +it('continues a chat once when two desktops both resume it, and the second hears nothing failed', async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + + const [first, second] = await Promise.all([ + host.restartResume.continueAfterRestart([SESSION], 'desktop-a'), + host.restartResume.continueAfterRestart([SESSION], 'desktop-b') + ]) + const later = await host.restartResume.continueAfterRestart([SESSION], 'desktop-b') + + expect([...first.continued, ...second.continued]).toMatchObject([{ outcome: 'continued' }]) + // The loser reserved nothing, so it sent nothing and has no failure to file or report. + expect(later.continued).toEqual([]) + expect([...first.failed!, ...second.failed!, ...later.failed!]).toEqual([]) + expect( + (await statusNotes(host)).filter( + (note) => note.text === AGENT_SESSION_RESTART_CONTINUATION_NOTE + ) + ).toHaveLength(1) +}) 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 d15ab6c874c6..935bb36c1f0b 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 @@ -1,6 +1,7 @@ // What a host's restart-resume surface offers: teardown's offer recording, the offer list and its // actions, and Continue on a cut reply. +import type { ListedRestartOffer } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import type { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation' import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' @@ -44,6 +45,12 @@ export type StructuredAgentSessionRestartResume = { sessionIds?: readonly string[], audience?: StructuredAgentSessionRestartAudience ) => Promise + /** Forgets offers exactly as a client listed them: a chat interrupted again since, or being + * resumed by another action right now, keeps its record. An audience limits it as `dismiss`. */ + dismissListed: ( + listed: readonly ListedRestartOffer[], + audience?: StructuredAgentSessionRestartAudience + ) => Promise /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ onAgentStarted: (sessionId: string) => void /** Continue on a reply an Orca stop cut off, offer or not; the send itself retires any offer. */ 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 e1a4e14cd0d9..b18a65ecb793 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 @@ -319,6 +319,7 @@ export function createStructuredAgentSessionRestartResume( }, audience ), + dismissListed: failures.dismissListed, continueAfterRestart, onAgentStarted: withdrawal.onAgentStarted, continueInterrupted: createInterruptedContinuation(continuationHost, readMarkers) diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index 9deb2e20fe67..09ef40bc5bfc 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -215,16 +215,90 @@ export function shouldReplaceMarker( /** Records "dismiss all" leaves as they were: this host does not list them (a newer Orca's chats). */ export type KeepRecord = (marker: AgentSessionResumeMarker) => boolean -/** What a "dismiss all" keeps, and how many pending offers it ends. */ +/** What a "dismiss all" keeps, and how many pending offers it ends. A chat another action is + * resuming right now (another device's, on a shared host) stays with its earlier failure for that + * action to settle; the action lease ends a reservation whose action died. */ export function splitDismissedAll( state: Pick, keep: KeepRecord ): { kept: Pick; dismissedPending: number } { - const entries = state.entries.filter((entry) => keep(entry.marker)) + const resuming = new Set( + state.entries + .filter((entry) => entry.state === 'in-progress') + .map((entry) => entry.marker.sessionId) + ) + const kept = (marker: AgentSessionResumeMarker): boolean => + resuming.has(marker.sessionId) || keep(marker) return { - kept: { entries, failed: state.failed.filter((failure) => keep(failure.marker)) }, + kept: { + entries: state.entries.filter((entry) => kept(entry.marker)), + failed: state.failed.filter((failure) => kept(failure.marker)) + }, dismissedPending: state.entries.filter( (entry) => entry.state === 'pending' && !keep(entry.marker) ).length } } + +/** One offer as a client listed it: the chat and the interruption it was shown. */ +export type ListedRestartOffer = { sessionId: string; recordedAt: number } + +/** What dismissing listed offers keeps: a record no longer the one listed (a newer interruption), + * and every record of a chat another action is resuming right now. */ +export function splitDismissedListed( + state: Pick, + listed: readonly ListedRestartOffer[], + keepRecord: KeepRecord = () => false +): { kept: Pick; dismissed: Set } { + const shown = new Map(listed.map((offer) => [offer.sessionId, offer.recordedAt])) + const resuming = new Set( + state.entries + .filter((entry) => entry.state === 'in-progress') + .map((entry) => entry.marker.sessionId) + ) + const dismissed = new Set() + const keep = (marker: AgentSessionResumeMarker): boolean => { + if ( + resuming.has(marker.sessionId) || + shown.get(marker.sessionId) !== marker.recordedAt || + keepRecord(marker) + ) { + return true + } + dismissed.add(marker.sessionId) + return false + } + return { + kept: { + entries: state.entries.filter((entry) => keep(entry.marker)), + failed: state.failed.filter((failure) => keep(failure.marker)) + }, + dismissed + } +} + +/** Records the chat itself has since superseded go; witness-keyed, so a newer record stays. */ +export function withoutSuperseded( + state: Pick, + superseded: readonly { sessionId: string; recordedAt: number; failedAt?: number }[] +): Pick { + return { + entries: state.entries.filter( + (entry) => + entry.state !== 'pending' || + !superseded.some( + (gone) => + gone.failedAt === undefined && + gone.sessionId === entry.marker.sessionId && + gone.recordedAt === entry.marker.recordedAt + ) + ), + failed: state.failed.filter( + (failure) => + !superseded.some( + (gone) => + gone.sessionId === failure.marker.sessionId && gone.failedAt === failure.failedAt + ) + ) + } +} diff --git a/src/main/runtime/agent-session-recovery-capsule-read.ts b/src/main/runtime/agent-session-recovery-capsule-read.ts new file mode 100644 index 000000000000..cb65a72cbcfa --- /dev/null +++ b/src/main/runtime/agent-session-recovery-capsule-read.ts @@ -0,0 +1,20 @@ +import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' +import { parseState, type RecoveryCapsuleState } from './agent-session-recovery-capsule-entries' + +export const MAX_RECOVERY_CAPSULE_BYTES = 4 * 1024 * 1024 + +/** The capsule as stored; a missing file is an empty one. Callers hold the file's lock. */ +export async function readRecoveryCapsuleState(filePath: string): Promise { + let raw: string + try { + raw = (await readNodeFileWithinLimit(filePath, MAX_RECOVERY_CAPSULE_BYTES)).buffer.toString( + 'utf8' + ) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return { entries: [], failed: [] } + } + throw error + } + return parseState(raw) +} diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index bb3c93a6a989..b4f2e17ca219 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -270,6 +270,33 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'third' }), marker()]) }) + it('forgets listed records only while they are still the interruption the client listed', async () => { + await capsule.record( + [marker(), marker({ sessionId: 'second' }), marker({ sessionId: 'third' })], + NOW + ) + // Interrupted again after the listing: the newer offer was never shown, so it stays. + await capsule.record([marker({ sessionId: 'second', recordedAt: NOW + 5 })], NOW + 5) + + const listed = [SESSION, 'second', 'third'].map((sessionId) => ({ sessionId, recordedAt: NOW })) + expect(await capsule.dismissListed(listed, NOW + 6)).toBe(2) + expect(await capsule.list(NOW + 6)).toEqual([ + marker({ sessionId: 'second', recordedAt: NOW + 5 }) + ]) + }) + + it('leaves a listed chat another action is resuming, and its earlier failure, to that action', async () => { + await capsule.record([marker()], NOW) + await fileFailure() + // A retry on another desktop reserves the failed chat; this desktop listed it before that. + await capsule.beginResume([SESSION], 'operation-b', NOW) + + expect(await capsule.dismissListed([{ sessionId: SESSION, recordedAt: NOW }], NOW)).toBe(0) + expect(await capsule.listFailed(NOW)).toHaveLength(1) + await capsule.failResume('operation-b', [failure({ reason: 'retry_failed' })], NOW) + expect(await capsule.listFailed(NOW)).toMatchObject([{ reason: 'retry_failed' }]) + }) + it('forgets every record of any state except the ones kept, without a fence', async () => { await capsule.record( [marker(), marker({ sessionId: 'second' }), marker({ sessionId: 'kept' })], @@ -487,16 +514,17 @@ describe('durable restart offers', () => { expect(await capsule.list(later)).toEqual([]) }) - it('dismisses pending and active recovery records', async () => { + // On a shared host the action may be another device's resume: dismissing what this listing showed + // must not take its reservation, or a refusal it meets would never be filed. + it('dismisses pending records and leaves a chat another action is resuming to that action', async () => { await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) await capsule.beginResume([SESSION], 'operation-a', NOW) expect(await capsule.clearAll(NOW)).toBe(1) expect(await capsule.list(NOW)).toEqual([]) - // A later failed action cannot resurrect a record the user explicitly dismissed. - await capsule.rollbackResume('operation-a', NOW) - expect(await capsule.list(NOW)).toEqual([]) + await capsule.failResume('operation-a', [failure()], NOW) + expect(await capsule.listFailed(NOW)).toMatchObject([{ marker: marker() }]) }) it('fences a late teardown write until a genuinely newer interruption', async () => { @@ -647,3 +675,29 @@ it('does not retain lock timers after reads, writes, or failed publications', as vi.useRealTimers() } }) + +describe('whether the capsule holds anything', () => { + it('is no for a missing file and for one every record left', async () => { + expect(await capsule.holdsAnyRecord()).toBe(false) + await capsule.record([marker()], NOW) + await capsule.dismiss([SESSION], NOW) + expect(await capsule.holdsAnyRecord()).toBe(false) + }) + + it('counts a pending offer, a reservation, and a filed failure alike', async () => { + await capsule.record([marker()], NOW) + expect(await capsule.holdsAnyRecord()).toBe(true) + await capsule.beginResume([SESSION], 'operation-a', NOW) + // Reserved, so the listing no longer shows it — the file still holds it. + expect(await capsule.list(NOW)).toEqual([]) + expect(await capsule.holdsAnyRecord()).toBe(true) + await capsule.failResume('operation-a', [failure()], NOW) + expect(await capsule.list(NOW)).toEqual([]) + expect(await capsule.holdsAnyRecord()).toBe(true) + }) + + it('counts unreadable bytes as holding something', async () => { + await writeFile(filePath, '{not json') + expect(await capsule.holdsAnyRecord()).toBe(true) + }) +}) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index ba34c82fc57c..923266549523 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -1,7 +1,6 @@ import { rm } from 'node:fs/promises' import { join } from 'node:path' import type { AgentSessionResumeMarker } from '../../shared/agent-session-resume-marker' -import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' import { durableWriteTempPath, @@ -13,23 +12,29 @@ import { withFileTransactionLock } from '../file-transaction-lock' import { MAX_FAILURE_FIELD_LENGTH, normalizeState, - parseState, shouldReplaceMarker, splitDismissedAll, + splitDismissedListed, + withoutSuperseded, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, type KeepRecord, + type ListedRestartOffer, type RecoveryCapsuleState, type RecoveryEntry } from './agent-session-recovery-capsule-entries' +import { + MAX_RECOVERY_CAPSULE_BYTES, + readRecoveryCapsuleState +} from './agent-session-recovery-capsule-read' export type { AgentSessionResumeFailureInput, - AgentSessionResumeFailureRecord + AgentSessionResumeFailureRecord, + ListedRestartOffer } from './agent-session-recovery-capsule-entries' export const AGENT_SESSION_RECOVERY_CAPSULE_FILE = 'agent-session-recovery.json' -const MAX_CAPSULE_BYTES = 4 * 1024 * 1024 /** Crash-leftover temp files only; offers themselves have no expiry. */ const STALE_WRITE_TEMP_FILE_AGE_MS = 24 * 60 * 60 * 1000 @@ -51,6 +56,19 @@ export class AgentSessionRecoveryCapsule { }) } + /** Whether the file holds anything at all — any entry in any state, any failure — read raw so a + * reservation or replacement counts too. Unreadable bytes count: only provable emptiness is no. */ + holdsAnyRecord(): Promise { + return withFileTransactionLock(this.filePath, async () => { + try { + const { entries, failed } = await this.readState() + return entries.length > 0 || failed.length > 0 + } catch { + return true + } + }) + } + /** Offers that were acted on and did not end with the agent carrying on. Read-only, like `list`. */ listFailed(now: number): Promise { return withFileTransactionLock( @@ -232,6 +250,20 @@ export class AgentSessionRecoveryCapsule { }) } + /** Forgets offers exactly as a client listed them. A chat interrupted again since, or being + * resumed by another action right now, keeps its record: the listing never named those. A + * record `keep` answers true for stays too, read against the stored marker under the lock. */ + dismissListed(listed: readonly ListedRestartOffer[], now: number, keep?: KeepRecord) { + return withFileTransactionLock(this.filePath, async () => { + const state = await this.readState() + const { kept, dismissed } = splitDismissedListed(normalizeState(state, now), listed, keep) + if (dismissed.size > 0) { + await this.publish(kept, now, state.dismissedAt) + } + return dismissed.size + }) + } + /** Drops records the chat itself has since superseded — the user's own newer message ends both a * pending offer and a recorded failure. Witness-keyed (`recordedAt`, and `failedAt` for a * failure) so a fresh record written after the caller read the stale one is kept. */ @@ -242,25 +274,9 @@ export class AgentSessionRecoveryCapsule { return withFileTransactionLock(this.filePath, async () => { const state = await this.readState() const { entries, failed } = normalizeState(state, now) - const keptEntries = entries.filter( - (entry) => - entry.state !== 'pending' || - !superseded.some( - (gone) => - gone.failedAt === undefined && - gone.sessionId === entry.marker.sessionId && - gone.recordedAt === entry.marker.recordedAt - ) - ) - const keptFailures = failed.filter( - (failure) => - !superseded.some( - (gone) => - gone.sessionId === failure.marker.sessionId && gone.failedAt === failure.failedAt - ) - ) - if (keptEntries.length !== entries.length || keptFailures.length !== failed.length) { - await this.publish({ entries: keptEntries, failed: keptFailures }, now, state.dismissedAt) + const kept = withoutSuperseded({ entries, failed }, superseded) + if (kept.entries.length !== entries.length || kept.failed.length !== failed.length) { + await this.publish(kept, now, state.dismissedAt) } }) } @@ -292,9 +308,9 @@ export class AgentSessionRecoveryCapsule { return 0 } const { kept, dismissedPending } = splitDismissedAll(state, keep) - // Dismiss is the explicit user request to forget every recovery record it was shown. An - // in-flight action may still finish, but its later complete/rollback becomes a no-op and - // cannot resurrect a row the user dismissed. + // Dismiss is the explicit user request to forget every recovery record it was shown. A chat + // in-flight on another action was never shown (listing returns pending only); that action + // settles it, and the fence still stops a stale teardown writer. await this.publish(kept, now, now) return dismissedPending }) @@ -304,19 +320,8 @@ export class AgentSessionRecoveryCapsule { return entry.state === 'in-progress' && entry.operationId === operationId } - private async readState(): Promise { - let raw: string - try { - raw = (await readNodeFileWithinLimit(this.filePath, MAX_CAPSULE_BYTES)).buffer.toString( - 'utf8' - ) - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return { entries: [], failed: [] } - } - throw error - } - return parseState(raw) + private readState(): Promise { + return readRecoveryCapsuleState(this.filePath) } private async publish( @@ -332,7 +337,7 @@ export class AgentSessionRecoveryCapsule { ...(dismissedAt === undefined ? {} : { dismissedAt }), ...(failed.length === 0 ? {} : { failed }) }, - MAX_CAPSULE_BYTES + MAX_RECOVERY_CAPSULE_BYTES ) await removeStaleDurableWriteTempFiles(this.filePath, { minimumAgeMs: STALE_WRITE_TEMP_FILE_AGE_MS diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c7e3c1d79a12..27b1ffc276f0 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -22,6 +22,8 @@ import { isFloatingWorkspaceSelector } from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { readRestartOfferWorkspaceProvenance } from './restart-offer-workspace-provenance' +import type { RestartOfferWorkspaceProvenance } from '../../shared/restart-offer-origin' import { isLegacyAgentSessionAccountHome, type AgentSessionAccountHome @@ -33,6 +35,11 @@ import { import { agentSessionWireProviderHandle } from '../../shared/agent-session-provider-handle-encoding' export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaRuntimeWithGetWorktreePs { + /** Who made the workspace an offered restart chat ran in; see `restartOfferOrigin`. */ + restartOfferWorkspaceProvenance(workspaceId: string): RestartOfferWorkspaceProvenance | undefined { + return readRestartOfferWorkspaceProvenance(this.store, workspaceId) + } + async getStructuredAgentSessionCreateSupport( worktreeSelector: string, agent: StructuredAgentId diff --git a/src/main/runtime/restart-offer-workspace-provenance.test.ts b/src/main/runtime/restart-offer-workspace-provenance.test.ts new file mode 100644 index 000000000000..b324806c5b0b --- /dev/null +++ b/src/main/runtime/restart-offer-workspace-provenance.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from 'vitest' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +type Records = Pick +import { readRestartOfferWorkspaceProvenance } from './restart-offer-workspace-provenance' + +const MINE = { kind: 'paired-device' as const, deviceId: 'device-mine' } + +function store( + meta: Record, + folders: Pick[] +) { + return { + getWorktreeMeta: (id: string): Records | undefined => meta[id], + getFolderWorkspaces: () => folders + } +} + +it('reads a git worktree’s records by its id, and a folder workspace’s by its key', () => { + const host = store({ 'repo::/work/a': { creatorProvenance: MINE } }, [ + { id: 'folder-1', creatorProvenance: { kind: 'host' } } + ]) + expect(readRestartOfferWorkspaceProvenance(host, 'repo::/work/a')).toEqual({ + creatorProvenance: MINE, + automationProvenance: undefined + }) + expect(readRestartOfferWorkspaceProvenance(host, 'folder:folder-1')).toEqual({ + creatorProvenance: { kind: 'host' } + }) +}) + +it('has no record for a workspace the host does not know', () => { + const host = store({}, []) + expect(readRestartOfferWorkspaceProvenance(host, 'repo::/main')).toBeUndefined() + expect(readRestartOfferWorkspaceProvenance(host, 'folder:gone')).toBeUndefined() + expect(readRestartOfferWorkspaceProvenance(null, 'repo::/main')).toBeUndefined() +}) diff --git a/src/main/runtime/restart-offer-workspace-provenance.ts b/src/main/runtime/restart-offer-workspace-provenance.ts new file mode 100644 index 000000000000..67554b2eff0c --- /dev/null +++ b/src/main/runtime/restart-offer-workspace-provenance.ts @@ -0,0 +1,34 @@ +import type { RestartOfferWorkspaceProvenance } from '../../shared/restart-offer-origin' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +/** The store reads this needs; the runtime's store provides them. */ +type ProvenanceStore = { + getWorktreeMeta: ( + worktreeId: string + ) => Pick | undefined + getFolderWorkspaces?: () => readonly Pick[] +} + +/** The creator and automation records of the workspace an offered chat ran in, as this host keeps + * them; undefined for one it has no record of (a repo's main checkout, a removed workspace). */ +export function readRestartOfferWorkspaceProvenance( + store: ProvenanceStore | null, + workspaceId: string +): RestartOfferWorkspaceProvenance | undefined { + if (!store) { + return undefined + } + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + const workspace = store + .getFolderWorkspaces?.() + .find((entry) => entry.id === scope.folderWorkspaceId) + return workspace ? { creatorProvenance: workspace.creatorProvenance } : undefined + } + const meta = store.getWorktreeMeta(scope?.type === 'worktree' ? scope.worktreeId : workspaceId) + return meta + ? { creatorProvenance: meta.creatorProvenance, automationProvenance: meta.automationProvenance } + : undefined +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-origin.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-origin.test.ts new file mode 100644 index 000000000000..c98257613c1b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-origin.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RestartOfferWorkspaceProvenance } from '../../../../shared/restart-offer-origin' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { RpcResponse } from '../core' +import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' +import { + call, + hostStub, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +const shortcut = vi.hoisted(() => ({ empty: vi.fn(async () => false) })) +vi.mock('./structured-agent-session-restart-offer-read', () => ({ + restartOffersProvablyEmpty: shortcut.empty +})) + +const WORKSPACES: Record = { + mine: { creatorProvenance: { kind: 'paired-device', deviceId: 'device-mine' } }, + theirs: { creatorProvenance: { kind: 'paired-device', deviceId: 'device-theirs' } }, + 'host-made': { creatorProvenance: { kind: 'host' } }, + automated: { + automationProvenance: { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Nightly', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Nightly', + createdAt: 1, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'project-1' + } + } +} +const row = (sessionId: string, workspaceId: string) => ({ sessionId, workspaceId, recordedAt: 1 }) +const restartResume = { + list: vi.fn(async () => [ + row('a', 'mine'), + row('b', 'theirs'), + row('c', 'host-made'), + row('d', 'main-checkout'), + row('e', 'automated') + ]), + listFailures: vi.fn(async () => [row('f', 'theirs')]), + dismiss: vi.fn(async () => 1), + dismissListed: vi.fn(async (_listed: unknown, _audience?: unknown) => 1), + continueAfterRestart: vi.fn(async () => ({ + resumed: [], + continued: [], + sessions: [row('a', 'mine'), row('b', 'theirs')], + failed: [row('f', 'host-made')] + })) +} +const RUNTIME = { + restartOfferWorkspaceProvenance: (workspaceId: string) => WORKSPACES[workspaceId] +} + +beforeEach(() => { + setStructuredAgentSessionHost( + Object.assign(hostStub(), { restartResume, knownAgentIds: () => ['claude', 'codex'] }) + ) +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) + vi.clearAllMocks() +}) + +function rowsOf(result: unknown, key: 'sessions' | 'failed'): unknown[] { + const value = typeof result === 'object' && result !== null && key in result ? result[key] : [] + return Array.isArray(value) ? value : [] +} + +/** Each listed row's origin by session, from a reply. */ +function origins(response: RpcResponse): Record { + const result = response.ok ? response.result : null + return Object.fromEntries( + [...rowsOf(result, 'sessions'), ...rowsOf(result, 'failed')].map((entry) => + typeof entry === 'object' && entry !== null && 'sessionId' in entry + ? [entry.sessionId, 'origin' in entry ? entry.origin : undefined] + : [] + ) + ) +} + +it('says whose each offer is for the paired desktop asking, by the sidebar rule', async () => { + const response = await call( + 'agentSession.restartResumable', + {}, + { ...STRUCTURED_CLIENT, pairedDeviceId: 'device-mine' }, + RUNTIME + ) + expect(response).toMatchObject({ ok: true }) + expect(origins(response)).toEqual({ + a: 'own', + b: 'other-device', + c: 'server-made', + // No creator record: the sidebar shows it as the asker's, so the offer is theirs too. + d: 'own', + e: 'automation', + f: 'other-device' + }) +}) + +it.each([ + ['an in-process caller', undefined], + [ + 'its own desktop window', + { ...STRUCTURED_CLIENT, clientId: 'desktop-renderer', caller: DESKTOP_RPC_CALLER } + ], + // The transport's caller identity decides, never the client id it declares. + ['its desktop window under any client id', { ...STRUCTURED_CLIENT, caller: DESKTOP_RPC_CALLER }] +])("counts the host's own user, as %s, as the owner of what the host made", async (_, client) => { + const response = await call('agentSession.restartResumable', {}, client, RUNTIME) + expect(origins(response)).toMatchObject({ + a: 'other-device', + c: 'own', + d: 'own', + e: 'automation' + }) +}) + +it.each([ + ['a remote caller with no device identity', 'token-1'], + ['a caller that only declares the desktop window’s client id', 'desktop-renderer'] +])('leaves the origin out for %s', async (_, clientId) => { + const response = await call( + 'agentSession.restartResumable', + {}, + { ...STRUCTURED_CLIENT, clientId }, + RUNTIME + ) + expect(origins(response)).toEqual({ + a: undefined, + b: undefined, + c: undefined, + d: undefined, + e: undefined, + f: undefined + }) +}) + +it('forgets listed offers by the interruption the client saw, with sessionIds riding along', async () => { + const offers = [{ sessionId: SESSION, recordedAt: 1 }] + const response = await call( + 'agentSession.restartResumableDismiss', + { sessionIds: [SESSION], offers }, + { ...STRUCTURED_CLIENT, pairedDeviceId: 'device-mine' }, + RUNTIME + ) + expect(response).toMatchObject({ ok: true, result: { dismissed: 1 } }) + expect(restartResume.dismissListed.mock.lastCall?.[0]).toEqual(offers) + expect(restartResume.dismiss).not.toHaveBeenCalled() + expect(origins(response)).toMatchObject({ a: 'own' }) +}) + +// The desktop publishes what a continue leaves; without an origin every remaining row would read as +// not the user's. +it('says whose each remaining offer is in a continue reply', async () => { + const response = await call( + 'agentSession.restartContinue', + { sessionIds: [SESSION] }, + { ...STRUCTURED_CLIENT, pairedDeviceId: 'device-mine' }, + RUNTIME + ) + expect(response).toMatchObject({ ok: true }) + expect(origins(response)).toEqual({ a: 'own', b: 'other-device', f: 'server-made' }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-origin.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-origin.ts new file mode 100644 index 000000000000..76dae1141455 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-origin.ts @@ -0,0 +1,52 @@ +// Each listed offer says whose it is, for the device asking. The host holds both facts — the +// workspace's creator record and the caller's paired device — so no client judges it from a +// workspace list it may not have loaded yet. + +import { restartOfferOrigin } from '../../../../shared/restart-offer-origin' +import type { RpcContext } from '../core' +import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' + +/** The asking device as workspace creator records name it: null is the host's own user (its desktop + * window, or an in-process caller), undefined an authenticated remote caller without a device + * identity (its rows go out without an origin). */ +function viewerDeviceId(ctx: RpcContext): string | null | undefined { + if (ctx.pairedDeviceId) { + return ctx.pairedDeviceId + } + if (ctx.caller?.kind === 'desktop') { + return null + } + try { + return resolveRpcWorkspaceCreatorProvenance(ctx).kind === 'host' ? null : undefined + } catch { + return undefined + } +} + +export function withRestartOfferOrigins( + ctx: RpcContext, + rows: readonly Row[] +): Row[] { + const viewer = viewerDeviceId(ctx) + if (viewer === undefined) { + return [...rows] + } + return rows.map((row) => ({ + ...row, + origin: restartOfferOrigin(ctx.runtime.restartOfferWorkspaceProvenance(row.workspaceId), viewer) + })) +} + +/** A listing payload with every row's origin added; fields it does not carry stay absent. */ +export function withRestartOfferPayloadOrigins< + Payload extends { + sessions?: readonly { workspaceId: string }[] + failed?: readonly { workspaceId: string }[] + } +>(ctx: RpcContext, payload: Payload): Payload { + return { + ...payload, + ...(payload.sessions ? { sessions: withRestartOfferOrigins(ctx, payload.sessions) } : {}), + ...(payload.failed ? { failed: withRestartOfferOrigins(ctx, payload.failed) } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-read.test.ts new file mode 100644 index 000000000000..daa62db0982c --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-read.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { restartOffersProvablyEmpty } from './structured-agent-session-restart-offer-read' + +describe('reading restart offers without building a host', () => { + it('answers empty only when no host exists and the capsule holds nothing', async () => { + const recordsHeld = vi.fn(async () => false) + expect(await restartOffersProvablyEmpty({ hostInstalled: () => false, recordsHeld })).toBe(true) + }) + + it('asks the installed host rather than the file', async () => { + const recordsHeld = vi.fn(async () => false) + expect(await restartOffersProvablyEmpty({ hostInstalled: () => true, recordsHeld })).toBe(false) + expect(recordsHeld).not.toHaveBeenCalled() + }) + + it('builds the host when the capsule holds anything or cannot be read', async () => { + expect( + await restartOffersProvablyEmpty({ + hostInstalled: () => false, + recordsHeld: async () => true + }) + ).toBe(false) + expect( + await restartOffersProvablyEmpty({ + hostInstalled: () => false, + recordsHeld: async () => { + throw new Error('locked') + } + }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-read.ts new file mode 100644 index 000000000000..b0c0e0fd5d2b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-offer-read.ts @@ -0,0 +1,35 @@ +// Listing restart offers on a process that never built its structured host. +// +// Building the host opens the record store and journal, so a paired desktop asking every server it +// connects to would open them on servers that never ran a chat. The offers live only in the +// recovery capsule, so a capsule holding nothing at all answers "nothing to resume" by itself. + +import { AgentSessionRecoveryCapsule } from '../../agent-session-recovery-capsule' +import { getProfileUserDataPath } from '../../../orca-profiles/profile-storage-paths' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' + +export type RestartOfferReadShortcutDeps = { + hostInstalled: () => boolean + recordsHeld: () => Promise +} + +const defaultDeps: RestartOfferReadShortcutDeps = { + hostInstalled: () => getStructuredAgentSessionHost() !== null, + // The host's own state directory (orca-runtime-get-worktree-ps.ts `stateDirectory`). + recordsHeld: () => new AgentSessionRecoveryCapsule(getProfileUserDataPath()).holdsAnyRecord() +} + +/** True only when no host exists and the capsule provably holds nothing; otherwise the caller + * builds the host and lists as it always has. */ +export async function restartOffersProvablyEmpty( + deps: RestartOfferReadShortcutDeps = defaultDeps +): Promise { + if (deps.hostInstalled()) { + return false + } + try { + return !(await deps.recordsHeld()) + } catch { + return false + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resumable-empty.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resumable-empty.test.ts new file mode 100644 index 000000000000..7536a8068e1b --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resumable-empty.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { call } from './structured-agent-session-rpc.test-fixture' + +const shortcut = vi.hoisted(() => ({ empty: vi.fn(async () => true) })) +vi.mock('./structured-agent-session-restart-offer-read', () => ({ + restartOffersProvablyEmpty: shortcut.empty +})) + +const ensureHost = vi.fn(async () => undefined) +const HOST_BUILDER = { ensureStructuredAgentSessionHost: ensureHost } + +// A paired desktop is a remote client; in-process callers carry no client kind at all. +function listRestartOffers(clientCapabilities?: string[]) { + return call( + 'agentSession.restartResumable', + {}, + clientCapabilities ? { clientKind: 'runtime', clientCapabilities } : undefined, + HOST_BUILDER + ) +} + +beforeEach(() => { + ensureHost.mockClear() + shortcut.empty.mockClear() + shortcut.empty.mockResolvedValue(true) +}) + +it('answers an empty capsule without building the chat host', async () => { + await expect(listRestartOffers()).resolves.toMatchObject({ + ok: true, + result: { sessions: [], failed: [] } + }) + expect(ensureHost).not.toHaveBeenCalled() +}) + +it('still refuses a client that cannot read structured sessions, before looking at the file', async () => { + await expect(listRestartOffers(['terminal.v1'])).resolves.toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(shortcut.empty).not.toHaveBeenCalled() + expect(ensureHost).not.toHaveBeenCalled() +}) + +it('builds the host when the capsule may hold an offer', async () => { + shortcut.empty.mockResolvedValue(false) + // No host comes up in this test, so the build is followed by the usual refusal. + await expect(listRestartOffers()).resolves.toMatchObject({ ok: false }) + expect(ensureHost).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts index 5662731c9a1d..fce99f48c435 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.test.ts @@ -58,6 +58,17 @@ function installRestartHost() { return gone.length } ), + dismissListed: vi.fn( + async ( + listed: readonly { sessionId: string }[], + audience?: StructuredAgentSessionRestartAudience + ) => { + const named = new Set(listed.map((offer) => offer.sessionId)) + const gone = restartRowsFor(offers, audience).filter((offer) => named.has(offer.sessionId)) + offers = offers.filter((offer) => !gone.includes(offer)) + return gone.length + } + ), continueAfterRestart: vi.fn( async ( _sessionIds: readonly string[] | undefined, @@ -110,6 +121,19 @@ describe("a paired client too old to show the host's other agents", () => { expect(host.offers()).toEqual([CLAUDE_OFFER, GROK_OFFER]) }) + it('cannot dismiss an offer it was not shown by listing it', async () => { + const host = installRestartHost() + const listed = [CLAUDE_OFFER, GROK_OFFER].map(({ sessionId }) => ({ sessionId, recordedAt: 1 })) + expect( + await call( + 'agentSession.restartResumableDismiss', + { sessionIds: listed.map((offer) => offer.sessionId), offers: listed }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true, result: { dismissed: 1, sessions: [], failed: [] } }) + expect(host.offers()).toEqual([GROK_OFFER]) + }) + it('hands the host its audience when it continues all, so hidden offers are not run', async () => { const host = installRestartHost() await call('agentSession.restartContinue', {}, OLD_CLIENT) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index 235a2784b882..3f35bb7ee036 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -8,11 +8,15 @@ import { defineMethod } from '../core' import { ensureStructuredHostInstalled, + requireStructuredCapability, requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +import { restartOffersProvablyEmpty } from './structured-agent-session-restart-offer-read' +import { withRestartOfferPayloadOrigins } from './structured-agent-session-restart-offer-origin' import { ContinueInterruptedParams, + RestartDismissParams, RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' @@ -24,14 +28,20 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ permission: 'workspace', params: RestartResumableParams, handler: async (_params, ctx) => { + requireStructuredCapability(ctx) + // A paired desktop asks on every connect; a server that never ran a chat must not build a + // host to say it has nothing. + if (await restartOffersProvablyEmpty()) { + return { sessions: [], failed: [] } + } await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) const audience = structuredAgentsReadBy(ctx, host.knownAgentIds()) - return { + return withRestartOfferPayloadOrigins(ctx, { sessions: await host.restartResume.list(audience), // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. failed: await host.restartResume.listFailures(audience) - } + }) } }), defineMethod({ @@ -39,23 +49,27 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // not call this method, so the status-bar entry can reopen the offer later. name: 'agentSession.restartResumableDismiss', permission: 'workspace', - params: RestartResumableParams, + params: RestartDismissParams, handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) // Offers this client was never shown stay for a client that can show them. const audience = structuredAgentsReadBy(ctx, host.knownAgentIds()) - const dismissed = await host.restartResume.dismiss(params.sessionIds, audience) - if (params.sessionIds === undefined) { + // `offers` names each chat with the interruption the client listed; read before `sessionIds`, + // which rides along for a host that predates it. + const dismissed = params.offers + ? await host.restartResume.dismissListed(params.offers, audience) + : await host.restartResume.dismiss(params.sessionIds, audience) + if (!params.offers && params.sessionIds === undefined) { // The dismissal removed every pending and in-flight record this client sees, so a second // read would only add a new failure point after the user's explicit dismissal. return { dismissed, sessions: [], failed: [] } } - return { + return withRestartOfferPayloadOrigins(ctx, { dismissed, sessions: await host.restartResume.list(audience), failed: await host.restartResume.listFailures(audience) - } + }) } }), defineMethod({ @@ -68,10 +82,13 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - return host.restartResume.continueAfterRestart( - params.sessionIds, - structuredCallerFor(ctx).callerKey, - structuredAgentsReadBy(ctx, host.knownAgentIds()) + return withRestartOfferPayloadOrigins( + ctx, + await host.restartResume.continueAfterRestart( + params.sessionIds, + structuredCallerFor(ctx).callerKey, + structuredAgentsReadBy(ctx, host.knownAgentIds()) + ) ) } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index dfda59b17b3f..db4766978fa6 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -17,6 +17,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RpcAnyMethodDeclaration, RpcRequest, RpcResponse } from '../core' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' @@ -253,6 +254,8 @@ export function dispatcher( registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn(), cleanupSubscriptionsByPrefix: vi.fn(), + // No creator record: every listed restart offer reads as the host user's own. + restartOfferWorkspaceProvenance: () => undefined, ...runtimeCalls, ...runtimeOverrides } @@ -267,12 +270,8 @@ export function dispatcher( export async function call( method: string, params: unknown, - client?: { - clientId?: string - clientKind?: 'mobile' | 'runtime' - clientCapabilities?: string[] - signal?: AbortSignal - }, + // What a transport tells the dispatcher about its caller. + client?: RpcDispatchStreamingOptions, runtimeOverrides: Record = {}, methods?: readonly RpcAnyMethodDeclaration[] ): Promise { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 76ce9c1b8249..9d1ad44eb099 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -22,8 +22,6 @@ export { QueuedMessagesResumeParams, RespondParams, RespondToQuestionParams, - RestartResumableParams, - RestartResumeParams, RewindParams, SendParams, SessionId, @@ -33,6 +31,11 @@ export { ThreadGoalParams, UnsubscribeParams } from '../../../../shared/rpc-contract/structured-agent-session-params' +export { + RestartDismissParams, + RestartResumableParams, + RestartResumeParams +} from '../../../../shared/rpc-contract/structured-agent-session-restart-params' export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params' export { QueuedMessageUpdateParams, diff --git a/src/renderer/src/components/NativeChatResumeFailureDetails.tsx b/src/renderer/src/components/NativeChatResumeFailureDetails.tsx index 968c0fc0769b..6769e1962bec 100644 --- a/src/renderer/src/components/NativeChatResumeFailureDetails.tsx +++ b/src/renderer/src/components/NativeChatResumeFailureDetails.tsx @@ -71,23 +71,45 @@ export function ResumeFailureStatus({ {failure.reason} - + onDismiss={() => onAction('dismiss')} + /> ) } +/** Forgets one listed chat's offer: a failed resume's, or one Dismiss all leaves for its owner. */ +export function ResumeRowDismiss({ + title, + workspaceName, + disabled, + onDismiss +}: { + title: string + workspaceName: string + disabled: boolean + onDismiss: () => void +}): React.JSX.Element { + return ( + + ) +} + export function ResumeFailureGuidanceLine({ failure, disabled, diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index 47c0c4466793..2e46a854e243 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -1,5 +1,6 @@ import { useContext } from 'react' import { AgentIcon } from '@/lib/agent-catalog' +import { Badge } from './ui/badge' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { translate } from '@/i18n/i18n' @@ -8,7 +9,11 @@ import { resumeFailureSelectable, type ResumeFailureAction } from './native-chat-resume-failure-guidance' -import { ResumeFailureGuidanceLine, ResumeFailureStatus } from './NativeChatResumeFailureDetails' +import { + ResumeFailureGuidanceLine, + ResumeFailureStatus, + ResumeRowDismiss +} from './NativeChatResumeFailureDetails' import { resumeActivityLabel } from './native-chat-resume-activity-label' import { ResumeTreeRow } from './NativeChatResumeTreeRow' import { ResumeTreeDepthContext } from './native-chat-resume-tree-state' @@ -46,6 +51,7 @@ export function ResumeCandidateRow({ onCheckedChange, failure, onFailureAction, + originLabel, renderStatus }: { candidate: ResumeCandidate @@ -58,6 +64,8 @@ export function ResumeCandidateRow({ /** Present when an earlier resume of this chat did not carry on. */ failure?: ResumeFailure onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void + /** Where the chat came from, for one that does not start ticked; absent for the user's own. */ + originLabel?: string renderStatus?: (sessionId: string, title: string) => React.ReactNode }): React.JSX.Element { const agentLabel = formatAgentTypeLabel(candidate.agent) @@ -84,10 +92,11 @@ export function ResumeCandidateRow({ )} compact checkboxSlot={status} - // Outside the label, so pressing them never toggles the checkbox. + // Outside the label, so pressing them never toggles the checkbox. A chat that is not the + // user's is never cleared by Dismiss; its own control ends it here. A run's status replaces + // both while it follows the chat. trailing={ - failure && - !status && ( + status ? null : failure ? ( + ) : ( + originLabel && + onFailureAction && ( + act('dismiss')} + /> + ) ) } below={ @@ -115,6 +134,8 @@ export function ResumeCandidateRow({ {title} {activity && - {activity.summary}} + {/* The same quiet context chip that names a workspace's machine. */} + {originLabel && {originLabel}} {model && ( tree.setExpanded(nodeKey, next)} + onExpandedChange={(next) => tree.setExpanded(nodeKey, next, hostId)} name={name} checked={selection.checked} - disabled={tree.busy || selection.total === 0} + disabled={treeBusy(tree, hostId) || selection.total === 0} onCheckedChange={() => toggleResumeSelection(covered, selection, tree.onToggle)} checkboxLabel={checkboxLabel} > @@ -125,6 +129,7 @@ function WorkspaceNode({ return ( ) @@ -205,6 +211,7 @@ function ProjectNode({ return ( + subtitle: string | undefined tree: TreeProps }): React.JSX.Element { const name = getHostContextLabel(hostId, { hostLabelById }) return ( )} + {subtitle && ( + {subtitle} + )} } > @@ -345,15 +358,15 @@ export function ResumeOnRestartGroups({ onToggle, failureFor, onFailureAction, - renderStatus, - selectableIds + ...machineProps }: { candidates: readonly ResumeCandidate[] listedAt: number busy: boolean selected: ReadonlySet - onToggle: (sessionId: string, checked: boolean) => void -} & FailureProps): React.JSX.Element { + onToggle: (key: string, checked: boolean) => void +} & FailureProps & + MachineProps): React.JSX.Element { const machines = useMemo( () => groupResumeCandidatesByHost(candidates).map((machine) => ({ @@ -370,7 +383,11 @@ export function ResumeOnRestartGroups({ () => new Map(hostOptions.map((host) => [host.id, host.label])), [hostOptions] ) - const expansion = useResumeTreeExpansion() + const { defaultExpanded, listingOf } = machineProps + const expansion = useResumeTreeExpansion( + defaultExpanded, + listingOf && { listingOf, shown: machines.map((machine) => listingOf(machine.hostId)) } + ) const tree: TreeProps = { listedAt, busy, @@ -381,8 +398,7 @@ export function ResumeOnRestartGroups({ ancestorsOf, failureFor, onFailureAction, - renderStatus, - selectableIds + ...machineProps } // Why: the machine is worth a level only when it is not obvious. const showMachines = @@ -397,6 +413,7 @@ export function ResumeOnRestartGroups({ candidates={machine.candidates} workspaces={machine.workspaces} hostLabelById={hostLabelById} + subtitle={machineProps.machineSubtitle?.(machine.hostId)} tree={tree} /> ) : ( diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.layout.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.layout.test.tsx new file mode 100644 index 000000000000..1d8855c50e98 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.layout.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } 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 type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + consumeNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' + +// The dialog's own layout and focus, apart from what its actions do. + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', + // A failed row opens the status feed; these cases never drive it. + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement +const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ + sessionId, + workspaceId: 'workspace', + agent: 'codex', + trigger: 'quit', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1_800_000_000_000, + executionHostId: 'local', + workspaceKind: 'git-worktree', + origin: 'own' +})) + +/** A chat the host acted on and could not carry on, as it reports it. */ +function failure(sessionId: string) { + const candidate = offered.find((entry) => entry.sessionId === sessionId)! + return { + ...candidate, + failedAt: candidate.recordedAt + 60_000, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded' + } +} + +/** 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 +} + +beforeEach(() => { + rpc.mockReset() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalNativeChat: true } + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() +}) + +// Left to the dialog, focus lands on the scrollable list and draws a ring around it. +it('opens with focus on the resume action', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + expect(document.activeElement?.textContent?.trim()).toBe('Resume 2 chats') +}) + +it('uses the sidebar surface without a border around the resume list', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + const list = document.querySelector('[aria-label="Chats that would be resumed"]') + expect(list).not.toBeNull() + expect(list?.classList.contains('bg-worktree-sidebar')).toBe(true) + expect(list?.classList.contains('border')).toBe(false) +}) + +it('keeps initial focus inside the dialog with no resumable chats', async () => { + rpc.mockResolvedValue({ sessions: [], failed: [failure('b')] }) + await mount() + await act(async () => requestNativeChatResumeOnRestartDialog()) + const dialog = document.querySelector('[role="dialog"]') + expect(button('Resume 0 chats').disabled).toBe(true) + expect(dialog).not.toBeNull() + expect(dialog?.contains(document.activeElement)).toBe(true) +}) + +it('keeps initial focus inside the dialog when reopened during resume', async () => { + const continued = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' + ? Promise.resolve({ sessions: offered }) + : continued.promise + ) + await mount( + <> + + + + ) + await act(async () => button('Resume 2 chats').click()) + const opener = button('Resuming chats 0/2') + await act(async () => { + opener.focus() + opener.click() + }) + const dialog = document.querySelector('[role="dialog"]') + expect(button('Resuming…').disabled).toBe(true) + expect(dialog).not.toBeNull() + expect(dialog?.contains(document.activeElement)).toBe(true) + await act(async () => continued.resolve({ sessions: [], failed: [], resumed: [], continued: [] })) +}) + +// One primary action and one way out of it; the body copy carries the transparency. +it('offers exactly Dismiss all and the resume action', async () => { + rpc.mockResolvedValue({ sessions: offered }) + await mount() + // Checkboxes and tree arrows are buttons too; the controls are what is left after them. + const controls = document.querySelectorAll( + '[role="dialog"] button:not([role="checkbox"]):not([aria-expanded])' + ) + expect([...controls].map((entry) => entry.textContent?.trim())).toEqual([ + 'Dismiss all', + 'Resume 2 chats', + 'Close' + ]) +}) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx index eed135ccce05..4f78eb53a41e 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx @@ -21,17 +21,18 @@ import { consumeNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' -import { _resetNativeChatRestartOffer } from './native-chat-resume-on-restart-store' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: vi.fn(async () => true) })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) globalThis.IS_REACT_ACT_ENVIRONMENT = true let root: Root @@ -221,6 +222,10 @@ it('disables Select all and the workspace checkbox while a resume runs', async ( for (const box of boxes) { expect(box.hasAttribute('disabled')).toBe(true) } + // Select all shows the run, as the rows under it do. + const selectAll = namedBox('Select all chats') + expect(selectAll.getAttribute('aria-checked')).toBe('true') + expect(selectAll.closest('label')?.textContent).toContain('2 of 2 selected') await act(async () => continued.resolve({ sessions: [], failed: [], resumed: [], continued: [] })) }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.machine-column.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.machine-column.test.tsx new file mode 100644 index 000000000000..2c3f672540f4 --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.machine-column.test.tsx @@ -0,0 +1,417 @@ +// @vitest-environment happy-dom + +// Machine rows inside the dialog's checkbox-column list: one column of boxes, one Select all. + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' +import { getDefaultSettings } from '../../../shared/constants' +import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' +import { buildSidebarHostOptions } from './sidebar/sidebar-host-options' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { TooltipProvider } from './ui/tooltip' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + consumeNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { readNativeChatRestartMachine } from './native-chat-resume-on-restart-store' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' +import { pairedEnvironment } from './native-chat-restart-offer-test-support' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { button, chatBox, namedBox } from './native-chat-resume-on-restart-modal.test-support' +import { + machineDisclosure, + machineRow, + machineRowFixture as row, + machineToggle +} from './native-chat-resume-machines.test-support' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement + +type Listing = { sessions: ResumeCandidate[]; failed?: ResumeFailure[] } + +const LOCAL = getHostContextLabel('local') +const SERVER_ROWS = [row('s1', 'own'), row('s2', 'other-device'), row('s3', 'automation')] + +/** Reads this computer and each named server with the listing given for it. A server's resume + * never answers; this computer's does at once. */ +async function stage(local: Listing, servers: Record): Promise { + rpc.mockImplementation(async (target, method) => { + if (method === 'agentSession.restartContinue') { + return target.kind === 'local' ? { continued: [], ...local } : new Promise(() => {}) + } + return target.kind === 'local' ? local : (servers[target.environmentId] ?? { sessions: [] }) + }) + await act(async () => { + await readNativeChatRestartMachine({ kind: 'local' }) + for (const environmentId of Object.keys(servers)) { + await readNativeChatRestartMachine({ kind: 'environment', environmentId }) + } + }) +} + +async function open(focus: string | null): Promise { + await act(async () => + root.render( + + + + ) + ) + await act(async () => requestNativeChatResumeOnRestartDialog(focus)) +} + +/** The grid row a checkbox heads: its first cell is the checkbox column. */ +function rowOf(box: HTMLElement): HTMLElement { + return box.parentElement!.parentElement! +} + +function selectAllCount(): string | undefined { + return namedBox('Select all chats').closest('label')?.querySelector('.tabular-nums')?.textContent +} + +beforeEach(() => { + rpc.mockReset() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalNativeChat: false }, + runtimeEnvironments: [ + pairedEnvironment('studio', 'studio-mac'), + pairedEnvironment('build', 'build-box') + ] + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + replaceRuntimeEnvironmentRevisions([]) + act(() => root.unmount()) + container.remove() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +it('puts each machine in the tree’s one checkbox column, its chats one level further in', async () => { + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + + expect(document.querySelectorAll('[aria-label="Select all chats"]')).toHaveLength(1) + // Every checkbox opens its row; nesting is the indent after it and the tree's own levels. + const level = (box: HTMLElement) => box.closest('[role="treeitem"]')?.getAttribute('aria-level') + for (const box of document.querySelectorAll('[role="tree"] [role="checkbox"]')) { + expect(box.closest('label')?.firstElementChild?.contains(box)).toBe(true) + } + expect(level(machineToggle('studio-mac'))).toBe('1') + expect(level(namedBox('Select all chats in workspace-s1'))).toBe('2') + expect(level(chatBox('s1'))).toBe('3') + + const order = [ + namedBox('Select all chats'), + machineToggle(LOCAL), + machineToggle('studio-mac'), + namedBox('Select all chats in workspace-s1'), + chatBox('s1'), + namedBox('Select all chats in workspace-s2'), + chatBox('s2'), + namedBox('Select all chats in workspace-s3'), + chatBox('s3') + ] + order[0]!.focus() + for (const next of order.slice(1)) { + await act(async () => { + document.activeElement?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ) + }) + expect(document.activeElement).toBe(next) + } +}) + +// One tree lists every machine: a paired server is one machine node, never a node inside a row of +// this dialog's own. The tree's keys pass the rows' own dismiss controls by: they are not checkboxes. +it('shows a paired server as exactly one machine node, and its keys pass the rows’ dismiss', async () => { + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + const named = [...document.querySelectorAll('[role="treeitem"]')].filter( + (item) => + item.getAttribute('aria-level') === '1' && + item.querySelector('[role="checkbox"]')?.getAttribute('aria-label')?.includes('studio-mac') + ) + expect(named).toHaveLength(1) + expect(document.querySelectorAll('[aria-label="Select all chats on studio-mac"]')).toHaveLength(1) + + // Another device's chat has its own dismiss beside it; Down from that chat goes to the next box. + const s2Row = chatBox('s2').closest('[role="treeitem"]')! + expect(s2Row.querySelector('button[aria-label^="Dismiss"]')).not.toBeNull() + const press = (target: Element, key: string) => + act(async () => { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + }) + chatBox('s2').focus() + await press(chatBox('s2'), 'ArrowDown') + expect(document.activeElement).toBe(namedBox('Select all chats in workspace-s3')) + // From the tree's own Tab stop: Down enters at the first box, End at the last. + const tree = document.querySelector('[role="tree"]')! + tree.focus() + await press(tree, 'ArrowDown') + expect(document.activeElement).toBe(namedBox('Select all chats')) + tree.focus() + await press(tree, 'End') + expect(document.activeElement).toBe(chatBox('s3')) + // A pressed arrow hands focus to its machine's checkbox, beside the machine's subtitle. + await act(async () => machineDisclosure('studio-mac').click()) + expect(document.activeElement).toBe(machineToggle('studio-mac')) +}) + +// A machine is a node of the tree: its row heads its workspaces, says why it stopped and when, and +// Left/Right on its checkbox close and open it as on any node. +it('heads each machine’s workspaces with its own row, opened and closed from its checkbox', async () => { + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + const studio = machineRow('studio-mac') + expect(studio.textContent).toContain('Installed an update · now') + expect( + studio.compareDocumentPosition(namedBox('Select all chats in workspace-s1')) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + expect(machineRow(LOCAL).getAttribute('aria-expanded')).toBe('false') + const key = (key: string) => + act(async () => { + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + }) + machineToggle(LOCAL).focus() + await key('ArrowRight') + expect(machineRow(LOCAL).getAttribute('aria-expanded')).toBe('true') + expect(chatBox('l1')).toBeTruthy() + await key('ArrowLeft') + expect(machineRow(LOCAL).getAttribute('aria-expanded')).toBe('false') +}) + +// Two servers may hold the same session id: each row is its own machine's, ticked and resumed there. +it('keeps a chat of the same id on two servers apart, ticked and resumed on its own machine', async () => { + await stage( + { sessions: [] }, + { studio: { sessions: [row('s1', 'own')] }, build: { sessions: [row('s1', 'other-device')] } } + ) + await open(null) + // build-box starts open (nothing on it starts ticked); open studio-mac too. + await act(async () => machineDisclosure('studio-mac').click()) + const boxes = [...document.querySelectorAll('[aria-label*="Prompt s1"]')] + const machineOf = (box: HTMLElement) => + machineToggle('build-box').compareDocumentPosition(box) & Node.DOCUMENT_POSITION_FOLLOWING && + !(machineToggle('studio-mac').compareDocumentPosition(box) & Node.DOCUMENT_POSITION_FOLLOWING) + ? 'build' + : 'studio' + const onBuild = boxes.find((box) => machineOf(box) === 'build')! + const onStudio = boxes.find((box) => machineOf(box) === 'studio')! + expect([onBuild.getAttribute('aria-checked'), onStudio.getAttribute('aria-checked')]).toEqual([ + 'false', + 'true' + ]) + await act(async () => onBuild.click()) + expect([onBuild.getAttribute('aria-checked'), onStudio.getAttribute('aria-checked')]).toEqual([ + 'true', + 'true' + ]) + await act(async () => onStudio.click()) + await act(async () => button('Resume 1 chat').click()) + expect( + rpc.mock.calls + .filter((call) => call[1] === 'agentSession.restartContinue') + .map((call) => call[0]) + ).toEqual([{ kind: 'environment', environmentId: 'build' }]) +}) + +// A machine the user opened or closed stays that way when its next answer lands. +it('keeps a machine the user closed closed when its answer arrives again', async () => { + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('true') + await act(async () => machineDisclosure('studio-mac').click()) + await stage( + { sessions: [row('l1', 'own')] }, + { studio: { sessions: [...SERVER_ROWS, row('s4', 'own')] } } + ) + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('false') + expect(machineRow('studio-mac').textContent).toContain('2 of 4') +}) + +// What the user opened or closed holds for the listing it was done in: a server re-paired, or gone +// and listed again, starts from its own defaults. +it.each([ + ['re-paired', 2], + ['listed again under the same pairing', undefined] +] as const)( + 'opens a machine the user had closed by its default once it is %s', + async (_, pairingRevision) => { + replaceRuntimeEnvironmentRevisions([]) + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + await act(async () => machineDisclosure('studio-mac').click()) + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('false') + // The server's listing goes; this computer's keeps the dialog open. + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: [] } }) + expect(document.querySelectorAll('[aria-label="Select all chats on studio-mac"]')).toHaveLength( + 0 + ) + if (pairingRevision !== undefined) { + replaceRuntimeEnvironmentRevisions([{ id: 'studio', createdAt: 1, pairingRevision }]) + } + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('true') + expect(chatBox('s1')).toBeTruthy() + } +) + +it('ticks and clears only its own machine’s chats from a machine’s tri-state box', async () => { + await stage( + { sessions: [row('l1', 'own'), row('l2', 'other-device')] }, + { studio: { sessions: SERVER_ROWS } } + ) + await open(null) + expect(machineToggle(LOCAL).getAttribute('aria-checked')).toBe('mixed') + expect(machineToggle('studio-mac').getAttribute('aria-checked')).toBe('mixed') + + await act(async () => machineToggle('studio-mac').click()) + expect(machineToggle('studio-mac').getAttribute('aria-checked')).toBe('true') + expect(machineToggle(LOCAL).getAttribute('aria-checked')).toBe('mixed') + expect(button('Resume 4 chats')).toBeTruthy() + + await act(async () => machineToggle('studio-mac').click()) + expect(machineToggle('studio-mac').getAttribute('aria-checked')).toBe('false') + expect(machineToggle(LOCAL).getAttribute('aria-checked')).toBe('mixed') + expect(button('Resume 1 chat')).toBeTruthy() +}) + +// Another device's chat starts unticked but can be picked, so Select all counts and ticks it; a +// failure no retry can fix cannot be picked at all, so it counts nowhere. +it('counts one Select all across machines, others’ chats included, unfixable failures not', async () => { + const unfixable = { + ...row('s4', 'own'), + failedAt: 1_800_000_060_000, + outcome: 'refused' as const, + reason: 'agent_session_restart_work_superseded', + retryable: false + } + await stage( + { sessions: [row('l1', 'own')] }, + { studio: { sessions: [row('s1', 'own'), row('s2', 'other-device')], failed: [unfixable] } } + ) + await open('environment:studio') + const selectAll = namedBox('Select all chats') + expect(selectAll.getAttribute('aria-checked')).toBe('mixed') + expect(selectAllCount()).toBe('2 of 3 selected') + + await act(async () => selectAll.click()) + expect(selectAll.getAttribute('aria-checked')).toBe('true') + expect(selectAllCount()).toBe('3 of 3 selected') + expect(chatBox('s2').getAttribute('aria-checked')).toBe('true') + expect(chatBox('s4').getAttribute('aria-checked')).toBe('false') + expect(button('Resume 3 chats')).toBeTruthy() + + await act(async () => selectAll.click()) + expect(selectAllCount()).toBe('0 of 3 selected') + expect(button('Resume 0 chats').disabled).toBe(true) +}) + +// A machine mid-resume is locked; its chats show the run, not a choice Select all can change. +it('leaves a machine whose resume is running out of Select all', async () => { + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + await act(async () => button('Resume 2 chats').click()) + await open('environment:studio') + expect(machineToggle('studio-mac').hasAttribute('disabled')).toBe(true) + expect(selectAllCount()).toBe('1 of 1 selected') + await act(async () => namedBox('Select all chats').click()) + expect(selectAllCount()).toBe('0 of 1 selected') + // The running chat shows where it stands in its checkbox's place. + expect( + document.querySelector('[role="img"][aria-label="Prompt s1: Waiting to start · 0s"]') + ).not.toBeNull() + expect(button('Resuming…').disabled).toBe(true) +}) + +// With every machine mid-resume there is nothing to choose; Select all shows the run instead. +it('shows the run in Select all while every machine is resuming', async () => { + await stage( + { sessions: [] }, + { studio: { sessions: SERVER_ROWS }, build: { sessions: [row('b1', 'own')] } } + ) + await open(null) + await act(async () => button('Resume 2 chats').click()) + await open(null) + const selectAll = namedBox('Select all chats') + expect(selectAll.hasAttribute('disabled')).toBe(true) + expect(selectAll.getAttribute('aria-checked')).toBe('mixed') + expect(selectAllCount()).toBe('2 of 4 selected') +}) + +// The machine row names the server; a chip saying the same on every workspace under it would only +// repeat it, so a paired server's workspaces carry none. +it('names a paired server once, on its machine row, not again on each workspace', async () => { + await stage({ sessions: [row('l1', 'own')] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + for (const sessionId of ['s1', 's2', 's3']) { + const workspace = rowOf(namedBox(`Select all chats in workspace-${sessionId}`)) + expect(workspace.textContent).not.toContain('studio') + } +}) + +// A machine is named from the sidebar's host names, a rename included: this computer, a server, +// and this computer's SSH host, which carries the SSH chip. No workspace repeats a machine's name. +it('names each machine as the sidebar does, a rename included, and an SSH host with its chip', async () => { + useAppStore.setState({ + settings: { + ...getDefaultSettings(''), + experimentalNativeChat: false, + hostSettingOverrides: { + local: { displayLabel: 'Desk' }, + 'runtime:studio': { displayLabel: 'Studio' } + } + }, + sshTargetLabels: new Map([['devbox-1', 'devbox']]) + }) + const onSsh = { ...row('l2', 'own'), executionHostId: 'ssh:devbox-1' as const } + await stage({ sessions: [row('l1', 'own'), onSsh] }, { studio: { sessions: SERVER_ROWS } }) + await open('environment:studio') + // The labels the sidebar's own host sections are built from. + const state = useAppStore.getState() + const sidebar = new Map( + buildSidebarHostOptions({ + repos: state.repos, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + settings: state.settings, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(state.settings) + }).map((host) => [host.id, host.label]) + ) + expect([sidebar.get('local'), sidebar.get('runtime:studio')]).toEqual(['Desk', 'Studio']) + expect(machineToggle('Desk')).toBeTruthy() + expect(machineToggle('Studio')).toBeTruthy() + expect(machineRow('devbox').textContent).toContain('SSH') + expect(machineRow('Studio').textContent).not.toContain('SSH') + await act(async () => machineDisclosure('devbox').click()) + const workspaceRow = (name: string) => + namedBox(`Select all chats in ${name}`).closest('[role="treeitem"]')?.textContent ?? '' + expect(workspaceRow('workspace-s1')).not.toContain('Studio') +}) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.machines.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.machines.test.tsx new file mode 100644 index 000000000000..fbc1b79867ec --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.machines.test.tsx @@ -0,0 +1,542 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { useAppStore } from '../store' +import { getDefaultSettings } from '../../../shared/constants' +import { getHostContextLabel } from '../../../shared/worktree/host-context-labels' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { TooltipProvider } from './ui/tooltip' +import { Dialog, DialogContent, DialogTitle } from './ui/dialog' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { requestLaunchResumePrompt } from './native-chat-resume-on-restart-launch-prompt' +import { readNativeChatRestartMachine } from './native-chat-resume-on-restart-store' +import { continueNativeChatRestartOffers } from './native-chat-restart-offer-actions' +import { reopenNativeChatRestartOffer } from './native-chat-restart-offer-reopen' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' +import { pairedEnvironment } from './native-chat-restart-offer-test-support' +import { lastToastShow } from './native-chat-resume-toast.test-support' +import { + machineRow, + machineRowFixture, + machineToggle +} from './native-chat-resume-machines.test-support' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement + +const row = machineRowFixture +const LOCAL = getHostContextLabel('local') + +let localRows = [row('l1', 'own')] +const SERVER_ROWS = [row('s1', 'own'), row('s2', 'other-device'), row('s3', 'automation')] +const OTHERS_ONLY = [row('o1', 'other-device')] + +async function stage(servers: Record): Promise { + rpc.mockImplementation(async (target, method) => { + if (method !== 'agentSession.restartResumable') { + return { continued: [], sessions: [] } + } + return { + sessions: target.kind === 'local' ? localRows : (servers[target.environmentId] ?? []) + } + }) + await act(async () => { + await readNativeChatRestartMachine({ kind: 'local' }) + for (const environmentId of Object.keys(servers)) { + await readNativeChatRestartMachine({ kind: 'environment', environmentId }) + } + }) +} + +/** Reads this computer and the studio server with whatever `rpc` answers now. */ +async function stageReads(): Promise { + await act(async () => { + await readNativeChatRestartMachine({ kind: 'local' }) + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) +} + +async function open(focus: string | null): Promise { + await act(async () => + root.render( + + + + ) + ) + await act(async () => requestNativeChatResumeOnRestartDialog(focus)) +} + +function button(text: string): HTMLButtonElement { + const found = [...document.querySelectorAll('button')].find( + (entry) => entry.textContent?.trim() === text + ) + if (!found) { + throw new Error(`Missing button: ${text}`) + } + return found +} + +function actionCalls(method: string): unknown[] { + return rpc.mock.calls.filter((call) => call[1] === method).map((call) => [call[0], call[2]]) +} + +beforeEach(() => { + rpc.mockReset() + vi.mocked(toast).mockClear() + localRows = [row('l1', 'own')] + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalNativeChat: false }, + runtimeEnvironments: [ + pairedEnvironment('studio', 'studio-mac'), + pairedEnvironment('build', 'build-box') + ] + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +it('lists each machine with only the user’s own chats ticked, opening the one it was asked for', async () => { + await stage({ studio: SERVER_ROWS }) + await open('environment:studio') + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('true') + expect(machineRow('studio-mac').textContent).toContain('1 of 3') + // This computer's own chats all start ticked, and it stays closed but listed. + expect(machineRow(LOCAL).getAttribute('aria-expanded')).toBe('false') + expect(machineToggle('studio-mac').getAttribute('data-state')).toBe('indeterminate') + expect(document.body.textContent).toContain('Another device') + expect(document.body.textContent).toContain('Automation') + expect(button('Resume 2 chats')).toBeTruthy() +}) + +it('opens a machine with nothing ticked so its empty box is explained', async () => { + await stage({ studio: SERVER_ROWS, build: OTHERS_ONLY }) + await open(null) + expect(machineRow('build-box').getAttribute('aria-expanded')).toBe('true') + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('false') + expect(machineToggle('build-box').getAttribute('data-state')).toBe('unchecked') +}) + +it('resumes each machine’s picked chats on that machine, and the machine box picks them all', async () => { + await stage({ studio: SERVER_ROWS }) + await open('environment:studio') + await act(async () => machineToggle('studio-mac').click()) + expect(machineToggle('studio-mac').getAttribute('data-state')).toBe('checked') + await act(async () => button('Resume 4 chats').click()) + expect(actionCalls('agentSession.restartContinue')).toEqual([ + [{ kind: 'local' }, { sessionIds: ['l1'] }], + [{ kind: 'environment', environmentId: 'studio' }, { sessionIds: ['s1', 's2', 's3'] }] + ]) +}) + +// Dismiss all clears only the user's own chats, on this computer as on a server; another +// device's, an automation's and the server's own stay for their owner, and the button says so. +it('dismisses only the user’s own chats, on every machine', async () => { + localRows = [row('l1', 'own'), row('l2', 'other-device'), row('l3', 'automation')] + await stage({ studio: [...SERVER_ROWS, row('s4', 'server-made')] }) + await open(null) + await act(async () => button('Dismiss').click()) + const listed = (sessionId: string) => ({ sessionId, recordedAt: 1_800_000_000_000 }) + expect(actionCalls('agentSession.restartResumableDismiss')).toEqual([ + [{ kind: 'local' }, { sessionIds: ['l1'], offers: [listed('l1')] }], + [ + { kind: 'environment', environmentId: 'studio' }, + { sessionIds: ['s1'], offers: [listed('s1')] } + ] + ]) +}) + +// What Dismiss all leaves has its own way out: its row's dismiss forgets exactly that offer. +it('dismisses a chat that is not the user’s from its own row, and offers no such control on theirs', async () => { + await stage({ studio: [row('s1', 'own'), row('s2', 'automation')] }) + await open('environment:studio') + const rowDismiss = (prompt: string) => + [...document.querySelectorAll('button')].find( + (entry) => + entry.getAttribute('aria-label') === `Dismiss "${prompt}" in workspace-s${prompt.at(-1)}` + ) + expect(rowDismiss('Prompt s1')).toBeUndefined() + await act(async () => rowDismiss('Prompt s2')?.click()) + expect(actionCalls('agentSession.restartResumableDismiss')).toEqual([ + [ + { kind: 'environment', environmentId: 'studio' }, + { + sessionIds: ['s2'], + offers: [{ sessionId: 's2', recordedAt: 1_800_000_000_000 }] + } + ] + ]) +}) + +it('says "Dismiss all" only when every listed chat is the user’s own', async () => { + await stage({ studio: [row('s1', 'own')] }) + await open(null) + expect(button('Dismiss all')).toBeTruthy() + await stage({ studio: [row('s1', 'own'), row('s2', 'automation')] }) + expect(button('Dismiss')).toBeTruthy() +}) + +it('reports one resume across machines in one toast', async () => { + await stage({ studio: SERVER_ROWS }) + rpc.mockImplementation(async (target, method) => + method === 'agentSession.restartContinue' + ? { + continued: [{ sessionId: target.kind === 'local' ? 'l1' : 's1', outcome: 'continued' }], + sessions: [], + failed: [] + } + : { sessions: [] } + ) + await open(null) + await act(async () => button('Resume 2 chats').click()) + await vi.waitFor(() => expect(toast).toHaveBeenCalled()) + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual(['Resumed 2 chats']) +}) + +// One slow server must not hold this computer's chats hostage. +it('locks only the machine whose resume is still running', async () => { + await stage({ studio: SERVER_ROWS }) + rpc.mockImplementation(async (target, method) => + method === 'agentSession.restartContinue' && target.kind === 'environment' + ? new Promise(() => {}) + : { continued: [], sessions: localRows } + ) + await open('environment:studio') + await act(async () => machineToggle('studio-mac').click()) + await act(async () => button('Resume 4 chats').click()) + await open(null) + expect(machineToggle('studio-mac').hasAttribute('disabled')).toBe(true) + expect(button('Resume 1 chat').disabled).toBe(false) +}) + +// The launch read joining a dialog the user opened must not move its focus. +it("keeps the user's ticks and open machine when this computer's launch read lands", async () => { + // Only the server is listed when the user opens it; this computer's read answers afterwards. + localRows = [] + await stage({ studio: [row('s1', 'own'), row('s2', 'own')] }) + await open('environment:studio') + expect(button('Resume 2 chats')).toBeTruthy() + await act(async () => machineToggle('studio-mac').click()) + expect(button('Resume 0 chats')).toBeTruthy() + + localRows = [row('l1', 'own')] + await act(async () => void (await readNativeChatRestartMachine({ kind: 'local' }))) + await act(async () => requestLaunchResumePrompt()) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'environment:studio' }) + // This computer joins the open dialog with its chat ticked; the server's unticks stay. + expect(machineRow(LOCAL)).toBeTruthy() + expect(button('Resume 1 chat')).toBeTruthy() + expect(machineToggle('studio-mac').getAttribute('data-state')).toBe('unchecked') + expect(machineRow('studio-mac').getAttribute('aria-expanded')).toBe('true') +}) + +it('keeps the flat list when only this computer has chats', async () => { + await stage({}) + await open('local') + // No machine level: the only machine is obvious. + expect(document.querySelector('[aria-label^="Select all chats on "]')).toBeNull() + expect(button('Resume 1 chat')).toBeTruthy() +}) + +// A dialog the user opened is never the app's own prompt: another dialog does not hide it. +it('keeps a user-opened dialog on screen when another dialog opens over it', async () => { + await stage({ studio: SERVER_ROWS }) + await act(async () => + root.render( + + + + + Another dialog + + + + ) + ) + await act(async () => requestNativeChatResumeOnRestartDialog('environment:studio')) + const resume = [...document.querySelectorAll('[role="dialog"]')].find((entry) => + entry.textContent?.includes('Resume interrupted chats?') + ) + expect(resume).toBeTruthy() +}) + +// Once open, a launch-raised dialog stays until the user closes it, whatever this computer's list +// does under it. +it('keeps an open launch dialog when this computer’s chats run out while a server’s remain', async () => { + await stage({ studio: [row('s1', 'own')] }) + await act(async () => + root.render( + + + + ) + ) + await act(async () => requestLaunchResumePrompt()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + localRows = [] + await act(async () => { + await readNativeChatRestartMachine({ kind: 'local' }) + }) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'local' }) +}) + +// The workspace rows inside name no host their machine row already names, so the machine row is +// what says where the chats are, this computer's and a server's alike, even alone. +it('names the machine on its row whenever chats are grouped by machine, even a single server', async () => { + localRows = [] + await stage({ studio: SERVER_ROWS }) + await open(null) + expect(machineRow('studio-mac').textContent).toContain('studio-mac') + localRows = [row('l1', 'own')] + await stage({ studio: SERVER_ROWS }) + expect(machineRow(LOCAL).textContent).toContain(LOCAL) + expect(machineRow('studio-mac')).toBeTruthy() +}) + +// With nothing of the user's listed, Dismiss would clear nothing and only close the dialog; it stays +// in place, disabled, and each row's own dismiss is the way out. +it('disables Dismiss when no listed chat is the user’s own', async () => { + localRows = [] + await stage({ studio: [row('s2', 'other-device'), row('s3', 'automation')] }) + await open(null) + expect(button('Dismiss').disabled).toBe(true) + await act(async () => button('Dismiss').click()) + expect(actionCalls('agentSession.restartResumableDismiss')).toEqual([]) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() +}) + +// A restart toast still on screen sits above the dialog's backdrop but cannot be clicked through +// it; the open dialog lists the same chats, so the toast goes. +it('dismisses a server’s restart toast once the dialog listing it opens', async () => { + await stage({ studio: [row('s1', 'own')] }) + await open('environment:studio') + expect(toast.dismiss).toHaveBeenCalledWith('native-chat-restart-reconnect:environment:studio') +}) + +// A server's chats opened from its toast before this computer's launch read answers show nothing of +// this computer; once that dialog is closed, this computer's own offer still opens when its read +// lands, with no status bar to fall back on. +it('opens this computer’s offer after a server-only dialog the user closed', async () => { + useAppStore.setState({ statusBarVisible: false }) + localRows = [] + await stage({ studio: [row('s1', 'own')] }) + await open('environment:studio') + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Prompt l1') + await act(async () => button('Close').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + + localRows = [row('l1', 'own')] + await act(async () => void (await readNativeChatRestartMachine({ kind: 'local' }))) + await act(async () => requestLaunchResumePrompt()) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'local' }) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Prompt l1') +}) + +// The open dialog lists a server's restart as it lands, so no toast repeats it. +it('raises no restart toast for a server whose chats land in the open dialog', async () => { + window.localStorage.clear() + await stage({}) + await open('local') + vi.mocked(toast).mockClear() + await stage({ studio: [row('s1', 'own')] }) + expect(toast).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('studio-mac') +}) + +// The dialog's last rows go and a server's restart lands in the same tick, before React renders: +// the dialog is already gone for the user, so the server's restart gets its toast. +it('announces a server restart that lands in the same tick the dialog loses its last rows', async () => { + window.localStorage.clear() + await stage({}) + await open('local') + const localRead = Promise.withResolvers() + const studioRead = Promise.withResolvers() + rpc.mockImplementation((target, method) => + method !== 'agentSession.restartResumable' + ? Promise.resolve({ continued: [], sessions: [] }) + : target.kind === 'local' + ? localRead.promise + : studioRead.promise + ) + vi.mocked(toast).mockClear() + // Outside act on purpose: React's passive effects must not run between the two answers. + const reads = Promise.all([ + readNativeChatRestartMachine({ kind: 'local' }), + readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + ]) + // Both requests are out (the server's after its capability check) before either answers. + await vi.waitFor(() => + expect( + rpc.mock.calls.filter((call) => call[1] === 'agentSession.restartResumable') + ).toHaveLength(2) + ) + localRead.resolve({ sessions: [] }) + studioRead.resolve({ sessions: [row('s1', 'own')] }) + await reads + await act(async () => {}) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ + 'studio-mac restarted for an update' + ]) +}) + +// A "Resuming" click while a concurrent read lists nothing opens the run it follows, and closing it +// leaves nothing behind: once that resume's answer is lost, a later server's restart still gets its +// toast and does not open the dialog by itself. +it('leaves no unseen request behind a Resuming click with nothing listed', async () => { + window.localStorage.clear() + localRows = [] + await stage({ studio: [row('s1', 'own')] }) + await act(async () => + root.render( + + + + ) + ) + const continued = Promise.withResolvers() + let serverRows: Record = { studio: [] } + let studioReadsFail = false + rpc.mockImplementation(async (target, method) => { + if (method === 'agentSession.restartContinue') { + return continued.promise + } + if (target.kind === 'local') { + return { sessions: localRows } + } + if (studioReadsFail && target.environmentId === 'studio') { + throw new Error('offline') + } + return { sessions: serverRows[target.environmentId] ?? [] } + }) + const studio = { kind: 'environment' as const, environmentId: 'studio' } + // 1-2. The server's only chat starts resuming; a read racing it lists nothing. + let resume = Promise.resolve() + await act(async () => { + resume = continueNativeChatRestartOffers([ + { machine: 'environment:studio', sessionIds: ['s1'] } + ]) + }) + await act(async () => void (await readNativeChatRestartMachine(studio))) + // 3. The status entry's "Resuming" click: nothing listed, but the run is shown, then closed. + await act(async () => reopenNativeChatRestartOffer()) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resuming 1 chat') + await act(async () => button('Close').click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() + expect(document.querySelector('[role="dialog"]')).toBeNull() + // 4. The answer is lost and its re-read fails. + studioReadsFail = true + await act(async () => { + continued.reject(new Error('lost')) + await resume + }) + // 5. Another server restarts with the user's chat. + studioReadsFail = false + serverRows = { studio: [], build: [row('b1', 'own')] } + vi.mocked(toast).mockClear() + await act( + async () => + void (await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'build' })) + ) + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ + 'build-box restarted for an update' + ]) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() +}) + +// The server's provider refused to carry the chat on: the host files the failure and lists it. One +// toast says so and opens that server's list, where the row's Retry ends in one toast of its own. +it('reports a chat a server could not carry on once, opens it on that server, and retries it', async () => { + localRows = [] + const failed = { + ...row('s1', 'own'), + failedAt: 1_800_000_060_000, + outcome: 'refused', + reason: 'provider_refused_continuation' + } + const others = [row('s2', 'other-device')] + let phase: 'offered' | 'failed' | 'retried' = 'offered' + rpc.mockImplementation(async (target, method) => { + if (target.kind === 'local') { + return { sessions: [] } + } + if (method === 'agentSession.restartResumable') { + return phase === 'offered' + ? { sessions: [row('s1', 'own'), ...others], failed: [] } + : { sessions: others, failed: phase === 'failed' ? [failed] : [] } + } + if (phase === 'offered') { + phase = 'failed' + return { + continued: [{ sessionId: 's1', outcome: 'refused' }], + sessions: others, + failed: [failed] + } + } + phase = 'retried' + return { continued: [{ sessionId: 's1', outcome: 'continued' }], sessions: others, failed: [] } + }) + await stageReads() + await open('environment:studio') + await act(async () => button('Resume 1 chat').click()) + await vi.waitFor(() => expect(toast).toHaveBeenCalled()) + expect(vi.mocked(toast).mock.calls).toEqual([ + [ + '1 chat on studio-mac couldn’t be resumed', + { action: { label: 'Show', onClick: expect.any(Function) } } + ] + ]) + expect(document.querySelector('[role="dialog"]')).toBeNull() + + await act(async () => lastToastShow()?.()) + await vi.waitFor(() => + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'environment:studio' }) + ) + const dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('studio-mac') + expect(dialog?.textContent).toContain('Prompt s1') + + await act(async () => button('Retry').click()) + await vi.waitFor(() => expect(toast).toHaveBeenCalledTimes(2)) + expect(actionCalls('agentSession.restartContinue').at(-1)).toEqual([ + { kind: 'environment', environmentId: 'studio' }, + { sessionIds: ['s1'] } + ]) + // Show opens the retry's own summary. + expect(vi.mocked(toast).mock.calls[1]).toEqual([ + 'Resumed 1 chat on studio-mac', + { action: { label: 'Show', onClick: expect.any(Function) } } + ]) +}) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 5cae482fa95e..a2dff3bd78ce 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -24,8 +24,8 @@ import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { - getNativeChatRestartOffer, - refreshNativeChatRestartOffer + getNativeChatRestartOffers, + readNativeChatRestartMachine } from './native-chat-resume-on-restart-store' const rpc: Mock = vi.hoisted(() => vi.fn()) @@ -36,6 +36,7 @@ const statusStream: ResumeStatusStream = vi.hoisted(() => ({ const activate: Mock<() => Promise> = vi.hoisted(() => vi.fn(async () => true)) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', subscribeStructuredAgentSessionStatus: async ( _target: unknown, emit: (event: Parameters[0]) => void @@ -48,12 +49,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: activate })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) -const { mount, button, offerIds, toasts, fakeHost, runStatus, calls } = createResumeModalFixture( - rpc, - statusStream -) +const { mount, button, offerIds, failedIds, toasts, fakeHost, runStatus, calls } = + createResumeModalFixture(rpc, statusStream) it('keeps next-launch preference out of the current resume action', async () => { const host = fakeHost() @@ -72,21 +71,6 @@ it('keeps next-launch preference out of the current resume action', async () => expect(calls().slice(2)).toEqual([]) }) -// One primary action and one way out of it; the body copy carries the transparency. -it('offers exactly Dismiss all and the resume action', async () => { - rpc.mockResolvedValue({ sessions: offered }) - await mount() - // Checkboxes and tree arrows are buttons too; the controls are what is left after them. - const controls = document.querySelectorAll( - '[role="dialog"] button:not([role="checkbox"]):not([aria-expanded])' - ) - expect([...controls].map((entry) => entry.textContent?.trim())).toEqual([ - 'Dismiss all', - 'Resume 2 chats', - 'Close' - ]) -}) - // Rows the sidebar showed as working for different reasons must read differently. it('says under each chat what it was doing when Orca went away', async () => { rpc.mockResolvedValue({ @@ -131,7 +115,16 @@ it('fully dismisses the offer only through Dismiss all', async () => { await act(async () => button('Dismiss all').click()) expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ ['agentSession.restartResumable', undefined], - ['agentSession.restartResumableDismiss', {}] + [ + 'agentSession.restartResumableDismiss', + { + sessionIds: ['a', 'b'], + offers: [ + { sessionId: 'a', recordedAt: 1_800_000_000_000 }, + { sessionId: 'b', recordedAt: 1_800_000_000_000 } + ] + } + ] ]) expect(offerIds()).toEqual([]) }) @@ -249,7 +242,7 @@ it('closes on Resume and shows the resume in the status bar until the host answe 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) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() }) // A dialog the user reopened mid-run is theirs: the run's answer must not close it over a chat @@ -485,16 +478,16 @@ it('reports the chats a lost resume request named once, as failed, until it is r expect(warn).toHaveBeenCalledWith(expect.any(String), lost) warn.mockRestore() expect(offerIds()).toEqual([]) - expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a', 'b']) + expect(failedIds()).toEqual(['a', 'b']) expect(button('2 chats failed to resume. Click for details.')).toBeTruthy() expect(document.body.textContent).not.toContain('chats to resume') // A host answer that no longer offers a chat ends its mark. sessions = [offered[1]!] await act(async () => { - await refreshNativeChatRestartOffer() + await readNativeChatRestartMachine({ kind: 'local' }) }) - expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['b']) + expect(failedIds()).toEqual(['b']) continueFails = false await act(async () => show?.()) @@ -503,7 +496,7 @@ it('reports the chats a lost resume request named once, as failed, until it is r ) await act(async () => button('Retry').click()) expect(calls().at(-1)).toEqual(['agentSession.restartContinue', { sessionIds: ['b'] }]) - expect(getNativeChatRestartOffer()).toMatchObject({ candidates: [], failed: [] }) + expect(getNativeChatRestartOffers().get('local')).toBeUndefined() expect(toasts().at(-1)).toEqual(['Resumed 1 chat']) }) @@ -533,17 +526,17 @@ it('keeps a lost resume request marked failed when its Dismiss fails', async () }) await mount() await act(async () => button('Resume 1 chat').click()) - expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a']) + expect(failedIds()).toEqual(['a']) expect(toasts()).toEqual([['1 chat couldn’t be resumed']]) await act(async () => requestNativeChatResumeOnRestartDialog()) await act(async () => button('Dismiss "Prompt a" in workspace').click()) expect(rpc.mock.calls.at(-2)?.slice(1)).toEqual([ 'agentSession.restartResumableDismiss', - { sessionIds: ['a'] } + { sessionIds: ['a'], offers: [{ sessionId: 'a', recordedAt: 1_800_000_000_000 }] } ]) expect(offerIds()).toEqual([]) - expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a']) + expect(failedIds()).toEqual(['a']) // A Dismiss is bookkeeping: whether the host took it raises no toast of its own. expect(toast).toHaveBeenCalledTimes(1) vi.mocked(console.warn).mockRestore() @@ -591,9 +584,13 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha expect(chatBox('b').getAttribute('data-state')).toBe('unchecked') await act(async () => button('Open chat').click()) - expect(activate).toHaveBeenCalledWith({ - structuredSession: { workspaceId: 'workspace', sessionId: 'b' } - }) + // This computer's chat opens from its workspace, as any other open does. + expect(activate).toHaveBeenCalledWith( + { structuredSession: { workspaceId: 'workspace', sessionId: 'b' } }, + undefined, + undefined, + {} + ) // Opening is read-only: the record stays with the host, the dialog just gets out of the way. expect(rpc.mock.calls.map((call) => call[1])).not.toContain( 'agentSession.restartResumableDismiss' @@ -686,7 +683,7 @@ it('keeps a failure the host marks unretryable out of Resume, even after a tick' expect(button('Resume 1 chat').disabled).toBe(false) failed = [{ ...failure('b'), retryable: false }] - await act(async () => void (await refreshNativeChatRestartOffer())) + await act(async () => void (await readNativeChatRestartMachine({ kind: 'local' }))) expect(chatBox('b').hasAttribute('disabled')).toBe(true) expect(button('Resume 0 chats').disabled).toBe(true) expect(button('Open chat').disabled).toBe(false) @@ -712,12 +709,15 @@ it('dismisses one failed chat by name, and every record through Dismiss all', as await act(async () => button('Dismiss "Prompt a" in workspace').click()) expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ 'agentSession.restartResumableDismiss', - { sessionIds: ['a'] } + { sessionIds: ['a'], offers: [{ sessionId: 'a', recordedAt: 1_800_000_000_000 }] } ]) expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Prompt a') expect(chatBoxes()).toHaveLength(1) await act(async () => button('Dismiss all').click()) - expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual(['agentSession.restartResumableDismiss', {}]) + expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ + 'agentSession.restartResumableDismiss', + { sessionIds: ['b'], offers: [{ sessionId: 'b', recordedAt: 1_800_000_000_000 }] } + ]) expect(document.querySelector('[role="dialog"]')).toBeNull() // Dismissing is bookkeeping, not a resume: neither form raises a toast. expect(toast).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 0394caa14d77..31391a59a6ba 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,5 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' -import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' +import { useCallback, useMemo, useRef, useState } from 'react' import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' import { Checkbox } from './ui/checkbox' @@ -13,198 +12,182 @@ import { } from './ui/dialog' import { useAppStore } from '../store' import { translate } from '@/i18n/i18n' -import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { ResumeOnRestartGroups } from './NativeChatResumeOnRestartGroups' import { ResumeTreeRow } from './NativeChatResumeTreeRow' +import { moveInResumeTree } from './native-chat-resume-tree-keyboard' +import { resumeTreeMachines, resumeTreeRowAction } from './native-chat-resume-tree-machines' +import type { ResumeFailureAction } from './native-chat-resume-failure-guidance' +import { consumeNativeChatResumeOnRestartDialogRequest } from './native-chat-resume-on-restart-dialog' import { - resumeFailureGuidance, - resumeFailureSelectable, - type ResumeFailureAction -} from './native-chat-resume-failure-guidance' + continueNativeChatRestartOffers, + dismissNativeChatRestartOffer +} from './native-chat-restart-offer-actions' import { - resumeSelectionState, - toggleResumeSelection, - type ResumeCandidate, - type ResumeFailure -} from './native-chat-resume-on-restart-grouping' -import { - consumeNativeChatResumeOnRestartDialogRequest, - getNativeChatResumeOnRestartDialogRequest, - subscribeNativeChatResumeOnRestartDialog -} from './native-chat-resume-on-restart-dialog' -import { - continueNativeChatRestartOffer, - dismissNativeChatRestartOffer, - markFinishedNativeChatRestartRunShown, - releaseFinishedNativeChatRestartRun, - useNativeChatRestartOffer, - useNativeChatRestartResuming, - useNativeChatRestartRun -} from './native-chat-resume-on-restart-store' + releaseFinishedNativeChatRestartRuns, + useNativeChatRestartResuming +} from './native-chat-restart-runs' import { useResumeRunPanel } from './NativeChatResumeRunPanel' +import type { MachineView } from './native-chat-resume-machine-views' +import { useNativeChatResumeDialogOpening } from './native-chat-resume-dialog-opening' +import { actOnResumeRow } from './native-chat-resume-failure-action' +import { resumeSelectionState } from './native-chat-resume-on-restart-grouping' +import { + chosenResumeRows, + dismissedRows, + resumeRowKey, + selectableResumeRows +} from './native-chat-resume-selection' /** - * What would be resumed, shown before anything runs. + * What would be resumed, shown before anything runs — on every machine with chats to resume. * * Resuming reattaches a chat AND asks the agent to carry on, so the list is the point: the user - * sees which chats the last teardown recorded as mid-turn before a message goes anywhere. Every - * string here has to say that a message is sent and that the user's own prompt is not re-sent. + * sees which chats each machine's last teardown recorded as mid-turn before a message goes + * anywhere. Every string here has to say that a message is sent and that the user's own prompt is + * not re-sent. * - * The "don't ask again" box removes the PROMPT, never a safety check — an opted-in launch calls - * the same RPC, which re-derives the same predicate and staggers the same way. + * Each machine is a row with a select-all box, opening onto its workspaces and chats; every box, + * from Select all down to each chat, sits in one left column. The user's own chats start ticked; + * chats another device, an automation or the server itself started are listed unticked with where + * they came from, and Select all counts and ticks them like any other. Only this computer, alone, + * keeps the flat list. + * + * The "don't ask again" box removes the PROMPT, never a safety check — an opted-in launch or + * reconnect calls 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: - * 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. + * not carry on. Each machine's run lives in the store, so the dialog is one view of them: reopened + * mid-run, each chat in one shows where it stands in its checkbox's place, and the dialog stays on + * those runs until it is closed after they have 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. - * Row actions (Retry, Dismiss) act on their row and leave the dialog open. It closes only on the - * user's own way out, or once the host confirms nothing is left; a resume settling never closes it. + * and what to do; selecting it and resuming is a retry. Row actions act on their row and leave the + * dialog open. It closes only on the user's own way out, or once no machine has anything left. * - * Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss all is - * the explicit path that deletes the durable records. + * Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss is the + * explicit path that deletes the durable records, and only the user's own; any other chat ends when + * it moves on, when its tab is closed, or by its own row's dismiss. */ -/** Pre-selected unless it is a failure a retry cannot fix; resuming that would only fail again. */ -function selectedByDefault(failure: ResumeFailure | undefined): boolean { - if (!failure) { - return true +/** Says where the chats were cut off: on several machines, by this computer's update, or its close. */ +function resumeDialogBody(flat: boolean, interruptedByUpdate: boolean): string { + if (!flat) { + return translate( + 'auto.components.NativeChatResumeOnRestartModal.machinesBody', + 'These chats were working when Orca on their machine closed or 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.' + ) } - const guidance = resumeFailureGuidance(failure) - return guidance.primary === 'retry' || guidance.secondary === 'retry' + return 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.' + ) } -/** - * Tree keys. On a row's checkbox: Up/Down step between the enabled checkboxes, Home/End jump to the - * first/last, Left collapses and Right expands the node through its own disclosure; Space toggles - * natively. On the tree itself (its Tab stop): Down/Home enter at the first checkbox, Up/End at - * the last. - */ -function moveInTree(event: React.KeyboardEvent): void { - const target = event.target - if (!(target instanceof HTMLElement)) { - return - } - const boxes = [ - ...event.currentTarget.querySelectorAll('[role="checkbox"]:not(:disabled)') - ] - const onTree = target === event.currentTarget - if (!onTree && target.getAttribute('role') !== 'checkbox') { - return - } - const jump = - event.key === 'Home' || (onTree && event.key === 'ArrowDown') - ? boxes[0] - : event.key === 'End' || (onTree && event.key === 'ArrowUp') - ? boxes.at(-1) - : undefined - if (jump) { - event.preventDefault() - jump.focus() - return - } - if (onTree) { - return - } - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - boxes[boxes.indexOf(target) + (event.key === 'ArrowDown' ? 1 : -1)]?.focus() - return - } - if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - event.preventDefault() - // Rows are flat treeitems, so the nearest one is this checkbox's own node. - const disclosure = target - .closest('[role="treeitem"]') - ?.querySelector('button[aria-expanded]') - const open = disclosure?.getAttribute('aria-expanded') === 'true' - if (disclosure && open === (event.key === 'ArrowLeft')) { - disclosure.click() - } +/** Mid-run with nothing left to choose, the button says the run is going rather than "Resume 0". */ +function resumeButtonLabel(chosenCount: number, running: boolean): string { + if (chosenCount === 0 && running) { + return translate('auto.components.NativeChatResumeOnRestartModal.resuming', 'Resuming…') } + return chosenCount === 1 + ? translate('auto.components.NativeChatResumeOnRestartModal.resumeSelectedOne', 'Resume 1 chat') + : translate( + 'auto.components.NativeChatResumeOnRestartModal.resumeSelected', + 'Resume {{value0}} chats', + { value0: chosenCount } + ) } export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { - const offerEnabled = useNativeChatRestartOfferEnabled() - const { candidates, failed, listedAt } = useNativeChatRestartOffer(offerEnabled) - const rows = useMemo(() => [...candidates, ...failed], [candidates, failed]) - const failureBySession = useMemo( - () => new Map(failed.map((failure) => [failure.sessionId, failure])), - [failed] - ) - // Open is an external one-shot request, never mirrored into local state: the launch load and the - // status-bar entry both raise it, and a copy here would go stale against whichever raised it last. - const open = useSyncExternalStore( - subscribeNativeChatResumeOnRestartDialog, - getNativeChatResumeOnRestartDialogRequest, - getNativeChatResumeOnRestartDialogRequest - ) + const { machines, runs, request, showing } = useNativeChatResumeDialogOpening() const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) const resumeButtonRef = useRef(null) - // The store's: the resume outlives this dialog, which can close or reopen mid-run. + // The store's: the resume outlives this dialog, which can close or reopen mid-run. Busy is per + // machine, so one slow server never locks this computer's chats. const resuming = useNativeChatRestartResuming() - const busy = resuming.length > 0 - /** The user's own ticks and unticks, over each row's default. Tracked as OVERRIDES rather than a - * selection because the list is the host's and arrives — and shrinks — under an open dialog; a - * stored selection would need seeding from an effect every time it changed. */ + const allBusy = machines.length > 0 && machines.every((machine) => resuming.has(machine.machine)) const [overrides, setOverrides] = useState>(() => new Map()) // Each opening starts from the rows' defaults. This component never unmounts, so an untick made // before a close would otherwise greet a reopen, e.g. as "Resume 0 chats" over what a run left. - const [openedWith, setOpenedWith] = useState(open) - if (openedWith !== open) { - setOpenedWith(open) - if (open) { + // Keyed on the request and the machine it was opened for. + const opening = request ? `open\u0000${request.focus ?? ''}` : null + const [openedWith, setOpenedWith] = useState(opening) + if (openedWith !== opening) { + setOpenedWith(opening) + if (opening) { setOverrides(new Map()) } } - /** Every chat a tick can name; a failure the host marks unretryable is left out. */ - const selectable = useMemo( + const chosen = useMemo( () => - rows - .filter((row) => { - const failure = failureBySession.get(row.sessionId) - return !failure || resumeFailureSelectable(failure) - }) - .map((row) => row.sessionId), - [rows, failureBySession] + machines + .filter((machine) => !resuming.has(machine.machine)) + .map((machine) => ({ + machine, + ids: chosenResumeRows(machine, overrides) + })), + [machines, overrides, resuming] ) - /** Derived from the host's own list, so an action can never name a chat it did not list. */ - const chosen = useMemo( + const dismissals = useMemo( () => - // A tick made before the host marked it unretryable must not carry into the action. - selectable.filter( - (sessionId) => - overrides.get(sessionId) ?? selectedByDefault(failureBySession.get(sessionId)) - ), - [selectable, overrides, failureBySession] + machines + .filter((machine) => !resuming.has(machine.machine)) + .map((machine) => ({ machine, ids: dismissedRows(machine) })), + [machines, resuming] ) - 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] + // "Dismiss all" only when it clears every chat listed; any chat not the user's own (another + // device's, an automation's, the server's, or one whose owner the host could not say) stays, and + // the button must not claim otherwise. With nothing of the user's listed it clears nothing, so it + // stays put but disabled; each such row's own dismiss is the way out. + const dismissesEverything = dismissals.every( + (entry) => entry.ids.length === entry.machine.rows.length ) - const run = useNativeChatRestartRun() + const dismissesNothing = dismissals.every((entry) => entry.ids.length === 0) + const chosenCount = chosen.reduce((total, entry) => total + entry.ids.length, 0) + // One Select all across machines, over every chat a tick can name on a machine not mid-resume; + // with every machine mid-resume it shows the run, as the rows below do, and stays disabled. + const allSelection = useMemo(() => { + const counted = allBusy + ? machines.map((machine) => ({ + machine, + ids: resuming.get(machine.machine) ?? [] + })) + : chosen + const keys = counted.flatMap(({ machine }) => + selectableResumeRows(machine).map((sessionId) => resumeRowKey(machine.identity, sessionId)) + ) + const ticked = new Set( + counted.flatMap(({ machine, ids }) => ids.map((id) => resumeRowKey(machine.identity, id))) + ) + return { keys, state: resumeSelectionState(keys, ticked) } + }, [allBusy, chosen, machines, resuming]) + const runPanel = useResumeRunPanel({ - run, - rows, - failureFor, - open + parts: machines.map((machine) => ({ + run: runs.get(machine.machine)?.run ?? null, + rows: machine.rows, + failureFor: machine.failureFor, + keyOf: (sessionId: string) => resumeRowKey(machine.identity, sessionId) + })), + open: showing }) - useEffect(() => { - if (offerEnabled && open && run) { - markFinishedNativeChatRestartRunShown(run) - } - }, [offerEnabled, open, run]) - const allSelection = resumeSelectionState(selectable, ticked) - const toggleSelected = useCallback((sessionId: string, checked: boolean) => { - setOverrides((current) => new Map(current).set(sessionId, checked)) + const toggle = useCallback((key: string, checked: boolean) => { + setOverrides((current) => new Map(current).set(key, checked)) + }, []) + const setTicks = useCallback((keys: readonly string[], checked: boolean) => { + setOverrides((current) => { + const next = new Map(current) + for (const key of keys) { + next.set(key, checked) + } + return next + }) }, []) /** Applied on whichever action the user takes, so the box means the same thing every way out. */ @@ -214,58 +197,61 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { } }, [dontAskAgain, updateSettings]) - // Never closes the dialog: only the user's own ways out do, and the store once nothing is left. - const resume = useCallback( - async (sessionIds: string[]): Promise => { - void persistPreference() - await continueNativeChatRestartOffer(sessionIds) - }, - [persistPreference] - ) - - /** Closing is a snooze: the host keeps the offer and the status bar keeps the way back to it. */ + /** Closing is a snooze: each host keeps its offer and the status bar keeps the way back. */ const snooze = useCallback((): void => { consumeNativeChatResumeOnRestartDialogRequest() - releaseFinishedNativeChatRestartRun() + releaseFinishedNativeChatRestartRuns() void persistPreference() }, [persistPreference]) - const dismissAll = useCallback(async (): Promise => { + const dismissAll = async (): Promise => { void persistPreference() - // Bookkeeping never gates the user's own action: the dialog closes here whatever the host + // Bookkeeping never gates the user's own action: the dialog closes here whatever each host // answers, rather than being trapped open behind a rejected promise. consumeNativeChatResumeOnRestartDialogRequest() - releaseFinishedNativeChatRestartRun() - await dismissNativeChatRestartOffer() - }, [persistPreference]) - - const actOnFailure = async (action: ResumeFailureAction, sessionId: string): Promise => { - if (action === 'dismiss') { - await dismissNativeChatRestartOffer([sessionId]) - return - } - if (action === 'retry') { - await resume([sessionId]) - return - } - const failure = failureBySession.get(sessionId) - if (!failure) { - return - } - // 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 } - }) + releaseFinishedNativeChatRestartRuns() + await Promise.all( + dismissals + .filter((entry) => entry.ids.length > 0) + .map((entry) => dismissNativeChatRestartOffer(entry.machine.machine, entry.ids)) + ) } - if (!offerEnabled || !open || (rows.length === 0 && !runPanel)) { + const actOnFailure = (machine: MachineView, action: ResumeFailureAction, sessionId: string) => + actOnResumeRow(machine, action, sessionId, persistPreference) + + if (!request || !showing) { return null } - const interruptedByUpdate = rows.some((row) => row.trigger === 'update') + const flat = machines.length === 1 && machines[0]!.offer.target.kind === 'local' + const rowsAcrossMachines = machines.flatMap((machine) => machine.rows) + const interruptedByUpdate = rowsAcrossMachines.some((row) => row.trigger === 'update') + // While a run is followed, each machine shows its run's rows, which keep a chat its host has + // already resumed and dropped from the list. + const runRows = runPanel?.rows + const tree = resumeTreeMachines( + machines, + resuming, + request.focus, + runRows ? (machine) => runRows[machines.indexOf(machine)] ?? machine.rows : undefined + ) + // Group boxes count only chats still listed, never a run's finished history. + const selectableIds = new Set( + machines.flatMap((machine) => + selectableResumeRows(machine).map((sessionId) => resumeRowKey(machine.identity, sessionId)) + ) + ) + const ticked = new Set( + machines.flatMap((machine) => + // Mid-run the ticks show what is running; this opening's own ticks may name chats left out. + ( + resuming.get(machine.machine) ?? + chosen.find((entry) => entry.machine === machine)?.ids ?? + [] + ).map((sessionId) => resumeRowKey(machine.identity, sessionId)) + ) + ) const selectAllLabel = translate( 'auto.components.NativeChatResumeOnRestartModal.selectAllLabel', 'Select all' @@ -310,16 +296,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { )} - {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?.description ?? resumeDialogBody(flat, interruptedByUpdate)} {runPanel?.summary} @@ -333,18 +310,22 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { )} // The sidebar's own surface, so its workspaces read here as they do there. className="min-h-0 overflow-y-auto scrollbar-sleek rounded-md bg-worktree-sidebar p-1.5 pb-2" - onKeyDown={moveInTree} + onKeyDown={moveInResumeTree} > - {/* Here, not in the groups: a list may hold one set of groups per machine. The tree's one - divider sets it apart from the nodes. */} + {/* Here, not in the tree: one Select all for every machine listed. The tree's one divider + sets it apart from the nodes. */}
- toggleResumeSelection(selectable, allSelection, toggleSelected) + setTicks( + allSelection.keys, + allSelection.state.selectedCount < allSelection.state.total + ) } checkboxLabel={translate( 'auto.components.NativeChatResumeOnRestartModal.selectAll', @@ -358,31 +339,51 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { {translate( 'auto.components.NativeChatResumeOnRestartModal.selectedCount', '{{value0}} of {{value1}} selected', - { value0: allSelection.selectedCount, value1: allSelection.total } + { + value0: allSelection.state.selectedCount, + value1: allSelection.state.total + } )}
+ {/* One tree over every machine: it shows a machine level whenever more than this + computer is listed, each machine named, busy and opened on its own. */} void actOnFailure(action, sessionId)} + onToggle={toggle} + rowKey={tree.rowKey} + busyFor={tree.busyFor} + failureFor={tree.failureFor} + onFailureAction={(action, key) => + resumeTreeRowAction( + tree, + key, + (machine, act, sessionId) => { + void actOnFailure(machine, act, sessionId) + }, + action + ) + } + originLabelFor={tree.originLabelFor} renderStatus={runPanel?.renderStatus} selectableIds={selectableIds} + defaultExpanded={tree.defaultExpanded} + machineSubtitle={tree.machineSubtitle} + listingOf={tree.listingOf} /> - {/* Two controls: one deletes the offer, one acts on it. Closing snoozes, so it needs none. */} + {/* Two controls: one deletes the offers, one acts on them. Closing snoozes, so it needs none. */} {/* Why order-last: the narrow footer stacks bottom-up, so this keeps the option above the actions. */}
- {rows.length === 0 ? ( + {rowsAcrossMachines.length === 0 ? ( + // Only a finished run is left to look at. ) : ( <> {/* Quiet, explicit cleanup of the durable records. */} - )} diff --git a/src/renderer/src/components/NativeChatResumeRunPanel.tsx b/src/renderer/src/components/NativeChatResumeRunPanel.tsx index 83746c6b0554..9d6f3e023d89 100644 --- a/src/renderer/src/components/NativeChatResumeRunPanel.tsx +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -4,27 +4,43 @@ 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 { + resumeRunView, + type ResumeRunFilter, + type ResumeRunRowStatus, + type ResumeRunView +} 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. + * The dialog's view of the runs it is following, one per machine: its title, the progress line and + * filters, each machine's rows in the order that matters, and each row's status icon. Null when no + * machine has a run, so the dialog is then exactly the offer it was. */ +/** One machine's part of the dialog: its run if it has one, its listed rows, and how its rows are + * keyed in the tree (two machines may hold the same session id). */ +export type ResumeRunPart = { + run: ResumeRun | null + rows: readonly ResumeCandidate[] + failureFor: (sessionId: string) => ResumeFailure | undefined + keyOf: (sessionId: string) => string +} + export type ResumeRunPanel = { title: React.ReactNode - /** Null once the run is over: the offer's own copy then describes what is left. */ + /** Null once every 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 + /** Each part's rows, in the parts' order. */ + rows: ResumeCandidate[][] + /** Keyed by the tree's row key. */ + renderStatus: (key: string, title: string) => React.ReactNode } -function runTitle(run: ResumeRun, total: number, resumed: number): React.ReactNode { - if (!resumeRunInFlight(run)) { +function runTitle(inFlight: boolean, total: number, resumed: number): React.ReactNode { + if (!inFlight) { if (total === 1) { return translate( 'auto.components.NativeChatResumeRunPanel.resumedTitleOne', @@ -52,15 +68,29 @@ function runTitle(run: ResumeRun, total: number, resumed: number): React.ReactNo ) } +type Counts = ResumeRunView['counts'] + +const NO_COUNTS: Counts = { all: 0, total: 0, done: 0, inProgress: 0, resumed: 0, attention: 0 } + +function addCounts(left: Counts, right: Counts): Counts { + return { + all: left.all + right.all, + total: left.total + right.total, + done: left.done + right.done, + inProgress: left.inProgress + right.inProgress, + resumed: left.resumed + right.resumed, + attention: left.attention + right.attention + } +} + +/** A machine with no run still lists its rows under the same filters, as nothing this run touched. */ +const NOT_RUN: ResumeRun = { startedAt: 0, entries: [], inFlight: false } + export function useResumeRunPanel({ - run, - rows, - failureFor, + parts, open }: { - run: ResumeRun | null - rows: readonly ResumeCandidate[] - failureFor: (sessionId: string) => ResumeFailure | undefined + parts: readonly ResumeRunPart[] open: boolean }): ResumeRunPanel | null { // Each opening starts on All, as the dialog's own ticks start from their defaults. @@ -71,24 +101,35 @@ export function useResumeRunPanel({ if (filter.open !== open) { setFilter({ value: 'all', open }) } - const inFlight = run !== null && resumeRunInFlight(run) + const followed = parts.flatMap((part) => (part.run ? [part.run] : [])) + const inFlightRuns = followed.filter(resumeRunInFlight) + const inFlight = inFlightRuns.length > 0 // 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) { + if (followed.length === 0) { return null } + const views = parts.map((part) => + resumeRunView(part.run ?? NOT_RUN, part.rows, part.failureFor, filter.value) + ) + const statusByKey = new Map() + views.forEach((view, index) => { + for (const [sessionId, status] of view.statusBySession) { + statusByKey.set(parts[index]!.keyOf(sessionId), status) + } + }) + const counts = views.reduce((total, view) => addCounts(total, view.counts), NO_COUNTS) + const phaseFor = (key: string) => { + const status = statusByKey.get(key) + return status?.kind === 'in-flight' ? status.phase : null + } + const inFlightKeys = [...statusByKey].flatMap(([key, status]) => + status.kind === 'in-flight' ? [key] : [] + ) + // The clock runs from the earliest resume still moving, across machines. + const startedAt = Math.min(...(inFlight ? inFlightRuns : followed).map((run) => run.startedAt)) return { - title: runTitle(run, view.counts.total, view.counts.resumed), + title: runTitle(inFlight, counts.total, counts.resumed), description: inFlight ? translate( 'auto.components.NativeChatResumeRunPanel.description', @@ -97,19 +138,19 @@ export function useResumeRunPanel({ : null, summary: ( setFilter({ value, open })} /> ), - rows: view.rows, - renderStatus: (sessionId, title) => { - const status = view.statusBySession.get(sessionId) + rows: views.map((view) => view.rows), + renderStatus: (key, title) => { + const status = statusByKey.get(key) return status ? ( - + ) : null } } diff --git a/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx b/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx index a6c506fd2925..e8d3d6b9c102 100644 --- a/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx +++ b/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx @@ -8,11 +8,11 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '../store' import { getDefaultSettings } from '../../../shared/constants' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { useNativeChatRestartResuming } from './native-chat-resume-on-restart-store' import { _resetNativeChatRestartOffer, - useNativeChatRestartOffer, - useNativeChatRestartResuming -} from './native-chat-resume-on-restart-store' + useNativeChatRestartOfferSources +} from './native-chat-restart-offer-triggers' import { _resetNativeChatLaunchResumeDecision, useNativeChatLaunchResumePending @@ -21,9 +21,10 @@ import { const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) globalThis.IS_REACT_ACT_ENVIRONMENT = true let root: Root @@ -36,16 +37,17 @@ const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ latestPrompt: `Prompt ${sessionId}`, recordedAt: 1_800_000_000_000, executionHostId: 'local', - workspaceKind: 'git-worktree' + workspaceKind: 'git-worktree', + origin: 'own' })) /** What Continue reads, rendered each time it changes. */ const seen: { pending: boolean; resuming: readonly string[] }[] = [] function Probe({ launch }: { launch: boolean }): null { - useNativeChatRestartOffer(launch) + useNativeChatRestartOfferSources(launch) seen.push({ pending: useNativeChatLaunchResumePending(), - resuming: useNativeChatRestartResuming() + resuming: useNativeChatRestartResuming().get('local') ?? [] }) return null } 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 b6d0b305fee4..249d96b033a4 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 @@ -2,7 +2,11 @@ import { isValidElement } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { toast } from 'sonner' import { beforeEach, expect, it, vi } from 'vitest' -import { announceRestartResults } from './native-chat-restart-action-notifications' +import { + announceRestartResults, + type RestartContinuationOutcome, + type RestartContinueResult +} from './native-chat-restart-action-notifications' import { lastToastShow } from './native-chat-resume-toast.test-support' vi.mock('sonner', () => ({ toast: vi.fn() })) @@ -13,20 +17,45 @@ const refusedBoth = [ { sessionId: 'b', outcome: 'refused' as const } ] -beforeEach(() => vi.mocked(toast).mockClear()) +function answered( + requested: string[], + results: RestartContinuationOutcome[] | undefined, + hostFailed: { sessionId: string; outcome: 'refused' | 'unconfirmed' }[] | undefined, + machine = 'local', + machineName?: string +): RestartContinueResult { + return { + machine, + requested, + kind: 'answered', + results, + hostFailed, + ...(machineName ? { machineName } : {}) + } +} function titles(): unknown[] { return vi.mocked(toast).mock.calls.map(([text]) => text) } +beforeEach(() => { + vi.mocked(toast).mockClear() + show.mockClear() +}) + +// Show opens the run's summary, so a resume that only succeeded offers it too. 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' } + answered( + ['a', 'b'], + [ + { sessionId: 'a', outcome: 'continued' }, + { sessionId: 'b', outcome: 'continued' } + ], + listed ? [] : undefined + ) ], - listed ? [] : undefined, show ) expect(titles()).toEqual(['Resumed 2 chats']) @@ -34,25 +63,30 @@ it.each([true, false])('offers Show for successful chats with a failure list: %s }) it('offers Show for chats a resume could not carry on', () => { - announceRestartResults(['a', 'b'], refusedBoth, refusedBoth, show) + announceRestartResults([answered(['a', 'b'], refusedBoth, refusedBoth)], show) expect(titles()).toEqual(['2 chats couldn’t be resumed']) const options = vi.mocked(toast).mock.calls[0]?.[1] expect(options).not.toHaveProperty('description') + expect(options).not.toHaveProperty('cancel') expect(options?.action).toEqual({ label: 'Show', onClick: expect.any(Function) }) lastToastShow()?.() - expect(show).toHaveBeenCalledTimes(1) + expect(show).toHaveBeenCalledWith('local') }) // One resume, one toast: the chats it resumed ride along under the ones it could not. it('reports a mixed resume in one toast', () => { announceRestartResults( - ['a', 'b', 'c'], [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'continued' }, - { sessionId: 'c', outcome: 'refused' } + answered( + ['a', 'b', 'c'], + [ + { sessionId: 'a', outcome: 'continued' }, + { sessionId: 'b', outcome: 'continued' }, + { sessionId: 'c', outcome: 'refused' } + ], + [{ sessionId: 'c', outcome: 'refused' }] + ) ], - [{ sessionId: 'c', outcome: 'refused' }], show ) expect(vi.mocked(toast).mock.calls).toEqual([ @@ -62,15 +96,19 @@ it('reports a mixed resume in one toast', () => { it('puts each extra count on its own line when a resume had all three outcomes', () => { announceRestartResults( - ['a', 'b', 'c'], [ - { sessionId: 'a', outcome: 'continued' }, - { sessionId: 'b', outcome: 'refused' }, - { sessionId: 'c', outcome: 'unknown' } - ], - [ - { sessionId: 'b', outcome: 'refused' }, - { sessionId: 'c', outcome: 'unconfirmed' } + answered( + ['a', 'b', 'c'], + [ + { sessionId: 'a', outcome: 'continued' }, + { sessionId: 'b', outcome: 'refused' }, + { sessionId: 'c', outcome: 'unknown' } + ], + [ + { sessionId: 'b', outcome: 'refused' }, + { sessionId: 'c', outcome: 'unconfirmed' } + ] + ) ], show ) @@ -90,32 +128,35 @@ it('puts each extra count on its own line when a resume had all three outcomes', // `b` finished on its own, or the user already answered it: the host no longer lists it, so the // notice must not count a failure the list it opens cannot show. it('counts only the requested chats the host still lists as failed', () => { - announceRestartResults(['a', 'b'], refusedBoth, [{ sessionId: 'a', outcome: 'refused' }], show) + announceRestartResults( + [answered(['a', 'b'], refusedBoth, [{ sessionId: 'a', outcome: 'refused' }])], + show + ) expect(titles()).toEqual(['1 chat couldn’t be resumed']) }) it('says nothing when the host lists none of them as failed', () => { - announceRestartResults(['a', 'b'], refusedBoth, [], show) + announceRestartResults([answered(['a', 'b'], refusedBoth, [])], show) expect(toast).not.toHaveBeenCalled() }) // A request that never reached the host leaves no read to narrow by, so every chat it named failed. it('counts every chat not carried on when no failure list was read, with nothing to Show', () => { - announceRestartResults(['a', 'b'], [], undefined, show) + announceRestartResults([answered(['a', 'b'], [], undefined)], show) expect(titles()).toEqual(['2 chats couldn’t be resumed']) expect(vi.mocked(toast).mock.calls[0]?.[1]).not.toHaveProperty('action') }) // An answer without outcomes may still have sent the message. it('counts every chat as unconfirmed when the answer carried no outcomes', () => { - announceRestartResults(['a', 'b'], undefined, undefined, show) + announceRestartResults([answered(['a', 'b'], undefined, undefined)], show) expect(titles()).toEqual(['Couldn’t confirm 2 chats were resumed']) }) // The host retires an unconfirmed send once the agent is seen carrying on it; the resume must still // report the chat, and as resumed, not as a failure the list can no longer show. it('counts an unconfirmed chat the host no longer lists as resumed', () => { - announceRestartResults(['a'], [{ sessionId: 'a', outcome: 'unknown' }], [], show) + announceRestartResults([answered(['a'], [{ sessionId: 'a', outcome: 'unknown' }], [])], show) expect(titles()).toEqual(['Resumed 1 chat']) }) @@ -123,11 +164,15 @@ it('counts an unconfirmed chat the host no longer lists as resumed', () => { // `b` reattached with no continuation row: only the host's filed outcome says it is unconfirmed. it('counts a chat the host filed as unconfirmed on its own line, as the list does', () => { announceRestartResults( - ['a', 'b'], - [{ sessionId: 'a', outcome: 'refused' }], [ - { sessionId: 'a', outcome: 'refused' }, - { sessionId: 'b', outcome: 'unconfirmed' } + answered( + ['a', 'b'], + [{ sessionId: 'a', outcome: 'refused' }], + [ + { sessionId: 'a', outcome: 'refused' }, + { sessionId: 'b', outcome: 'unconfirmed' } + ] + ) ], show ) @@ -141,12 +186,16 @@ it('counts a chat the host filed as unconfirmed on its own line, as the list doe it('leads with the unconfirmed count when nothing was refused', () => { announceRestartResults( - ['a', 'b'], [ - { sessionId: 'a', outcome: 'unknown' }, - { sessionId: 'b', outcome: 'pending' } + answered( + ['a', 'b'], + [ + { sessionId: 'a', outcome: 'unknown' }, + { sessionId: 'b', outcome: 'pending' } + ], + undefined + ) ], - undefined, show ) expect(vi.mocked(toast).mock.calls).toEqual([ @@ -156,3 +205,113 @@ it('leads with the unconfirmed count when nothing was refused', () => { ] ]) }) + +// One resume, one toast, however many machines it reached. +it('reports one resume across machines in a single toast', () => { + announceRestartResults( + [ + answered(['l1', 'l2'], [{ sessionId: 'l1', outcome: 'continued' }], []), + answered( + ['s1', 's2'], + [{ sessionId: 's1', outcome: 'continued' }], + [{ sessionId: 's2', outcome: 'refused' }], + 'environment:studio', + 'studio-mac' + ), + answered( + ['b1'], + [{ sessionId: 'b1', outcome: 'unknown' }], + [{ sessionId: 'b1', outcome: 'unconfirmed' }], + 'environment:build', + 'build-box' + ) + ], + show + ) + expect(toast).toHaveBeenCalledTimes(1) + const [title, options] = vi.mocked(toast).mock.calls[0]! + expect(title).toBe('1 chat couldn’t be resumed') + const description = options?.description + if (!isValidElement(description)) { + throw new Error('Expected one line per count') + } + expect(renderToStaticMarkup(description)).toBe( + 'Couldn’t confirm 1 other chat was resumed' + + 'Resumed 2 chats' + ) +}) + +it('names the server when its chats alone are counted', () => { + announceRestartResults( + [ + answered( + ['s1'], + [{ sessionId: 's1', outcome: 'continued' }], + [], + 'environment:studio', + 'studio-mac' + ) + ], + show + ) + expect(titles()).toEqual(['Resumed 1 chat on studio-mac']) +}) + +it('opens the dialog on the one failing server', () => { + announceRestartResults( + [ + answered( + ['s1', 's2'], + [{ sessionId: 's1', outcome: 'unknown' }], + [ + { sessionId: 's1', outcome: 'unconfirmed' }, + { sessionId: 's2', outcome: 'unconfirmed' } + ], + 'environment:studio', + 'studio-mac' + ) + ], + show + ) + expect(titles()).toEqual(['Couldn’t confirm 2 chats on studio-mac were resumed']) + lastToastShow()?.() + expect(show).toHaveBeenCalledWith('environment:studio') +}) + +it('counts failures on several machines together and opens the dialog on none in particular', () => { + announceRestartResults( + [ + answered(['a'], [], [{ sessionId: 'a', outcome: 'refused' }]), + answered( + ['s1'], + [], + [{ sessionId: 's1', outcome: 'refused' }], + 'environment:studio', + 'studio-mac' + ) + ], + show + ) + expect(titles()).toEqual(['2 chats couldn’t be resumed']) + expect(vi.mocked(toast).mock.calls[0]?.[1]).not.toHaveProperty('cancel') + lastToastShow()?.() + expect(show).toHaveBeenCalledWith(null) +}) + +// The server was re-paired under the listing: nothing was sent, and nothing it lists is this +// pairing's to show. +it('counts a resume refused before it left as not resumed, with nothing to Show', () => { + announceRestartResults( + [ + { + machine: 'environment:studio', + machineName: 'studio-mac', + requested: ['s1'], + kind: 'not-sent' + } + ], + show + ) + expect(titles()).toEqual(['1 chat on studio-mac couldn’t be resumed']) + expect(vi.mocked(toast).mock.calls[0]?.[1]).not.toHaveProperty('action') +}) 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 5b6c3356124d..606c498c1bc2 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.ts @@ -2,10 +2,12 @@ import { createElement, Fragment, type ReactNode } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { RestartMachineKey } from './native-chat-restart-machines' /** - * The one toast a resume raises, clicked or automatic at launch: what it did across chats that are - * mostly off-screen. Each chat's own note stays the record of what happened to it. + * The one toast a resume raises, clicked or automatic: what it did across chats that are mostly + * off-screen, on every machine it reached. Each chat's own note stays the record of what happened + * to it. */ /** One `continued` row as the host reports it. */ @@ -14,7 +16,45 @@ export type RestartContinuationOutcome = { outcome: 'continued' | 'pending' | 'unknown' | 'refused' } -function continuedCountText(count: number): string { +/** How one machine's part of a resume ended: the host's answer (or, when the request was lost, the + * list re-read after it), a paired request whose answer and re-read were both lost, or a request + * refused before it was sent (the server was re-paired). */ +export type RestartContinueResult = { + machine: RestartMachineKey + /** Names a paired server; this computer's own chats need no "where". */ + machineName?: string + requested: readonly string[] +} & ( + | { + kind: 'answered' + /** Undefined for an answer without outcomes, which may still have sent the message. */ + results: readonly RestartContinuationOutcome[] | undefined + /** The failure list after the action, as the dialog shows it; undefined when none was read. */ + hostFailed: readonly Pick[] | undefined + } + | { + kind: 'unconfirmed' + /** Whether the machine still has a listing for Show to open. */ + listed: boolean + } + | { kind: 'not-sent' } +) + +/** `machineName` names a paired server; this computer's own chats need no "where". */ +function continuedText(count: number, machineName: string | undefined): string { + if (machineName !== undefined) { + return count === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.continuedOneOnMachine', + 'Resumed 1 chat on {{value0}}', + { value0: machineName } + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.continuedManyOnMachine', + 'Resumed {{value0}} chats on {{value1}}', + { value0: count, value1: machineName } + ) + } return count === 1 ? translate('auto.components.NativeChatResumeOnRestartModal.continuedOne', 'Resumed 1 chat') : translate( @@ -24,7 +64,20 @@ function continuedCountText(count: number): string { ) } -function refusedCountText(count: number): string { +function refusedCountText(count: number, machineName: string | undefined): string { + if (machineName !== undefined) { + return count === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.notContinuedOneOnMachine', + '1 chat on {{value0}} couldn’t be resumed', + { value0: machineName } + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.notContinuedManyOnMachine', + '{{value0}} chats on {{value1}} couldn’t be resumed', + { value0: count, value1: machineName } + ) + } return count === 1 ? translate( 'auto.components.NativeChatResumeOnRestartModal.notContinuedOne', @@ -37,7 +90,20 @@ function refusedCountText(count: number): string { ) } -function unconfirmedCountText(count: number): string { +function unconfirmedCountText(count: number, machineName: string | undefined): string { + if (machineName !== undefined) { + return count === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.notConfirmedOneOnMachine', + 'Couldn’t confirm 1 chat on {{value0}} was resumed', + { value0: machineName } + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.notConfirmedManyOnMachine', + 'Couldn’t confirm {{value0}} chats on {{value1}} were resumed', + { value0: count, value1: machineName } + ) + } return count === 1 ? translate( 'auto.components.NativeChatResumeOnRestartModal.notConfirmedOne', @@ -75,39 +141,6 @@ function descriptionFrom(lines: readonly string[]): ReactNode { ) } -/** 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), action) - } - return - } - const title = refused > 0 ? refusedCountText(refused) : unconfirmedCountText(unconfirmed) - const lines = [ - ...(refused > 0 && unconfirmed > 0 ? [otherUnconfirmedCountText(unconfirmed)] : []), - ...(continued > 0 ? [continuedCountText(continued)] : []) - ] - toast(title, { - ...(lines.length === 0 ? {} : { description: descriptionFrom(lines) }), - ...action - }) -} - /** Which of the requested chats the host did not carry on: refused, unconfirmed, or — since * eligibility can change after listing — omitted from the answer altogether. */ export function restartChatsNotContinued( @@ -137,15 +170,21 @@ export function restartContinuationHistory( ) } -export function announceRestartResults( +type MachineTally = { + machine: RestartMachineKey + machineName: string | undefined + continued: number + refused: number + unconfirmed: number + /** Whether a failure list was read, so Show has rows to open. */ + listed: boolean +} + +function tallyAnswer( requested: readonly string[], - /** Undefined for an answer without outcomes, which may still have sent the message. */ reportedResults: readonly RestartContinuationOutcome[] | undefined, - /** The failure list after the action, as the dialog shows it; undefined when none was read. */ - hostFailed: readonly Pick[] | undefined, - /** Opens the dialog over a fresh read; never rejects. */ - show: () => Promise -): void { + hostFailed: readonly Pick[] | undefined +): Omit { const results = restartContinuationHistory(requested, reportedResults, hostFailed) const notContinued = restartChatsNotContinued(requested, results) const failed = new Map(hostFailed?.map((failure) => [failure.sessionId, failure.outcome])) @@ -163,11 +202,83 @@ export function announceRestartResults( (failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) === 'unconfirmed' const unconfirmedCount = reported.filter(unconfirmed).length - const continuedCount = new Set(requested).size - notContinued.length - announceResume( - continuedCount, - reported.length - unconfirmedCount, - unconfirmedCount, - hostFailed === undefined && continuedCount === 0 ? undefined : show - ) + return { + continued: new Set(requested).size - notContinued.length, + refused: reported.length - unconfirmedCount, + unconfirmed: unconfirmedCount, + listed: hostFailed !== undefined + } +} + +function tally(result: RestartContinueResult): MachineTally { + const base = { machine: result.machine, machineName: result.machineName } + if (result.kind === 'unconfirmed') { + const unconfirmed = new Set(result.requested).size + return { ...base, continued: 0, refused: 0, unconfirmed, listed: result.listed } + } + if (result.kind === 'not-sent') { + // Nothing reached the host and the pairing it was listed under is gone: no list to show. + const refused = new Set(result.requested).size + return { ...base, continued: 0, refused, unconfirmed: 0, listed: false } + } + return { ...base, ...tallyAnswer(result.requested, result.results, result.hostFailed) } +} + +/** One machine's name when it alone holds the counted chats; otherwise the count needs no "where" + * and the dialog it opens lists each machine. */ +function soleMachineName(tallies: readonly MachineTally[]): string | undefined { + return tallies.length === 1 ? tallies[0]!.machineName : undefined +} + +function sum(tallies: readonly MachineTally[], count: (entry: MachineTally) => number): number { + return tallies.reduce((total, entry) => total + count(entry), 0) +} + +/** + * One toast per resume, however many machines it reached: what went wrong leads, and the rest of + * the outcome rides beneath it. No chat 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. Show opens the run's summary, so it is offered whenever there is a result + * to look at: a chat resumed, or a failure list read. + * + * `show` opens the dialog over a fresh read, on the one machine it concerns or on none in particular. + */ +export function announceRestartResults( + results: readonly RestartContinueResult[], + show: (machine: RestartMachineKey | null) => void +): void { + const tallies = results.map(tally) + const failing = tallies.filter((entry) => entry.refused + entry.unconfirmed > 0) + const continuing = tallies.filter((entry) => entry.continued > 0) + const refused = sum(failing, (entry) => entry.refused) + const unconfirmed = sum(failing, (entry) => entry.unconfirmed) + const continued = sum(continuing, (entry) => entry.continued) + const showable = tallies.filter((entry) => entry.listed || entry.continued > 0) + const action = + showable.length === 0 + ? {} + : { + action: { + label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'), + onClick: () => show(showable.length === 1 ? showable[0]!.machine : null) + } + } + if (failing.length === 0) { + if (continued > 0) { + toast(continuedText(continued, soleMachineName(continuing)), action) + } + return + } + const title = + refused > 0 + ? refusedCountText(refused, soleMachineName(failing)) + : unconfirmedCountText(unconfirmed, soleMachineName(failing)) + const lines = [ + ...(refused > 0 && unconfirmed > 0 ? [otherUnconfirmedCountText(unconfirmed)] : []), + ...(continued > 0 ? [continuedText(continued, soleMachineName(continuing))] : []) + ] + toast(title, { + ...(lines.length === 0 ? {} : { description: descriptionFrom(lines) }), + ...action + }) } diff --git a/src/renderer/src/components/native-chat-restart-decided.ts b/src/renderer/src/components/native-chat-restart-decided.ts new file mode 100644 index 000000000000..ab86279193f7 --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-decided.ts @@ -0,0 +1,113 @@ +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' + +/** + * The interruptions on each paired server this desktop has already decided about: announced in a + * toast, resumed without asking, or shown in the dialog. Offers are born only at a server teardown, + * so a chat and the moment it was cut off name one interruption exactly, and a reconnect, a reload + * or a reopened window decides nothing twice. + * + * This run's record in memory is the authority; the window's storage only seeds it at startup and + * carries it to the next launch. It is presentation only — the server holds the offers — so a + * storage that refuses writes costs at most one repeated notice per launch, never a second + * decision within a run. Every successful read prunes it to what the server still offers, and a + * removed or re-paired server's entry goes, so it can never grow. + */ + +const STORAGE_KEY = 'orca.nativeChatRestartDecided.v1' + +type DecidedByEnvironment = Record + +export function restartInterruptionKey( + candidate: Pick +): string { + return `${candidate.sessionId}\u0000${candidate.recordedAt}` +} + +function read(): DecidedByEnvironment { + try { + const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}') + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {} + } + return Object.fromEntries( + Object.entries(parsed).flatMap(([environmentId, keys]) => + Array.isArray(keys) + ? [[environmentId, keys.filter((key): key is string => typeof key === 'string')]] + : [] + ) + ) + } catch { + return {} + } +} + +function write(next: DecidedByEnvironment): void { + try { + if (Object.keys(next).length === 0) { + window.localStorage.removeItem(STORAGE_KEY) + } else { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + } + } catch { + // Storage refused: the next read may announce again, which is all this costs. + } +} + +/** This run's record per server, seeded from storage on first use. */ +const memory = new Map>() + +function keysFor(environmentId: string): Set { + let keys = memory.get(environmentId) + if (!keys) { + keys = new Set(read()[environmentId] ?? []) + memory.set(environmentId, keys) + } + return keys +} + +function persist(environmentId: string, keys: ReadonlySet): void { + const all = read() + if (keys.size === 0) { + delete all[environmentId] + } else { + all[environmentId] = [...keys] + } + write(all) +} + +export function decidedRestartInterruptions(environmentId: string): ReadonlySet { + return new Set(keysFor(environmentId)) +} + +/** Marks these decided, and drops every key the server no longer offers. */ +export function settleRestartInterruptions( + environmentId: string, + offered: readonly string[], + decided: readonly string[] +): void { + const stillOffered = new Set(offered) + const keys = new Set( + [...keysFor(environmentId), ...decided].filter((key) => stillOffered.has(key)) + ) + memory.set(environmentId, keys) + persist(environmentId, keys) +} + +/** Every server with a record, so a removed one's can be forgotten. */ +export function restartDecidedEnvironments(): string[] { + const remembered = [...memory].flatMap(([environmentId, keys]) => + keys.size > 0 ? [environmentId] : [] + ) + return [...new Set([...Object.keys(read()), ...remembered])] +} + +export function forgetRestartInterruptions(environmentId: string): void { + // Empty rather than absent, so storage that refused the delete cannot seed the old keys back. + memory.set(environmentId, new Set()) + persist(environmentId, new Set()) +} + +/** @internal - a new window starts with only what storage carried over. */ +export function _resetRestartDecidedMemory(): void { + memory.clear() +} diff --git a/src/renderer/src/components/native-chat-restart-machine-fence.ts b/src/renderer/src/components/native-chat-restart-machine-fence.ts new file mode 100644 index 000000000000..0d1ef91612cf --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-machine-fence.ts @@ -0,0 +1,35 @@ +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { StructuredAgentSessionCallFence } from '@/runtime/structured-agent-session-client' + +/** + * The pairing a paired server's offer was read under. A server re-paired under the same id may be a + * different machine, so every request on that offer is sent with it and refused before it leaves + * if the pairing moved. A restart of the same server needs no fence: the host re-derives which + * named chats it still offers. This computer has none. + */ +export type RestartMachineFence = Readonly<{ pairingRevision?: number }> + +export function currentRestartMachineFence(target: RuntimeClientTarget): RestartMachineFence { + const pairingRevision = + target.kind === 'local' ? undefined : getRuntimeEnvironmentRevision(target.environmentId) + return pairingRevision === undefined ? {} : { pairingRevision } +} + +export function sameRestartMachineFence( + left: RestartMachineFence | undefined, + right: RestartMachineFence | undefined +): boolean { + return left?.pairingRevision === right?.pairingRevision +} + +/** The call options a fenced request carries; none for this computer, which cannot be re-paired. */ +export function restartMachineCallFence( + target: RuntimeClientTarget, + fence: RestartMachineFence +): StructuredAgentSessionCallFence | undefined { + if (target.kind === 'local' || fence.pairingRevision === undefined) { + return undefined + } + return { expectedEnvironmentPairingRevision: fence.pairingRevision } +} diff --git a/src/renderer/src/components/native-chat-restart-machine-name.ts b/src/renderer/src/components/native-chat-restart-machine-name.ts new file mode 100644 index 000000000000..7b53dd6bf7bd --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-machine-name.ts @@ -0,0 +1,81 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' +import { buildSidebarHostOptions } from './sidebar/sidebar-host-options' +import { useAppStore } from '../store' +import type { AppState } from '../store/types' +import { + restartMachineExecutionHostId, + restartMachineTarget, + type RestartMachineKey +} from './native-chat-restart-machines' + +type HostLabelSources = Pick< + AppState, + | 'repos' + | 'sshTargetLabels' + | 'sshConnectionStates' + | 'settings' + | 'runtimeEnvironments' + | 'runtimeStatusByEnvironmentId' +> + +let cached: { sources: HostLabelSources; labels: ReadonlyMap } | null = + null + +/** The sidebar's host names, built as `useSidebarHostScopeOptions` builds them; rebuilt only when an + * input changes, since store selectors call this on every store update. */ +function sidebarHostLabels(state: HostLabelSources): ReadonlyMap { + const previous = cached?.sources + if ( + cached && + previous?.repos === state.repos && + previous.sshTargetLabels === state.sshTargetLabels && + previous.sshConnectionStates === state.sshConnectionStates && + previous.settings === state.settings && + previous.runtimeEnvironments === state.runtimeEnvironments && + previous.runtimeStatusByEnvironmentId === state.runtimeStatusByEnvironmentId + ) { + return cached.labels + } + const hosts = buildSidebarHostOptions({ + repos: state.repos, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + settings: state.settings, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(state.settings) + }) + const labels = new Map(hosts.map((host) => [host.id, host.label])) + cached = { + sources: { + repos: state.repos, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + settings: state.settings, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId + }, + labels + } + return labels +} + +/** What the user calls a machine: the sidebar's own name for its host (a paired server's name, this + * computer's label, either renamed in host settings), so a machine row and a workspace's host chip + * never spell one host two ways. */ +export function restartMachineNameFromState( + state: HostLabelSources, + machine: RestartMachineKey +): string { + const target = restartMachineTarget(machine) + const hostId = restartMachineExecutionHostId(target) + return ( + sidebarHostLabels(state).get(hostId) ?? + (target.kind === 'environment' ? target.environmentId : hostId) + ) +} + +export function restartMachineName(machine: RestartMachineKey): string { + return restartMachineNameFromState(useAppStore.getState(), machine) +} diff --git a/src/renderer/src/components/native-chat-restart-machines.ts b/src/renderer/src/components/native-chat-restart-machines.ts new file mode 100644 index 000000000000..3a5177c52ebd --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-machines.ts @@ -0,0 +1,54 @@ +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { structuredAgentSessionStatusFeedKey } from '@/runtime/structured-agent-session-status-feed' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' + +/** + * Which machine a restart offer belongs to: this computer, or one paired server. + * + * Keyed like the structured status feed (`local` | `environment:`), so an offer and the feed + * that watches its chats name the same machine the same way. + */ +export type RestartMachineKey = string + +export const LOCAL_RESTART_MACHINE: RestartMachineKey = 'local' + +export function restartMachineKey(target: RuntimeClientTarget): RestartMachineKey { + return structuredAgentSessionStatusFeedKey(target) +} + +const ENVIRONMENT_PREFIX = 'environment:' + +export function restartMachineTarget(machine: RestartMachineKey): RuntimeClientTarget { + return machine.startsWith(ENVIRONMENT_PREFIX) + ? { kind: 'environment', environmentId: machine.slice(ENVIRONMENT_PREFIX.length) } + : { kind: 'local' } +} + +/** + * A paired server's rows in this desktop's terms. + * + * The server builds its host as `local`, so every row it lists names ITS machine as `local`. Here + * that is the server's runtime host, which is the id this desktop files its workspaces under. + */ +export function projectRestartMachineRows( + target: RuntimeClientTarget, + rows: readonly Row[] +): Row[] { + if (target.kind === 'local') { + return [...rows] + } + const executionHostId = restartMachineExecutionHostId(target) + return rows.map((row) => ({ ...row, executionHostId })) +} + +/** The host this desktop files a machine's workspaces under. */ +export function restartMachineExecutionHostId(target: RuntimeClientTarget): ExecutionHostId { + return target.kind === 'local' + ? LOCAL_EXECUTION_HOST_ID + : toRuntimeExecutionHostId(target.environmentId) +} diff --git a/src/renderer/src/components/native-chat-restart-offer-actions.ts b/src/renderer/src/components/native-chat-restart-offer-actions.ts new file mode 100644 index 000000000000..482f58554b03 --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-actions.ts @@ -0,0 +1,296 @@ +import { + callStructuredAgentSession, + pairedRestartOffersSupport +} from '@/runtime/structured-agent-session-client' +import { hasRuntimeRpcErrorCode } from '@/runtime/runtime-rpc-client' +import { + announceRestartResults, + restartContinuationHistory, + type RestartContinuationOutcome, + type RestartContinueResult +} from './native-chat-restart-action-notifications' +import { + projectRestartMachineRows, + restartMachineTarget, + type RestartMachineKey +} from './native-chat-restart-machines' +import { restartMachineName } from './native-chat-restart-machine-name' +import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' +import { + currentRestartMachineFence, + restartMachineCallFence, + sameRestartMachineFence, + type RestartMachineFence +} from './native-chat-restart-machine-fence' +import { failedFrom, type HostOfferPayload } from './native-chat-restart-offer-payload' +import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + beginNativeChatRestartAction, + getNativeChatRestartOffers, + publishNativeChatRestartAnswer, + readNativeChatRestartMachine, + restartTicketPairingCurrent, + type NativeChatRestartMachineOffer, + type RestartMachineRead, + type RestartMachineTicket +} from './native-chat-resume-on-restart-store' +import { forgetUnsentResumes, markUnsentResumes } from './native-chat-resume-unsent-requests' +import { reopenNativeChatRestartOffer } from './native-chat-restart-offer-reopen' +import type { RestartRunAnswer } from './native-chat-restart-runs' + +/** + * Acting on one machine's offer: continue the chats, or turn them down for good. + * + * Every action names the chats it means and is sent under the pairing the offer was listed from: + * a server re-paired since is refused before anything leaves. A restart of the same server needs no + * fence — its host re-derives which named chats it still offers, and a dismissal names each chat + * with the interruption it listed, so a newer one is never deleted by an older listing. + */ + +/** The machine's listed offer, if it is still the one the caller saw. */ +function currentOffer( + machine: RestartMachineKey, + expected: RestartMachineFence | undefined +): NativeChatRestartMachineOffer | 'gone' | 'moved' { + // A caller holding an older listing (a toast) acts only while the machine is paired the same way. + if ( + expected !== undefined && + !sameRestartMachineFence(expected, currentRestartMachineFence(restartMachineTarget(machine))) + ) { + return 'moved' + } + const offer = getNativeChatRestartOffers().get(machine) + if (!offer) { + return 'gone' + } + return expected !== undefined && !sameRestartMachineFence(expected, offer.fence) ? 'moved' : offer +} + +/** The server was re-paired under the listing, so the call was refused before anything was sent. */ +function refusedAsStale(error: unknown): boolean { + return hasRuntimeRpcErrorCode(error, 'runtime_environment_changed') +} + +/** A later request published first: this answer is not the newest, so ask the host again. */ +async function publishOrReread( + ticket: RestartMachineTicket, + payload: HostOfferPayload +): Promise { + const published = + Array.isArray(payload.sessions) && + publishNativeChatRestartAnswer(ticket, payload.sessions, failedFrom(payload)) + if (!published && restartTicketPairingCurrent(ticket)) { + await readNativeChatRestartMachine(ticket.target) + } +} + +/** The toast's Show: the dialog over a fresh read of the failing machine, or of every machine. */ +function showResult(machine: RestartMachineKey | null): void { + void reopenNativeChatRestartOffer(machine ? [machine] : undefined) +} + +type ContinueReply = HostOfferPayload & { + continued?: RestartContinuationOutcome[] + /** Named chats the host left out of this action; an older host never sends it. */ + skipped?: string[] +} +type RestartFailureRow = Pick + +export type RestartContinueRequest = { + machine: RestartMachineKey + sessionIds: readonly string[] +} + +/** A lost request's chats as the re-read shows them: each one still offered was marked failed. + * A paired server may have taken the request and be continuing chats it no longer lists, so those + * are unconfirmed rather than dropped; a local request that failed reached nothing. */ +function lostRequestFailures( + read: RestartMachineRead, + lost: { machine: RestartMachineKey; requested: readonly string[] } +): RestartFailureRow[] | undefined { + if (read.kind !== 'answered') { + return undefined + } + const offer = getNativeChatRestartOffers().get(lost.machine) + const failed = offer?.failed ?? [] + if (restartMachineTarget(lost.machine).kind === 'local') { + return [...failed] + } + const listed = new Set([...(offer?.candidates ?? []), ...failed].map((row) => row.sessionId)) + return [ + ...failed, + ...lost.requested + .filter((sessionId) => !listed.has(sessionId)) + .map((sessionId) => ({ sessionId, outcome: 'unconfirmed' as const })) + ] +} + +async function continueOnMachine( + request: RestartContinueRequest, + expected: RestartMachineFence | undefined +): Promise { + const { machine, sessionIds } = request + const target = restartMachineTarget(machine) + const base = { + machine, + requested: sessionIds, + ...(target.kind === 'local' ? {} : { machineName: restartMachineName(machine) }) + } + const offer = currentOffer(machine, expected) + if (offer === 'gone') { + return { ...base, kind: 'answered', results: [], hostFailed: [] } + } + // Re-paired since the listing: refused before anything is sent or followed. + if ( + offer === 'moved' || + !sameRestartMachineFence(currentRestartMachineFence(target), offer.fence) + ) { + return { ...base, kind: 'not-sent' } + } + forgetUnsentResumes(machine, sessionIds) + // The run follows each named chat's progress until the host answers. + const { ticket, settle } = beginNativeChatRestartAction(target, { listing: offer, sessionIds }) + let answer: RestartRunAnswer | undefined + if (target.kind === 'local') { + // `resuming` now names this computer's chats, so the launch's one resume decision is made. + markNativeChatLaunchResumeDecided() + } + try { + const params = { sessionIds: [...sessionIds] } + const callFence = restartMachineCallFence(target, offer.fence) + const result = await (callFence + ? callStructuredAgentSession( + target, + 'agentSession.restartContinue', + params, + callFence + ) + : callStructuredAgentSession(target, 'agentSession.restartContinue', params)) + await publishOrReread(ticket, result) + const skipped = new Set(Array.isArray(result.skipped) ? result.skipped : []) + // A chat another action was already resuming is that action's to report. + const actedOn = sessionIds.filter((sessionId) => !skipped.has(sessionId)) + // A shape this side did not expect counts as unconfirmed: the message may well have gone out. + const results = Array.isArray(result.continued) ? result.continued : undefined + const hostFailed = Array.isArray(result.failed) + ? projectRestartMachineRows(target, failedFrom(result)) + : undefined + answer = { skipped, continued: restartContinuationHistory(actedOn, results, hostFailed) } + return { ...base, requested: actedOn, kind: 'answered', results, hostFailed } + } catch (error) { + if (refusedAsStale(error)) { + await readNativeChatRestartMachine(target) + return { ...base, kind: 'not-sent' } + } + // 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 or its answer was lost', error) + markUnsentResumes(machine, sessionIds, Date.now()) + const read = await readNativeChatRestartMachine(target) + if (read.kind !== 'answered' && target.kind === 'environment') { + // Neither the answer nor a re-read came back: the server may be continuing every chat. + return { ...base, kind: 'unconfirmed', listed: getNativeChatRestartOffers().has(machine) } + } + return { ...base, kind: 'answered', results: [], hostFailed: lostRequestFailures(read, base) } + } finally { + settle(answer) + } +} + +/** + * Reattach the named chats on each machine, ask each agent to carry on, then replace each machine's + * offer with its host's authoritative remaining list. + * + * Ends in one toast saying what it did across every machine it reached, whether a click, a toast or + * an opted-in automatic resume started it; each chat's note stays the record. `sessionIds` always + * names the chats: an action never continues one this side did not choose, which on a shared server + * could be another device's. `expected` is the listing a caller saw (a toast), which acts only + * while the machine is still paired that way. + * + * Never rejects; a lost answer is followed by a re-read, never a retry. The chats a lost request + * named that the host still offers show as failed, with Retry. A request refused because the + * server was re-paired counts as not resumed. + */ +export async function continueNativeChatRestartOffers( + requests: readonly RestartContinueRequest[], + options: { expected?: RestartMachineFence } = {} +): Promise { + const results = await Promise.all( + requests + .filter((request) => request.sessionIds.length > 0) + .map((request) => continueOnMachine(request, options.expected)) + ) + announceRestartResults(results, showResult) +} + +/** The offers a named dismissal lists back to the host, each with the interruption it showed. */ +function listedOffers(offer: NativeChatRestartMachineOffer, sessionIds: readonly string[]) { + const named = new Set(sessionIds) + return [...offer.candidates, ...offer.failed] + .filter((row) => named.has(row.sessionId)) + .map((row) => ({ sessionId: row.sessionId, recordedAt: row.recordedAt })) +} + +/** + * Turning offers down for good, which explicitly deletes the durable records. + * + * This computer may dismiss everything it listed by naming nothing. A paired server is always told + * which chats: unnamed, its dismissal would delete every device's offers. A server is only ever + * listed when it advertises named dismissal, so one that does not is never sent one. + * + * Says nothing either way: the dialog row going away is the answer. A refused or lost dismissal + * leaves the durable record untouched and re-reads the list, which puts it back. + */ +export async function dismissNativeChatRestartOffer( + machine: RestartMachineKey, + sessionIds?: readonly string[], + expected?: RestartMachineFence +): Promise { + const offer = currentOffer(machine, expected) + if (offer === 'gone') { + return + } + const target = restartMachineTarget(machine) + if (offer === 'moved') { + void readNativeChatRestartMachine(target) + return + } + const named = + target.kind === 'environment' + ? (sessionIds ?? [...offer.candidates, ...offer.failed].map((row) => row.sessionId)) + : sessionIds + if (named?.length === 0) { + return + } + if (target.kind === 'environment') { + // Checked again rather than assumed from the listing: never an unnamed or unknown dismissal. + const support = await pairedRestartOffersSupport(target.environmentId) + if (support !== 'supported') { + void readNativeChatRestartMachine(target) + return + } + } + const { ticket, settle } = beginNativeChatRestartAction(target) + try { + const params = named ? { sessionIds: [...named], offers: listedOffers(offer, named) } : {} + const callFence = restartMachineCallFence(target, offer.fence) + const result = await (callFence + ? callStructuredAgentSession( + target, + 'agentSession.restartResumableDismiss', + params, + callFence + ) + : callStructuredAgentSession( + target, + 'agentSession.restartResumableDismiss', + params + )) + // Only a dismissal the host took ends the marks; a failed one leaves the chats shown as failed. + forgetUnsentResumes(machine, named) + await publishOrReread(ticket, result) + } catch { + await readNativeChatRestartMachine(target) + } finally { + settle() + } +} diff --git a/src/renderer/src/components/native-chat-restart-offer-activity-watch.ts b/src/renderer/src/components/native-chat-restart-offer-activity-watch.ts new file mode 100644 index 000000000000..642919283411 --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-activity-watch.ts @@ -0,0 +1,57 @@ +import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { restartMachineTarget, type RestartMachineKey } from './native-chat-restart-machines' +import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch' + +/** One offered-chat watch per machine, on that machine's own status feed. */ + +type WatchedOffer = { + candidates: readonly ResumeCandidate[] + failed: readonly ResumeCandidate[] + listedAt: number +} + +type Watch = { offer: WatchedOffer; watch: ReturnType } + +const watches = new Map() + +function releaseWatch(machine: RestartMachineKey): void { + watches.get(machine)?.watch.release() + watches.delete(machine) +} + +export function syncOfferedChatWatch( + machine: RestartMachineKey, + offer: WatchedOffer | undefined, + onActivity: (machine: RestartMachineKey) => void +): void { + if (!offer || offer.candidates.length + offer.failed.length === 0) { + releaseWatch(machine) + return + } + let entry = watches.get(machine) + if (!entry) { + const created: Watch = { + offer, + watch: createOfferedChatWatch({ + feed: () => getStructuredAgentSessionStatusFeed(restartMachineTarget(machine)), + offeredIds: () => + new Set( + [...created.offer.candidates, ...created.offer.failed].map((row) => row.sessionId) + ), + listedAt: () => created.offer.listedAt, + refresh: () => onActivity(machine) + }) + } + watches.set(machine, created) + entry = created + } + entry.offer = offer + entry.watch.sync() +} + +export function releaseOfferedChatWatches(): void { + for (const machine of watches.keys()) { + releaseWatch(machine) + } +} diff --git a/src/renderer/src/components/native-chat-restart-offer-payload.ts b/src/renderer/src/components/native-chat-restart-offer-payload.ts new file mode 100644 index 000000000000..717df109a9df --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-payload.ts @@ -0,0 +1,21 @@ +import { hasRuntimeRpcErrorCode } from '@/runtime/runtime-rpc-client' +import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' + +/** The host's answer as this side understands it. `failed` is optional on the wire: an older host + * never sends it, and its absence means nothing to show, not an invalid answer. */ +export type HostOfferPayload = { sessions?: unknown; failed?: unknown } + +export function failedFrom(payload: HostOfferPayload): ResumeFailure[] { + // SAFETY: the host is the single writer of this shape; a malformed row is a host bug, not input. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above. + return Array.isArray(payload.failed) ? (payload.failed as ResumeFailure[]) : [] +} + +/** A host that cannot hold restart offers at all: it predates the method, or has no structured + * chat surface for this client. Distinct from a read that failed, which proves nothing. */ +export function hostCannotOffer(error: unknown): boolean { + return ( + hasRuntimeRpcErrorCode(error, 'method_not_found') || + (error instanceof Error && error.message.includes('structured_agent_session_unsupported')) + ) +} diff --git a/src/renderer/src/components/native-chat-restart-offer-reopen.ts b/src/renderer/src/components/native-chat-restart-offer-reopen.ts new file mode 100644 index 000000000000..3ffd42b553aa --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-reopen.ts @@ -0,0 +1,69 @@ +import { useAppStore } from '../store' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' +import { restartMachineTarget, type RestartMachineKey } from './native-chat-restart-machines' +import { + getNativeChatRestartOffers, + readNativeChatRestartMachine +} from './native-chat-resume-on-restart-store' +import { getNativeChatRestartResuming, getNativeChatRestartRuns } from './native-chat-restart-runs' + +/** The machine to open the dialog on: the only one named, or none in particular. */ +function focusOf(machines: readonly RestartMachineKey[]): RestartMachineKey | null { + return machines.length === 1 ? machines[0]! : null +} + +/** How long a click waits on a server's re-read before the dialog opens on its last listing. A + * connected server lists its offers in well under this (one capability probe at most, then a + * capsule read); past it the click must still feel answered, and the late answer lands in the + * open dialog like any other read. */ +const PAIRED_READ_WAIT_MS = 1_500 + +/** Only a server whose runtime answers right now is worth a wait; any other state (its runtime not + * answering, contact lost or still being checked) opens on its last listing at once. */ +function readableNow(machine: RestartMachineKey): boolean { + const target = restartMachineTarget(machine) + if (target.kind === 'local') { + return true + } + const entry = useAppStore.getState().runtimeStatusByEnvironmentId.get(target.environmentId) + return runtimeHostConnectionStateForEntry(entry) === 'connected' +} + +/** This computer's read is awaited as main's opener does; a server's only for its budget. */ +async function readForClick(machine: RestartMachineKey): Promise { + const target = restartMachineTarget(machine) + const read = readNativeChatRestartMachine(target) + if (target.kind === 'local') { + await read + return + } + let timer: ReturnType | undefined + const budget = new Promise((resolve) => { + timer = setTimeout(resolve, PAIRED_READ_WAIT_MS) + }) + await Promise.race([read, budget]) + clearTimeout(timer) +} + +/** + * Every way back to the dialog — the status bar entry, a resume toast's Show, a reconnect toast's + * Show chats — re-reads the named machines (by default every listed one), then opens only over + * rows, since the dialog draws nothing without them. A failed read keeps that machine's last + * listing, so a user's click is never lost to bookkeeping. Opening a chat from it is read-only and + * keeps the offer. + */ +export async function reopenNativeChatRestartOffer( + machines: readonly RestartMachineKey[] = [...getNativeChatRestartOffers().keys()] +): Promise { + // Mid-resume that machine's answer is already on its way; a re-read racing it could undo it. + const resuming = getNativeChatRestartResuming() + await Promise.all( + machines.filter((machine) => !resuming.has(machine) && readableNow(machine)).map(readForClick) + ) + // Only over rows or a run to follow: a dialog with neither draws nothing, and a request nothing + // draws would stay open unseen. + if (getNativeChatRestartOffers().size > 0 || getNativeChatRestartRuns().size > 0) { + requestNativeChatResumeOnRestartDialog(focusOf(machines)) + } +} diff --git a/src/renderer/src/components/native-chat-restart-offer-test-support.ts b/src/renderer/src/components/native-chat-restart-offer-test-support.ts new file mode 100644 index 000000000000..11faf74e90d3 --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-test-support.ts @@ -0,0 +1,51 @@ +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +/** A paired server as the desktop's saved list holds it. */ +export function pairedEnvironment( + id: string, + name: string, + pairedDeviceId?: string +): PublicKnownRuntimeEnvironment { + return { + id, + name, + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint', + ...(pairedDeviceId ? { pairedDeviceId } : {}) + } +} + +/** A verified connection to a server's runtime under one pairing. */ +export function verifiedConnection(args: { + environmentId: string + runtimeId: string + pairedDeviceId?: string + hostContactEpoch?: number + pairingRevision?: number +}): RuntimeEnvironmentStatus { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the offer triggers read only runtimeId and pairedDeviceId from a status; the rest of RuntimeStatus is irrelevant here. + const status = { + runtimeId: args.runtimeId, + pairedDeviceId: args.pairedDeviceId + } as RuntimeStatus + return { + status, + checkedAt: 1, + hostContactEpoch: args.hostContactEpoch ?? 0, + snapshot: { + environmentId: args.environmentId, + pairingRevision: args.pairingRevision ?? 1, + sequence: 1, + checkedAt: 1, + status, + verification: 'verified', + transport: 'ready' + } + } +} diff --git a/src/renderer/src/components/native-chat-restart-offer-triggers.test.ts b/src/renderer/src/components/native-chat-restart-offer-triggers.test.ts new file mode 100644 index 000000000000..9eb75332661c --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-triggers.test.ts @@ -0,0 +1,658 @@ +// @vitest-environment happy-dom + +import { toast } from 'sonner' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import type { RestartOfferOrigin } from '../../../shared/restart-offer-origin' +import { useAppStore } from '../store' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, + mountNativeChatResumeDialog, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { + getNativeChatRestartOffers, + readNativeChatRestartMachine +} from './native-chat-resume-on-restart-store' +import { + continueNativeChatRestartOffers, + dismissNativeChatRestartOffer +} from './native-chat-restart-offer-actions' +import { + _resetNativeChatRestartOffer, + useNativeChatRestartOfferSources +} from './native-chat-restart-offer-triggers' +import { cleanup, renderHook } from '@testing-library/react' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { pairedEnvironment, verifiedConnection } from './native-chat-restart-offer-test-support' + +const mocks = vi.hoisted(() => ({ + rpc: vi.fn(), + support: vi.fn(async (): Promise<'supported' | 'unsupported' | 'unknown'> => 'supported') +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.rpc, + pairedRestartOffersSupport: mocks.support, + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const SERVER = 'studio' +const MACHINE = `environment:${SERVER}` +const TARGET = { kind: 'environment', environmentId: SERVER } as const +const RECORDED_AT = 1_800_000_000_000 + +function row( + sessionId: string, + origin: RestartOfferOrigin | undefined, + trigger: 'quit' | 'update' = 'update', + recordedAt = RECORDED_AT +) { + return { + sessionId, + workspaceId: `workspace-${sessionId}`, + agent: 'codex', + trigger, + latestPrompt: `Prompt ${sessionId}`, + recordedAt, + // The server's own terms: its host is `local` there. + executionHostId: 'local', + workspaceKind: 'git-worktree', + ...(origin ? { origin } : {}) + } satisfies ResumeCandidate +} + +function stageServer(status: { runtimeId: string; epoch?: number; pairingRevision?: number }) { + const pairingRevision = status.pairingRevision ?? 1 + replaceRuntimeEnvironmentRevisions([{ id: SERVER, createdAt: 1, pairingRevision }]) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment(SERVER, 'studio-mac')], + runtimeEnvironmentCatalogHydrated: true, + runtimeStatusByEnvironmentId: new Map([ + [ + SERVER, + verifiedConnection({ + environmentId: SERVER, + runtimeId: status.runtimeId, + hostContactEpoch: status.epoch, + pairingRevision + }) + ] + ]) + }) +} + +function callsTo(method: string): unknown[][] { + return mocks.rpc.mock.calls.filter(([, called]) => called === method) +} + +function continueCalls(): unknown[] { + return callsTo('agentSession.restartContinue').map(([target, , params]) => [target, params]) +} + +function offerReads(): number { + return callsTo('agentSession.restartResumable').length +} + +function toastTitles(): string[] { + return vi.mocked(toast).mock.calls.map(([title]) => String(title)) +} + +/** Sonner types a toast button as a labelled action or arbitrary content; only the former can be + * pressed. */ +function press(entry: unknown): void { + if ( + typeof entry !== 'object' || + entry === null || + !('onClick' in entry) || + typeof entry.onClick !== 'function' + ) { + throw new Error('toast button is not clickable') + } + entry.onClick() +} + +function label(entry: unknown): unknown { + return typeof entry === 'object' && entry !== null && 'label' in entry ? entry.label : undefined +} + +async function settle(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +/** A connected desktop: the watcher reads the server on its verified connection. */ +async function connect(status: { runtimeId: string; epoch?: number; pairingRevision?: number }) { + stageServer(status) + renderHook(() => useNativeChatRestartOfferSources(false)) + await vi.waitFor(() => expect(offerReads()).toBeGreaterThan(0)) + await settle() +} + +function serveOffers(sessions: ReturnType[]): void { + mocks.rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions, failed: [] } + : { continued: [], sessions: [], failed: [] } + ) +} + +beforeEach(() => { + vi.mocked(toast).mockClear() + mocks.rpc.mockReset() + mocks.support.mockReset() + mocks.support.mockResolvedValue('supported') + window.localStorage.clear() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalNativeChat: false } + }) + serveOffers([row('a', 'own'), row('b', 'other-device')]) +}) + +afterEach(() => { + vi.useRealTimers() + cleanup() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +it("rewrites a server's rows to its runtime host, so its workspaces are found under it", async () => { + await connect({ runtimeId: 'r2' }) + expect( + getNativeChatRestartOffers() + .get(MACHINE) + ?.candidates.map((candidate) => candidate.executionHostId) + ).toEqual(['runtime:studio', 'runtime:studio']) +}) + +it("toasts the user's own interruptions once, and a reconnect without a restart adds nothing", async () => { + await connect({ runtimeId: 'r2' }) + expect(vi.mocked(toast).mock.calls).toEqual([ + [ + 'studio-mac restarted for an update', + expect.objectContaining({ + description: + '1 of your chats there was stopped mid-reply. It shows where it stopped, and nothing was lost.' + }) + ] + ]) + stageServer({ runtimeId: 'r2', epoch: 1 }) + await vi.waitFor(() => expect(offerReads()).toBe(2)) + await settle() + expect(toast).toHaveBeenCalledTimes(1) +}) + +it('names a plain quit by the server being restarted', async () => { + serveOffers([row('a', 'own', 'quit')]) + await connect({ runtimeId: 'r2' }) + expect(toastTitles()[0]).toBe('Orca on studio-mac was restarted') +}) + +it('raises no toast for chats that are not the user’s, nor for a row with no answer', async () => { + serveOffers([ + row('a', 'other-device'), + row('b', 'automation'), + row('c', 'server-made'), + row('d', undefined) + ]) + await connect({ runtimeId: 'r2' }) + expect(toast).not.toHaveBeenCalled() + expect(getNativeChatRestartOffers().get(MACHINE)?.candidates).toHaveLength(4) +}) + +it("the toast's buttons resume exactly the own chats there, or open the dialog on that server", async () => { + await connect({ runtimeId: 'r2' }) + const options = vi.mocked(toast).mock.calls[0]?.[1] + expect([label(options?.action), label(options?.cancel)]).toEqual(['Resume 1 chat', 'Show chats']) + press(options?.cancel) + // It re-reads the server first, then opens over what it still lists. + await settle() + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: MACHINE }) + press(options?.action) + await settle() + expect(continueCalls()).toEqual([[TARGET, { sessionIds: ['a'] }]]) +}) + +it('resumes only own chats without asking, once per interruption, when the preference is on', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings(''), nativeChatResumeWorkOnRestart: true } + }) + const rows = [row('a', 'own'), row('b', 'automation'), row('c', 'other-device')] + mocks.rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: rows, failed: [] } + : { continued: [{ sessionId: 'a', outcome: 'continued' }], sessions: [], failed: [] } + ) + await connect({ runtimeId: 'r2' }) + stageServer({ runtimeId: 'r2', epoch: 1 }) + await vi.waitFor(() => expect(offerReads()).toBeGreaterThanOrEqual(2)) + await settle() + expect(continueCalls()).toEqual([[TARGET, { sessionIds: ['a'] }]]) + // No reconnect offer: the resume's own one toast says what it did, as a launch's does. + expect(toastTitles()).toEqual(['Resumed 1 chat on studio-mac']) +}) + +// A fresh window (a reload, or a reopened window on macOS) reads the same offer again. +it('decides nothing twice across a new window, and forgets interruptions the server no longer offers', async () => { + await connect({ runtimeId: 'r2' }) + expect(toast).toHaveBeenCalledTimes(1) + _resetNativeChatRestartOffer() + await connect({ runtimeId: 'r2' }) + expect(toast).toHaveBeenCalledTimes(1) + // Resumed elsewhere, then interrupted again: a new interruption of the same chat is announced. + serveOffers([]) + await readNativeChatRestartMachine(TARGET) + serveOffers([row('a', 'own', 'update', RECORDED_AT + 1)]) + await readNativeChatRestartMachine(TARGET) + expect(toast).toHaveBeenCalledTimes(2) +}) + +// The watcher mounts before the saved server list loads; a server not listed yet is not removed. +it('keeps an earlier run’s decisions through boot, before the server list has loaded', async () => { + const stored = JSON.stringify({ [SERVER]: [`a\u0000${RECORDED_AT}`] }) + window.localStorage.setItem('orca.nativeChatRestartDecided.v1', stored) + renderHook(() => useNativeChatRestartOfferSources(false)) + expect(window.localStorage.getItem('orca.nativeChatRestartDecided.v1')).toBe(stored) + stageServer({ runtimeId: 'r2' }) + await vi.waitFor(() => expect(offerReads()).toBeGreaterThan(0)) + await settle() + expect(toast).not.toHaveBeenCalled() +}) + +// Storage that refuses writes must not turn one interruption into repeated decisions this run. +it('decides each interruption once per run even when storage refuses writes', async () => { + const refuse = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + try { + await connect({ runtimeId: 'r2' }) + for (const epoch of [1, 2]) { + stageServer({ runtimeId: 'r2', epoch }) + await vi.waitFor(() => expect(offerReads()).toBe(epoch + 1)) + await settle() + } + expect(toast).toHaveBeenCalledTimes(1) + } finally { + refuse.mockRestore() + } +}) + +it('continues an interruption at most once per run when storage refuses writes and the continue fails', async () => { + useAppStore.setState({ + settings: { ...getDefaultSettings(''), nativeChatResumeWorkOnRestart: true } + }) + // Bounded so a regression ends: after five continues the server stops listing the chat. + mocks.rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: continueCalls().length >= 5 ? [] : [row('a', 'own')], failed: [] } + } + throw new Error('timeout') + }) + const refuse = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + try { + await connect({ runtimeId: 'r2' }) + await vi.waitFor(() => expect(offerReads()).toBeGreaterThanOrEqual(2)) + await settle() + expect(continueCalls()).toHaveLength(1) + } finally { + refuse.mockRestore() + } +}) + +// A dialog on screen lists every machine: no toast repeats it. +it('raises no toast while a resume dialog is on screen, which lists the server too', async () => { + const unmount = mountNativeChatResumeDialog() + try { + requestNativeChatResumeOnRestartDialog('local') + await connect({ runtimeId: 'r2' }) + expect(toast).not.toHaveBeenCalled() + } finally { + unmount() + } +}) + +// A request alone is not a dialog the user sees: one nothing draws must not swallow the toast. +it('still announces a paired interruption while a request is pending but no dialog is drawn', async () => { + requestNativeChatResumeOnRestartDialog('local') + await connect({ runtimeId: 'r2' }) + expect(toastTitles()).toEqual(['studio-mac restarted for an update']) +}) + +it('announces a paired interruption once no resume dialog is open', async () => { + await connect({ runtimeId: 'r2' }) + expect(toastTitles()).toEqual(['studio-mac restarted for an update']) +}) + +it('opens nothing from a toast whose chats are already gone', async () => { + await connect({ runtimeId: 'r2' }) + const options = vi.mocked(toast).mock.calls[0]?.[1] + serveOffers([]) + await readNativeChatRestartMachine(TARGET) + press(options?.cancel) + await settle() + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() +}) + +// Re-pairing to a server that cannot be reached yet must not leave the old pairing's rows behind. +it('forgets the old pairing’s rows as soon as the saved record is re-paired', async () => { + await connect({ runtimeId: 'r2' }) + replaceRuntimeEnvironmentRevisions([{ id: SERVER, createdAt: 1, pairingRevision: 2 }]) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) +}) + +it('decides what an open dialog already shows without a toast', async () => { + const unmount = mountNativeChatResumeDialog() + requestNativeChatResumeOnRestartDialog(null) + await connect({ runtimeId: 'r2' }) + expect(toast).not.toHaveBeenCalled() + unmount() + consumeNativeChatResumeOnRestartDialogRequest() + await readNativeChatRestartMachine(TARGET) + expect(toast).not.toHaveBeenCalled() +}) + +it('never asks a server that does not advertise paired restart offers', async () => { + mocks.support.mockResolvedValue('unsupported') + stageServer({ runtimeId: 'r2' }) + renderHook(() => useNativeChatRestartOfferSources(false)) + await vi.waitFor(() => expect(mocks.support).toHaveBeenCalled()) + await settle() + expect(offerReads()).toBe(0) + expect(getNativeChatRestartOffers().size).toBe(0) +}) + +// A server rolled back to a build without the capability must not keep a listing no click clears. +it('clears a listing once the server definitively no longer supports it', async () => { + await connect({ runtimeId: 'r2' }) + mocks.support.mockResolvedValue('unsupported') + expect((await readNativeChatRestartMachine(TARGET)).kind).toBe('unsupported') + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) +}) + +// A failed probe proves nothing: it neither hides the offer nor spends the toast. +it('treats a failed capability probe like a failed read, and announces on the next good one', async () => { + mocks.support.mockResolvedValue('unknown') + stageServer({ runtimeId: 'r2' }) + renderHook(() => useNativeChatRestartOfferSources(false)) + await vi.waitFor(() => expect(mocks.support).toHaveBeenCalled()) + await settle() + expect(offerReads()).toBe(0) + expect(toast).not.toHaveBeenCalled() + mocks.support.mockResolvedValue('supported') + await readNativeChatRestartMachine(TARGET) + expect(toast).toHaveBeenCalledTimes(1) + mocks.support.mockResolvedValue('unknown') + expect((await readNativeChatRestartMachine(TARGET)).kind).toBe('unavailable') + expect(getNativeChatRestartOffers().get(MACHINE)?.candidates).toHaveLength(2) +}) + +it('retries a failed read while the connection holds, then stops', async () => { + vi.useFakeTimers() + mocks.rpc.mockRejectedValue(new Error('server still starting')) + stageServer({ runtimeId: 'r2' }) + renderHook(() => useNativeChatRestartOfferSources(false)) + await vi.advanceTimersByTimeAsync(0) + expect(offerReads()).toBe(1) + serveOffers([row('a', 'own')]) + await vi.advanceTimersByTimeAsync(2_000) + expect(offerReads()).toBe(2) + expect(toast).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000) + expect(offerReads()).toBe(2) +}) + +it('gives up retrying after a few attempts', async () => { + vi.useFakeTimers() + mocks.rpc.mockRejectedValue(new Error('unreachable')) + stageServer({ runtimeId: 'r2' }) + renderHook(() => useNativeChatRestartOfferSources(false)) + await vi.advanceTimersByTimeAsync(120_000) + expect(offerReads()).toBe(4) +}) + +it("keeps a server's offer through a failed read, and drops it only for an unsupported host", async () => { + await connect({ runtimeId: 'r2' }) + mocks.rpc.mockRejectedValueOnce(new Error('connection lost')) + expect((await readNativeChatRestartMachine(TARGET)).kind).toBe('unavailable') + expect(getNativeChatRestartOffers().get(MACHINE)?.candidates).toHaveLength(2) + mocks.rpc.mockRejectedValueOnce( + Object.assign(new Error('Unknown method'), { code: 'method_not_found' }) + ) + expect((await readNativeChatRestartMachine(TARGET)).kind).toBe('unsupported') + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) +}) + +// An older read answering after a newer one must not replace it, nor decide anything. +it('publishes and decides only the newest answer for a machine', async () => { + await connect({ runtimeId: 'r2' }) + let answerOld: (value: unknown) => void = () => {} + mocks.rpc.mockImplementationOnce( + () => + new Promise((resolve) => { + answerOld = resolve + }) + ) + const older = readNativeChatRestartMachine(TARGET) + await settle() + serveOffers([]) + await readNativeChatRestartMachine(TARGET) + answerOld({ sessions: [row('a', 'own', 'update', RECORDED_AT + 9)], failed: [] }) + expect(await older).toMatchObject({ kind: 'answered', published: false }) + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) + expect(toast).toHaveBeenCalledTimes(1) +}) + +// A read issued before a dismiss must not restore what the dismiss removed. +it('lets a dismiss issued after a read outrank that read’s late answer', async () => { + await connect({ runtimeId: 'r2' }) + let answerRead: (value: unknown) => void = () => {} + mocks.rpc.mockImplementationOnce( + () => + new Promise((resolve) => { + answerRead = resolve + }) + ) + const reading = readNativeChatRestartMachine(TARGET) + await settle() + mocks.rpc.mockResolvedValue({ dismissed: 2, sessions: [], failed: [] }) + await dismissNativeChatRestartOffer(MACHINE) + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) + answerRead({ sessions: [row('a', 'own'), row('b', 'other-device')], failed: [] }) + expect(await reading).toMatchObject({ kind: 'answered', published: false }) + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) +}) + +it('names every chat with the interruption it listed when dismissing on a server', async () => { + await connect({ runtimeId: 'r2' }) + mocks.rpc.mockResolvedValue({ dismissed: 2, sessions: [], failed: [] }) + await dismissNativeChatRestartOffer(MACHINE) + expect(callsTo('agentSession.restartResumableDismiss').at(-1)?.slice(1)).toEqual([ + 'agentSession.restartResumableDismiss', + { + sessionIds: ['a', 'b'], + offers: [ + { sessionId: 'a', recordedAt: RECORDED_AT }, + { sessionId: 'b', recordedAt: RECORDED_AT } + ] + }, + { expectedEnvironmentPairingRevision: 1 } + ]) +}) + +// Dismissing is bookkeeping: a refusal re-reads the list, which keeps the offer, and says nothing. +it('sends nothing and raises no toast for a dismiss when the server cannot be probed', async () => { + await connect({ runtimeId: 'r2' }) + const toasts = toastTitles().length + const listed = getNativeChatRestartOffers().get(MACHINE) + mocks.support.mockResolvedValue('unknown') + await dismissNativeChatRestartOffer(MACHINE) + await settle() + expect(callsTo('agentSession.restartResumableDismiss')).toEqual([]) + expect(toastTitles()).toHaveLength(toasts) + // The re-read could not reach the server either, so the last answer stands. + expect(getNativeChatRestartOffers().get(MACHINE)).toBe(listed) +}) + +// The host re-derives which named chats it still offers, so a restart since the listing is fine. +it('acts under the pairing the offer was listed from, whatever runtime answers now', async () => { + await connect({ runtimeId: 'r2' }) + const options = vi.mocked(toast).mock.calls[0]?.[1] + stageServer({ runtimeId: 'r3' }) + press(options?.action) + await settle() + expect(callsTo('agentSession.restartContinue')[0]?.[3]).toEqual({ + expectedEnvironmentPairingRevision: 1 + }) +}) + +it('says which chats could not be resumed when the server was re-paired before the call left', async () => { + await connect({ runtimeId: 'r2' }) + mocks.rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartContinue') { + throw Object.assign(new Error('runtime_environment_changed'), { + code: 'runtime_environment_changed' + }) + } + return { sessions: [row('a', 'own')] } + }) + await continueNativeChatRestartOffers([{ machine: MACHINE, sessionIds: ['a'] }]) + expect(toastTitles()).toContain('1 chat on studio-mac couldn’t be resumed') + expect(toastTitles().some((title) => title.includes('unconfirmed'))).toBe(false) +}) + +it('retires the old pairing’s offers, decisions and toast on a re-pair', async () => { + await connect({ runtimeId: 'r2' }) + const options = vi.mocked(toast).mock.calls[0]?.[1] + // The new pairing cannot be read yet: nothing from the old one may stand in for its answer. + mocks.rpc.mockRejectedValue(new Error('connection lost')) + stageServer({ runtimeId: 'r2', pairingRevision: 2 }) + await vi.waitFor(() => expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false)) + press(options?.action) + await settle() + expect(continueCalls()).toEqual([]) + expect(toastTitles()).toContain('1 chat on studio-mac couldn’t be resumed') + // Once readable, the new pairing's own chats are its own decision. + serveOffers([row('a', 'own')]) + await readNativeChatRestartMachine(TARGET) + expect( + toastTitles().filter((title) => title === 'studio-mac restarted for an update') + ).toHaveLength(2) +}) + +it('drops an action answer that lands after a re-pair', async () => { + await connect({ runtimeId: 'r2' }) + let answer: (value: unknown) => void = () => {} + mocks.rpc.mockImplementation((_target, method) => + method === 'agentSession.restartContinue' + ? new Promise((resolve) => { + answer = resolve + }) + : Promise.reject(new Error('new pairing not readable yet')) + ) + const resuming = continueNativeChatRestartOffers([{ machine: MACHINE, sessionIds: ['a'] }]) + await settle() + stageServer({ runtimeId: 'r9', pairingRevision: 2 }) + await vi.waitFor(() => expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false)) + answer({ continued: [], sessions: [row('b', 'other-device')], failed: [] }) + await resuming + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) +}) + +it('does not read the old pairing’s answer into a re-paired server while probing', async () => { + stageServer({ runtimeId: 'r2' }) + let probed: (value: 'supported') => void = () => {} + mocks.support.mockImplementationOnce( + () => + new Promise((resolve) => { + probed = resolve + }) + ) + const reading = readNativeChatRestartMachine(TARGET) + replaceRuntimeEnvironmentRevisions([{ id: SERVER, createdAt: 1, pairingRevision: 2 }]) + probed('supported') + expect((await reading).kind).toBe('unavailable') + expect(offerReads()).toBe(0) +}) + +it('reads the machine again when a later read overtook an action’s answer', async () => { + await connect({ runtimeId: 'r2' }) + let answer: (value: unknown) => void = () => {} + mocks.rpc.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const resuming = continueNativeChatRestartOffers([{ machine: MACHINE, sessionIds: ['a'] }]) + await settle() + await readNativeChatRestartMachine(TARGET) + const before = offerReads() + serveOffers([row('b', 'other-device')]) + answer({ continued: [{ sessionId: 'a', outcome: 'continued' }], sessions: [], failed: [] }) + await resuming + expect(offerReads()).toBe(before + 1) + expect( + getNativeChatRestartOffers() + .get(MACHINE) + ?.candidates.map((entry) => entry.sessionId) + ).toEqual(['b']) +}) + +it('forgets a server this desktop no longer pairs with', async () => { + // Decided about in an earlier run, and unpaired since. + window.localStorage.setItem( + 'orca.nativeChatRestartDecided.v1', + JSON.stringify({ 'unpaired-earlier': ['a\u00001'] }) + ) + await connect({ runtimeId: 'r2' }) + expect(window.localStorage.length).toBe(1) + useAppStore.setState({ runtimeEnvironments: [] }) + expect(getNativeChatRestartOffers().has(MACHINE)).toBe(false) + expect(window.localStorage.length).toBe(0) +}) + +// Only this computer's launch read raises the dialog by itself, and only over the user's own chats. +it("asks for the dialog on this computer when its launch read finds the user's own chats", async () => { + mocks.rpc.mockImplementation(async (target) => + target.kind === 'local' ? { sessions: [row('l1', 'own')] } : { sessions: [] } + ) + renderHook(() => useNativeChatRestartOfferSources(true)) + await vi.waitFor(() => + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'local' }) + ) +}) + +// The launch asks about the user's own chats here; an automation's or another device's alone never +// open the dialog, launch after launch. +it.each(['automation', 'other-device', 'server-made'] as const)( + 'never raises the launch dialog for chats here that are only %s', + async (origin) => { + mocks.rpc.mockImplementation(async (target) => + target.kind === 'local' ? { sessions: [row('l1', origin)] } : { sessions: [] } + ) + for (let launch = 0; launch < 2; launch += 1) { + _resetNativeChatRestartOffer() + const { unmount } = renderHook(() => useNativeChatRestartOfferSources(true)) + await vi.waitFor(() => expect(getNativeChatRestartOffers().get('local')).toBeDefined()) + unmount() + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() + expect(getNativeChatRestartOffers().get('local')?.candidates).toHaveLength(1) + } + } +) + +it('never opens the dialog by itself for a paired server', async () => { + await connect({ runtimeId: 'r2' }) + expect(toast).toHaveBeenCalledTimes(1) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat-restart-offer-triggers.ts b/src/renderer/src/components/native-chat-restart-offer-triggers.ts new file mode 100644 index 000000000000..f1126496c7af --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-offer-triggers.ts @@ -0,0 +1,319 @@ +import { useEffect } from 'react' +import { isRuntimeHostContactRevoked } from '../../../shared/runtime-host-status' +import { parseRestartOfferOrigin } from '../../../shared/restart-offer-origin' +import { isWebClientLocation } from '@/lib/web-client-location' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useAppStore } from '../store' +import type { AppState } from '../store/types' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + LOCAL_RESTART_MACHINE, + restartMachineKey, + restartMachineTarget, + type RestartMachineKey +} from './native-chat-restart-machines' +import { restartMachineName } from './native-chat-restart-machine-name' +import { continueNativeChatRestartOffers } from './native-chat-restart-offer-actions' +import { + _resetNativeChatRestartOfferState, + forgetNativeChatRestartMachine, + getNativeChatRestartOffers, + readNativeChatRestartMachine, + setNativeChatRestartReadListener, + type RestartMachineRead +} from './native-chat-resume-on-restart-store' +import { requestLaunchResumePrompt } from './native-chat-resume-on-restart-launch-prompt' +import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' +import { + _resetNativeChatResumeOnRestartDialog, + nativeChatResumeDialogOnScreen +} from './native-chat-resume-on-restart-dialog' +import { + _resetRestartDecidedMemory, + decidedRestartInterruptions, + forgetRestartInterruptions, + restartDecidedEnvironments, + restartInterruptionKey, + settleRestartInterruptions +} from './native-chat-restart-decided' +import { announceReconnectRestartOffer } from './native-chat-restart-reconnect-toast' +import { reopenNativeChatRestartOffer } from './native-chat-restart-offer-reopen' + +/** + * When each machine is asked for its offer, and the one decision a fresh offer makes: ask, resume + * the user's own chats without asking, or (for a paired server) say so in a toast. + * + * This computer is asked once, at launch. Each paired server is asked on every verified connection + * — the first, a new runtime, and a return after lost contact — never from its chat status feed, + * which needs a host that may not exist yet. A failed paired read is retried a few times while the + * connection holds. + * + * The toast is decided from the answer, not from a remembered edge: any published read of a paired + * server announces (or, opted in, resumes) the user's own interruptions this desktop has not yet + * decided about, then records them as decided. + */ + +const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const +const PAIRED_READ_RETRY_DELAYS_MS = [2_000, 5_000, 15_000] as const + +let launch: Promise | undefined + +function autoResumeEnabled(): boolean { + return useAppStore.getState().settings?.nativeChatResumeWorkOnRestart === true +} + +/** The user's own chats among those the machine still offers right now. */ +function ownCandidates( + target: RuntimeClientTarget, + read: readonly ResumeCandidate[] +): ResumeCandidate[] { + const stillOffered = new Set( + getNativeChatRestartOffers() + .get(restartMachineKey(target)) + ?.candidates.map((candidate) => candidate.sessionId) ?? [] + ) + return read.filter( + (candidate) => + stillOffered.has(candidate.sessionId) && parseRestartOfferOrigin(candidate.origin) === 'own' + ) +} + +/** "Resume automatically": ends in the same one toast a clicked resume does. */ +function resumeOwn(machine: RestartMachineKey, own: readonly ResumeCandidate[]): Promise { + return continueNativeChatRestartOffers([ + { machine, sessionIds: own.map((candidate) => candidate.sessionId) } + ]) +} + +/** + * This launch's single read of this computer's offer. + * + * Runs once however many surfaces mount, so the count and the dialog describe the same answer and + * an opted-in launch cannot dispatch twice. + */ +async function loadLaunchOffer(): Promise { + // The preference belongs to this launch's request; later saves cannot dispatch another. + const resumeWithoutAsking = autoResumeEnabled() + const target: RuntimeClientTarget = { kind: 'local' } + let read: RestartMachineRead = await readNativeChatRestartMachine(target) + // Host startup can race the renderer. Retry only failed reads, never a confirmed empty result, + // so a transient startup gap does not strand a durable offer or add steady-state polling. + for (const delay of LAUNCH_READ_RETRY_DELAYS_MS) { + if (read.kind !== 'unavailable') { + break + } + await new Promise((resolve) => setTimeout(resolve, delay)) + read = await readNativeChatRestartMachine(target) + } + // Failures left from an earlier launch are the status bar's to show; only a fresh offer of the + // user's own chats asks. Another device's or an automation's alone never open the dialog. + const own = read.kind === 'answered' ? ownCandidates(target, read.candidates) : [] + if (own.length === 0) { + return + } + if (!resumeWithoutAsking) { + requestLaunchResumePrompt() + return + } + // Not awaited: nothing will ask, so other launch prompts need not wait for the resume to settle. + void resumeOwn(LOCAL_RESTART_MACHINE, own) +} + +/** + * A paired server's published answer: its own interruptions this desktop has not decided about + * are resumed (opted in) or announced, and every key the server no longer offers is forgotten. + * Shown in an open dialog, they are decided without a toast. + */ +function decidePairedAnswer(target: RuntimeClientTarget, candidates: readonly ResumeCandidate[]) { + if (target.kind !== 'environment') { + return + } + const machine = restartMachineKey(target) + const decided = decidedRestartInterruptions(target.environmentId) + const fresh = ownCandidates(target, candidates).filter( + (candidate) => !decided.has(restartInterruptionKey(candidate)) + ) + settleRestartInterruptions( + target.environmentId, + candidates.map(restartInterruptionKey), + fresh.map(restartInterruptionKey) + ) + // A resume dialog on screen lists every machine: what it shows needs no toast. + if (fresh.length === 0 || nativeChatResumeDialogOnScreen(getNativeChatRestartOffers().size > 0)) { + return + } + if (autoResumeEnabled()) { + void resumeOwn(machine, fresh) + return + } + const fence = getNativeChatRestartOffers().get(machine)?.fence + announceReconnectRestartOffer({ + machine, + machineName: restartMachineName(machine), + own: fresh, + resume: (sessionIds) => + void continueNativeChatRestartOffers([{ machine, sessionIds }], { expected: fence }), + show: () => void reopenNativeChatRestartOffer([machine]) + }) +} + +/** Marks what an open dialog shows as decided, so a later read does not announce it again. */ +export function markNativeChatRestartOffersShown( + shown: readonly { machine: RestartMachineKey; candidates: readonly ResumeCandidate[] }[] +): void { + for (const { machine, candidates } of shown) { + const target = restartMachineTarget(machine) + if (target.kind === 'environment') { + const keys = candidates.map(restartInterruptionKey) + settleRestartInterruptions(target.environmentId, keys, keys) + } + } +} + +type SeenConnection = { key: string; pairingRevision: number | undefined; generation: number } +const seenConnections = new Map() +/** Each paired server's pairing revision as the saved list last named it. */ +const knownPairingRevisions = new Map() +let stopWatchingConnections: (() => void) | null = null +let connectionGenerations = 0 + +function connectionStillCurrent(environmentId: string, generation: number): boolean { + return seenConnections.get(environmentId)?.generation === generation +} + +/** One verified connection: read, and retry a failed read a few times while it holds. */ +async function readPairedMachineOnConnection(environmentId: string, generation: number) { + const target: RuntimeClientTarget = { kind: 'environment', environmentId } + for (const delay of [...PAIRED_READ_RETRY_DELAYS_MS, null]) { + const read = await readNativeChatRestartMachine(target) + if (read.kind !== 'unavailable' || delay === null) { + return + } + await new Promise((resolve) => setTimeout(resolve, delay)) + if (!connectionStillCurrent(environmentId, generation)) { + return + } + } +} + +function forgetPairedMachine(environmentId: string): void { + forgetNativeChatRestartMachine(restartMachineKey({ kind: 'environment', environmentId })) + forgetRestartInterruptions(environmentId) +} + +/** + * Reads a paired server's offer on each verified connection transition. Loss of contact reads + * nothing and keeps the offer; only a revoked pairing, a re-pair or a removed server forgets it. + */ +function noticeConnections(state: AppState): void { + const paired = new Set(state.runtimeEnvironments.map((environment) => environment.id)) + for (const environmentId of paired) { + // A re-pair is known from the saved record before the new pairing ever connects: the old + // pairing's rows go now, not once the new one answers. + const revision = getRuntimeEnvironmentRevision(environmentId) + const known = knownPairingRevisions.get(environmentId) + if (revision !== undefined) { + if (known !== undefined && known !== revision) { + forgetPairedMachine(environmentId) + } + knownPairingRevisions.set(environmentId, revision) + } + } + for (const [environmentId, entry] of state.runtimeStatusByEnvironmentId) { + if (!paired.has(environmentId)) { + continue + } + const pairingRevision = entry.snapshot?.pairingRevision + if (isRuntimeHostContactRevoked(entry)) { + seenConnections.set(environmentId, { key: '', pairingRevision, generation: 0 }) + forgetPairedMachine(environmentId) + continue + } + const runtimeId = entry.status?.runtimeId + if (!runtimeId) { + continue + } + const key = `${pairingRevision ?? ''}\u0000${runtimeId}\u0000${entry.hostContactEpoch ?? 0}` + const seen = seenConnections.get(environmentId) + if (seen?.key === key) { + continue + } + if (seen && seen.pairingRevision !== pairingRevision) { + // Re-paired under the same id: the old pairing's offers, ticks and decisions are not this one's. + forgetPairedMachine(environmentId) + } + const generation = ++connectionGenerations + seenConnections.set(environmentId, { key, pairingRevision, generation }) + void readPairedMachineOnConnection(environmentId, generation) + } + // Until the saved server list has loaded, a missing server is not a removed one. + if (!state.runtimeEnvironmentCatalogHydrated) { + return + } + const removed = new Set( + [ + ...[...getNativeChatRestartOffers().keys()].flatMap((machine) => { + const target = restartMachineTarget(machine) + return target.kind === 'environment' ? [target.environmentId] : [] + }), + ...seenConnections.keys(), + ...restartDecidedEnvironments() + ].filter((environmentId) => !paired.has(environmentId)) + ) + for (const environmentId of removed) { + seenConnections.delete(environmentId) + knownPairingRevisions.delete(environmentId) + forgetPairedMachine(environmentId) + } +} + +function watchPairedConnections(): void { + // A browser client has no paired servers of its own to ask. + if (stopWatchingConnections || isWebClientLocation()) { + return + } + setNativeChatRestartReadListener(decidePairedAnswer) + let last: AppState | null = null + const check = (state: AppState): void => { + if ( + last && + last.runtimeStatusByEnvironmentId === state.runtimeStatusByEnvironmentId && + last.runtimeEnvironments === state.runtimeEnvironments + ) { + return + } + last = state + noticeConnections(state) + } + stopWatchingConnections = useAppStore.subscribe(check) + check(useAppStore.getState()) +} + +/** + * Starts every source of restart offers. `localEnabled` gates only this computer's launch read: + * settings arrive after the first render, so the read waits for the flag rather than being lost. + */ +export function useNativeChatRestartOfferSources(localEnabled: boolean): void { + useEffect(watchPairedConnections, []) + useEffect(() => { + if (localEnabled) { + // Fetched after mount, never awaited by startup: the workspace is usable first. Decided + // either way, so in-chat Continue stops waiting on this computer's chats. + launch ??= loadLaunchOffer().finally(markNativeChatLaunchResumeDecided) + } + }, [localEnabled]) +} + +/** @internal - tests need a clean module between cases: every offer, action and trigger. */ +export function _resetNativeChatRestartOffer(): void { + _resetNativeChatRestartOfferState() + _resetNativeChatResumeOnRestartDialog() + _resetRestartDecidedMemory() + launch = undefined + seenConnections.clear() + knownPairingRevisions.clear() + connectionGenerations = 0 + stopWatchingConnections?.() + stopWatchingConnections = null +} diff --git a/src/renderer/src/components/native-chat-restart-reconnect-toast.ts b/src/renderer/src/components/native-chat-restart-reconnect-toast.ts new file mode 100644 index 000000000000..ae3a27c5cc02 --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-reconnect-toast.ts @@ -0,0 +1,84 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import type { RestartMachineKey } from './native-chat-restart-machines' + +/** + * The notice a paired server's restart raises once this desktop reconnects: how many of the user's + * own chats there were cut off, with a way to resume exactly those or to pick in the dialog. + * + * Never shown for this computer, whose own launch opens the dialog instead. + */ +function reconnectToastId(machine: RestartMachineKey): string { + return `native-chat-restart-reconnect:${machine}` +} + +/** An open resume dialog lists these machines' chats and blocks the page under it, so a toast still + * on screen above it would only look clickable. */ +export function dismissReconnectRestartOffers(machines: readonly RestartMachineKey[]): void { + for (const machine of machines) { + toast.dismiss(reconnectToastId(machine)) + } +} + +export function announceReconnectRestartOffer(args: { + machine: RestartMachineKey + machineName: string + own: readonly ResumeCandidate[] + resume: (sessionIds: readonly string[]) => void + show: () => void +}): void { + const { machineName, own } = args + if (own.length === 0) { + return + } + const title = own.some((candidate) => candidate.trigger === 'update') + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.reconnectUpdateTitle', + '{{value0}} restarted for an update', + { value0: machineName } + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.reconnectQuitTitle', + 'Orca on {{value0}} was restarted', + { value0: machineName } + ) + const description = + own.length === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.reconnectBodyOne', + '1 of your chats there was stopped mid-reply. It shows where it stopped, and nothing was lost.' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.reconnectBodyMany', + '{{value0}} of your chats there were stopped mid-reply. Each one shows where it stopped, and nothing was lost.', + { value0: own.length } + ) + const sessionIds = own.map((candidate) => candidate.sessionId) + toast(title, { + // One per machine: a newer restart of the same server replaces the older notice. + id: reconnectToastId(args.machine), + description, + action: { + label: + own.length === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.resumeSelectedOne', + 'Resume 1 chat' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.resumeSelected', + 'Resume {{value0}} chats', + { value0: own.length } + ), + onClick: () => args.resume(sessionIds) + }, + cancel: { + label: translate( + 'auto.components.NativeChatResumeOnRestartModal.reconnectShow', + 'Show chats' + ), + onClick: args.show + } + }) +} diff --git a/src/renderer/src/components/native-chat-restart-runs.ts b/src/renderer/src/components/native-chat-restart-runs.ts new file mode 100644 index 000000000000..ad465d28e74b --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-runs.ts @@ -0,0 +1,198 @@ +import { useSyncExternalStore } from 'react' +import type { RestartContinuationOutcome } from './native-chat-restart-action-notifications' +import { restartMachineTarget, type RestartMachineKey } from './native-chat-restart-machines' +import type { NativeChatRestartMachineOffer } from './native-chat-resume-on-restart-store' +import { + beginResumeRun, + resumeRunInFlight, + resumeRunPendingIds, + type ResumeRun +} from './native-chat-resume-run' +import { watchResumeRunStatus } from './native-chat-resume-run-status-watch' + +/** + * Each machine's latest resume, which the dialog and the status bar follow: the chats it named, each + * one's live progress from that machine's host, then what became of each. + * + * Memory-only history. It retires after its finished summary was shown and then closed, when the + * next resume starts (one resume's runs are followed together, as one run was before machines), or + * when the machine is unpaired. + */ +export type NativeChatRestartRun = Readonly<{ + /** The listing the resume started from; it still names the machine once nothing is left there. */ + listing: NativeChatRestartMachineOffer + run: ResumeRun +}> + +export type NativeChatRestartRuns = ReadonlyMap + +const NO_RUNS: NativeChatRestartRuns = new Map() +const NOTHING_RESUMING: ReadonlyMap = new Map() +let runs = NO_RUNS +let resuming = NOTHING_RESUMING +const statusWatches = new Map void>() +const listeners = new Set<() => void>() + +function sameResuming( + left: ReadonlyMap, + right: ReadonlyMap +): boolean { + return ( + left.size === right.size && + [...left].every(([machine, ids]) => right.get(machine)?.join('\0') === ids.join('\0')) + ) +} + +function setRun(machine: RestartMachineKey, next: NativeChatRestartRun | null): void { + const updated = new Map(runs) + if (next) { + updated.set(machine, next) + } else { + updated.delete(machine) + } + runs = updated.size === 0 ? NO_RUNS : updated + const pending = new Map() + for (const [key, entry] of runs) { + const ids = resumeRunPendingIds(entry.run) + if (ids.length > 0) { + pending.set(key, ids) + } + } + // Same chats, same map, so readers of `resuming` re-render only when it changes. + if (!sameResuming(pending, resuming)) { + resuming = pending.size === 0 ? NOTHING_RESUMING : pending + } + for (const listener of listeners) { + listener() + } +} + +function releaseStatusWatch(machine: RestartMachineKey): void { + statusWatches.get(machine)?.() + statusWatches.delete(machine) +} + +export type RestartRunAnswer = { + continued?: readonly RestartContinuationOutcome[] + /** Named chats the host left out of this action, e.g. another action already resuming them. */ + skipped?: ReadonlySet +} + +/** + * Starts following one resume on the listing's machine, replacing that machine's last run and any + * finished run of an earlier resume. The returned `finish` is called once the host's answer is + * published, so reopening never shows the old selection as actionable. + */ +export function beginNativeChatRestartRun( + listing: NativeChatRestartMachineOffer, + sessionIds: readonly string[] +): (answer?: RestartRunAnswer) => void { + const { machine } = listing + const rows = new Map( + [...listing.candidates, ...listing.failed].map((row) => [row.sessionId, row]) + ) + let current: NativeChatRestartRun = { + listing, + run: beginResumeRun( + [...new Set(sessionIds)].flatMap((sessionId) => rows.get(sessionId) ?? []), + Date.now() + ) + } + releaseStatusWatch(machine) + for (const [other, entry] of runs) { + if (other !== machine && !resumeRunInFlight(entry.run)) { + setRun(other, null) + } + } + setRun(machine, current) + const release = watchResumeRunStatus( + restartMachineTarget(machine), + () => (runs.get(machine) === current ? current.run : null), + (run) => { + current = { ...current, run } + setRun(machine, current) + } + ) + statusWatches.set(machine, release) + return (answer = {}) => { + release() + if (statusWatches.get(machine) === release) { + statusWatches.delete(machine) + } + if (runs.get(machine) !== current) { + return + } + const skipped = answer.skipped ?? new Set() + current = { + ...current, + run: { + ...current.run, + entries: current.run.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), + inFlight: false, + continued: answer.continued + } + } + setRun(machine, current) + } +} + +/** A finished summary must be shown before closing can retire it. */ +export function markFinishedNativeChatRestartRunsShown(shown: NativeChatRestartRuns): void { + for (const [machine, entry] of shown) { + if (runs.get(machine) === entry && !entry.run.inFlight && !entry.run.finishedViewShown) { + setRun(machine, { ...entry, run: { ...entry.run, finishedViewShown: true } }) + } + } +} + +/** Closing the dialog retires every finished run it showed. */ +export function releaseFinishedNativeChatRestartRuns(): void { + for (const [machine, entry] of runs) { + if (entry.run.finishedViewShown && !resumeRunInFlight(entry.run)) { + setRun(machine, null) + } + } +} + +/** An unpaired machine's run is not this desktop's to show. */ +export function forgetNativeChatRestartRun(machine: RestartMachineKey): void { + releaseStatusWatch(machine) + if (runs.has(machine)) { + setRun(machine, null) + } +} + +export function getNativeChatRestartRuns(): NativeChatRestartRuns { + return runs +} + +/** The chats each machine's resume is carrying on right now, until the host answers. */ +export function getNativeChatRestartResuming(): ReadonlyMap { + return resuming +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function useNativeChatRestartRuns(): NativeChatRestartRuns { + return useSyncExternalStore(subscribe, getNativeChatRestartRuns, getNativeChatRestartRuns) +} + +/** The chats a resume is carrying on right now, per machine, whichever surface started it. */ +export function useNativeChatRestartResuming(): ReadonlyMap { + return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming) +} + +/** @internal - tests need a clean module between cases. */ +export function _resetNativeChatRestartRuns(): void { + for (const machine of statusWatches.keys()) { + releaseStatusWatch(machine) + } + runs = NO_RUNS + resuming = NOTHING_RESUMING + listeners.clear() +} diff --git a/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx index 869430476514..854d9117cb8b 100644 --- a/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx +++ b/src/renderer/src/components/native-chat-resume-bulk-progress.test.tsx @@ -4,7 +4,10 @@ import { offered, failure, type RestartRpc, - type ResumeStatusStream + type ResumeStatusStream, + continueLocal, + localOffer, + readLocalOffer } from './native-chat-resume-modal.test-support' import { act } from 'react' import { expect, it, vi, type Mock } from 'vitest' @@ -14,11 +17,6 @@ import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStat 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(() => ({ @@ -27,6 +25,7 @@ const statusStream: ResumeStatusStream = vi.hoisted(() => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', subscribeStructuredAgentSessionStatus: async ( _target: unknown, emit: (event: Parameters[0]) => void @@ -39,7 +38,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: vi.fn(async () => true) })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) const { mount, button, offerIds, toasts, fakeHost, runStatus } = createResumeModalFixture( rpc, @@ -78,8 +77,8 @@ it('resumes a 21-chat selection with one action and no redundant listing', async continued: candidates.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })) } ) - await refreshNativeChatRestartOffer() - await continueNativeChatRestartOffer(candidates.map(({ sessionId }) => sessionId)) + await readLocalOffer() + await continueLocal(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) }] @@ -105,13 +104,10 @@ it('uses the host unconfirmed result after a lost bulk reply without reporting a } }) try { - await refreshNativeChatRestartOffer() - await continueNativeChatRestartOffer(['a', 'b']) + await readLocalOffer() + await continueLocal(['a', 'b']) expect(toasts()).toEqual([['Couldn’t confirm 2 chats were resumed']]) - expect(getNativeChatRestartOffer().failed.map((row) => row.outcome)).toEqual([ - 'unconfirmed', - 'unconfirmed' - ]) + expect(localOffer().failed.map((row) => row.outcome)).toEqual(['unconfirmed', 'unconfirmed']) } finally { warn.mockRestore() } @@ -137,7 +133,7 @@ it('publishes a refusal reply without needing a second host read', async () => { 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(localOffer().failed.map((row) => row.sessionId)).toEqual(['a']) expect(toasts()).toEqual([['1 chat couldn’t be resumed']]) }) @@ -268,7 +264,7 @@ it('uses reply skipped ids even when the skip frame was missed', async () => { } ) await mount() - await act(async () => continueNativeChatRestartOffer(['a', 'b'])) + await act(async () => continueLocal(['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() @@ -283,7 +279,7 @@ it.each(['unknown', 'pending'] as const)( : { sessions: [], failed: [], continued: [{ sessionId: 'a', outcome }] } ) await mount() - await act(async () => continueNativeChatRestartOffer(['a'])) + await act(async () => continueLocal(['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']]) @@ -293,7 +289,7 @@ it.each(['unknown', 'pending'] as const)( 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'])) + await act(async () => continueLocal(['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() @@ -311,7 +307,7 @@ it('keeps an unknown reply unconfirmed when it carries no confirmed failure list : { sessions: [], continued: [{ sessionId: 'a', outcome: 'unknown' }] } ) await mount() - await act(async () => continueNativeChatRestartOffer(['a'])) + await act(async () => continueLocal(['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']]) @@ -335,7 +331,7 @@ it('a retry hides its old failure, then a dismissed final failure leaves Need yo const dismiss = document.querySelector('button[aria-label^="Dismiss"]') expect(dismiss).not.toBeNull() await act(async () => dismiss?.click()) - expect(getNativeChatRestartOffer().failed).toEqual([]) + expect(localOffer().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-dialog-opening.ts b/src/renderer/src/components/native-chat-resume-dialog-opening.ts new file mode 100644 index 000000000000..2bc397c72e5f --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-dialog-opening.ts @@ -0,0 +1,67 @@ +import { useEffect, useSyncExternalStore } from 'react' +import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate' +import { + getNativeChatResumeOnRestartDialogRequest, + mountNativeChatResumeDialog, + subscribeNativeChatResumeOnRestartDialog, + type NativeChatResumeOnRestartDialogRequest +} from './native-chat-resume-on-restart-dialog' +import { dismissReconnectRestartOffers } from './native-chat-restart-reconnect-toast' +import { useNativeChatRestartOffers } from './native-chat-resume-on-restart-store' +import { + markFinishedNativeChatRestartRunsShown, + useNativeChatRestartRuns, + type NativeChatRestartRuns +} from './native-chat-restart-runs' +import { useMachineViews, type MachineView } from './native-chat-resume-machine-views' +import { + markNativeChatRestartOffersShown, + useNativeChatRestartOfferSources +} from './native-chat-restart-offer-triggers' + +/** + * Whether the resume dialog is on screen, and over which machines. Starts every source of offers + * and records what the dialog shows as decided. + */ +export function useNativeChatResumeDialogOpening(): { + machines: MachineView[] + runs: NativeChatRestartRuns + request: NativeChatResumeOnRestartDialogRequest | null + showing: boolean +} { + const localEnabled = useNativeChatRestartOfferEnabled() + useNativeChatRestartOfferSources(localEnabled) + const offers = useNativeChatRestartOffers() + const runs = useNativeChatRestartRuns() + const machines = useMachineViews(offers, runs) + // Open is an external request, never mirrored into local state: the launch load, the status-bar + // entry and a reconnect toast all raise it, and a copy here would go stale against the last one. + const request = useSyncExternalStore( + subscribeNativeChatResumeOnRestartDialog, + getNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest + ) + // What the open dialog shows is decided: a later read of a paired server does not announce it, + // and a restart toast still up goes, since the dialog lists its chats and blocks clicks on it. + const showing = request !== null && machines.length > 0 + useEffect(mountNativeChatResumeDialog, []) + useEffect(() => { + if (!showing) { + return + } + markNativeChatRestartOffersShown( + machines.map((machine) => ({ + machine: machine.machine, + candidates: machine.offer.candidates + })) + ) + dismissReconnectRestartOffers(machines.map((machine) => machine.machine)) + }, [showing, machines]) + useEffect(() => { + if (showing) { + // A finished run on screen has been seen, so closing the dialog retires it. + markFinishedNativeChatRestartRunsShown(runs) + } + }, [showing, runs]) + return { machines, runs, request, showing } +} 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 81e90f10c914..3614e41b5827 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 @@ -7,12 +7,14 @@ import type { } from '../../../shared/agent-session-wire' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' import { - _resetNativeChatRestartOffer, - continueNativeChatRestartOffer, - dismissNativeChatRestartOffer, - getNativeChatRestartOffer, - refreshNativeChatRestartOffer + getNativeChatRestartOffers, + readNativeChatRestartMachine } from './native-chat-resume-on-restart-store' +import { + continueNativeChatRestartOffers, + dismissNativeChatRestartOffer +} from './native-chat-restart-offer-actions' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' const mocks = vi.hoisted(() => ({ rpc: vi.fn(), @@ -24,6 +26,14 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ subscribeStructuredAgentSessionStatus: mocks.subscribeStatus })) +function getNativeChatRestartOffer() { + return getNativeChatRestartOffers().get('local') ?? { candidates: [], failed: [] } +} + +function refreshNativeChatRestartOffer() { + return readNativeChatRestartMachine({ kind: 'local' }) +} + // When the user sends in an offered or failed chat, its status bar entry must retire without the // user reopening anything, and nothing may run while nothing is offered or failed. @@ -34,6 +44,7 @@ const failure = { trigger: 'update', latestPrompt: 'Fix it', recordedAt: 1, + origin: 'own', failedAt: 2, outcome: 'refused', reason: 'agent_session_restart_work_superseded' @@ -170,7 +181,7 @@ it('never lets a re-read that was already in flight bring back a dismissed failu await vi.advanceTimersByTimeAsync(500) expect(offerReads()).toBe(2) - await dismissNativeChatRestartOffer(['a']) + await dismissNativeChatRestartOffer('local', ['a']) expect(getNativeChatRestartOffer().failed).toEqual([]) stale.resolve({ sessions: [], failed: [failure] }) await vi.advanceTimersByTimeAsync(0) @@ -189,7 +200,7 @@ it('waits for a resume in flight instead of re-reading under it', async () => { ? acting.promise : Promise.resolve({ sessions: [], failed }) ) - const retry = continueNativeChatRestartOffer(['a']) + const retry = continueNativeChatRestartOffers([{ machine: 'local', sessionIds: ['a'] }]) hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) }) await vi.advanceTimersByTimeAsync(500) expect(offerReads()).toBe(1) diff --git a/src/renderer/src/components/native-chat-resume-failure-action.ts b/src/renderer/src/components/native-chat-resume-failure-action.ts new file mode 100644 index 000000000000..3ef2adc7ca56 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-failure-action.ts @@ -0,0 +1,46 @@ +import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' +import type { ResumeFailureAction } from './native-chat-resume-failure-guidance' +import { consumeNativeChatResumeOnRestartDialogRequest } from './native-chat-resume-on-restart-dialog' +import { + continueNativeChatRestartOffers, + dismissNativeChatRestartOffer +} from './native-chat-restart-offer-actions' +import type { MachineView } from './native-chat-resume-machine-views' +import { releaseFinishedNativeChatRestartRuns } from './native-chat-restart-runs' +import { restartMachineTarget } from './native-chat-restart-machines' + +/** A dialog row's own action on one chat. Row actions act on their row and leave the dialog open, + * except Open, which gets out of the way of the chat it opens. */ +export async function actOnResumeRow( + machine: MachineView, + action: ResumeFailureAction, + sessionId: string, + persistPreference: () => Promise +): Promise { + if (action === 'dismiss') { + await dismissNativeChatRestartOffer(machine.machine, [sessionId]) + return + } + if (action === 'retry') { + void persistPreference() + await continueNativeChatRestartOffers([{ machine: machine.machine, sessionIds: [sessionId] }]) + return + } + const failure = machine.failureFor(sessionId) + if (!failure) { + return + } + // Opening is read-only and keeps the record: the user's own send in that chat settles it. + consumeNativeChatResumeOnRestartDialogRequest() + releaseFinishedNativeChatRestartRuns() + // A paired server's chat opens on that server under the pairing it was listed under, never on + // whichever machine holds a workspace with the same id. This computer's chats resolve from their + // workspace as any other open does. + const target = restartMachineTarget(machine.machine) + await activateAiVaultStructuredSession( + { structuredSession: { workspaceId: failure.workspaceId, sessionId } }, + undefined, + target.kind === 'local' ? undefined : target, + machine.offer.fence + ) +} 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 index 9f1b4c126aea..3d23cdd33753 100644 --- a/src/renderer/src/components/native-chat-resume-finished-run.test.tsx +++ b/src/renderer/src/components/native-chat-resume-finished-run.test.tsx @@ -6,15 +6,15 @@ import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStat import { createResumeModalFixture, type RestartRpc, - type ResumeStatusStream + type ResumeStatusStream, + readLocalOffer } from './native-chat-resume-modal.test-support' import { lastToastShow } from './native-chat-resume-toast.test-support' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' import { - _resetNativeChatRestartOffer, - getNativeChatRestartRun, - refreshNativeChatRestartOffer, - releaseFinishedNativeChatRestartRun -} from './native-chat-resume-on-restart-store' + getNativeChatRestartRuns, + releaseFinishedNativeChatRestartRuns +} from './native-chat-restart-runs' import { consumeNativeChatResumeOnRestartDialogRequest, requestNativeChatResumeOnRestartDialog @@ -29,6 +29,7 @@ const statusStream: ResumeStatusStream = vi.hoisted(() => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', subscribeStructuredAgentSessionStatus: async ( _target: unknown, emit: ResumeStatusStream['emit'] @@ -38,7 +39,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ return { unsubscribe: () => {} } } })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) const { mount, button, fakeHost, runStatus, toasts } = createResumeModalFixture(rpc, statusStream) const dialog = () => document.querySelector('[role="dialog"]')?.textContent @@ -162,7 +163,7 @@ it('does not consume a finished run if its requested view never rendered', async await act(async () => { requestNativeChatResumeOnRestartDialog() consumeNativeChatResumeOnRestartDialogRequest() - releaseFinishedNativeChatRestartRun() + releaseFinishedNativeChatRestartRuns() }) expect(dialog()).toBeUndefined() await act(async () => lastToastShow()?.()) @@ -183,8 +184,8 @@ it('keeps the run out of settings and storage and loses it on a fresh renderer s expect(storage).not.toHaveBeenCalled() await mount(null) _resetNativeChatRestartOffer() - expect(getNativeChatRestartRun()).toBeNull() - await refreshNativeChatRestartOffer() + expect(getNativeChatRestartRuns().size).toBe(0) + await readLocalOffer() await mountSurfaces() await act(async () => button('1 chat failed to resume').click()) expect(dialog()).toContain('Resume interrupted chats?') diff --git a/src/renderer/src/components/native-chat-resume-machine-runs.test.tsx b/src/renderer/src/components/native-chat-resume-machine-runs.test.tsx new file mode 100644 index 000000000000..0440a62744c7 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-machine-runs.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } 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 { + consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' +import { + forgetNativeChatRestartMachine, + getNativeChatRestartOffers, + readNativeChatRestartMachine +} from './native-chat-resume-on-restart-store' +import { getNativeChatRestartRuns } from './native-chat-restart-runs' +import { continueNativeChatRestartOffers } from './native-chat-restart-offer-actions' +import { reopenNativeChatRestartOffer } from './native-chat-restart-offer-reopen' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' +import { pairedEnvironment } from './native-chat-restart-offer-test-support' +import { machineRowFixture as row } from './native-chat-resume-machines.test-support' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement + +const STUDIO = { kind: 'environment', environmentId: 'studio' } as const +const LOCAL = { kind: 'local' } as const + +beforeEach(() => { + rpc.mockReset() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalNativeChat: false }, + runtimeEnvironments: [pairedEnvironment('studio', 'studio-mac')] + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() +}) + +// Unpairing a server whose finished run is all the open dialog shows leaves nothing to draw, so +// the request goes too: a later read must never pop the dialog up by itself. +it('retires the dialog request when the only run it shows belongs to a server that is unpaired', async () => { + let localRows: unknown[] = [] + rpc.mockImplementation(async (target, method, params) => { + if (method === 'agentSession.restartResumable') { + return { sessions: target.kind === 'local' ? localRows : [row('s1', 'own')], failed: [] } + } + if (method === 'agentSession.restartContinue') { + return { + sessions: [], + failed: [], + continued: params.sessionIds.map((sessionId: string) => ({ + sessionId, + outcome: 'continued' + })) + } + } + return {} + }) + await act(async () => + root.render( + + + + ) + ) + await act(async () => { + await readNativeChatRestartMachine(STUDIO) + await continueNativeChatRestartOffers([{ machine: 'environment:studio', sessionIds: ['s1'] }]) + }) + expect(getNativeChatRestartOffers().size).toBe(0) + expect(getNativeChatRestartRuns().size).toBe(1) + // The success toast's Show (or the user reopening) opens the finished run. + await act(async () => requestNativeChatResumeOnRestartDialog('environment:studio')) + expect(document.body.textContent).toContain('Resumed 1 of 1 chat') + // The resumed chat has left the listing; its origin comes from the run's own row. + expect(document.body.textContent).not.toContain('Unknown origin') + // Server unpaired / re-paired / contact revoked while the dialog is up. + await act(async () => forgetNativeChatRestartMachine('environment:studio')) + expect(getNativeChatRestartRuns().size).toBe(0) + expect(document.body.textContent).not.toContain('Resumed 1 of 1 chat') + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() + localRows = [row('l1', 'own')] + await act(async () => { + await readNativeChatRestartMachine(LOCAL) + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() +}) + +// A new resume retires an earlier resume's finished run, so the dialog and the status bar count the +// same chats. +it('counts one resume in the dialog and the status bar alike, not an earlier finished one', async () => { + rpc.mockImplementation(async (target, method, params) => { + if (method === 'agentSession.restartResumable') { + return { + sessions: + target.kind === 'local' ? [row('l1', 'own'), row('l2', 'own')] : [row('s1', 'own')], + failed: [] + } + } + if (method === 'agentSession.restartContinue') { + if (target.kind === 'environment') { + return new Promise(() => {}) + } + return { + sessions: [], + failed: [], + continued: params.sessionIds.map((sessionId: string) => ({ + sessionId, + outcome: 'continued' + })) + } + } + return {} + }) + await act(async () => + root.render( + + + + + ) + ) + await act(async () => { + await readNativeChatRestartMachine(LOCAL) + await readNativeChatRestartMachine(STUDIO) + await continueNativeChatRestartOffers([{ machine: 'local', sessionIds: ['l1', 'l2'] }]) + void continueNativeChatRestartOffers([{ machine: 'environment:studio', sessionIds: ['s1'] }]) + }) + await act(async () => requestNativeChatResumeOnRestartDialog(null)) + const text = document.body.textContent ?? '' + expect(document.querySelector('[role="dialog"] h2')?.textContent).toContain('Resuming 1 chat') + expect(text).toContain('0 of 1 done') + expect(text).toContain('Resuming chats 0/1') +}) + +// Only the resuming machine's re-read is skipped; another machine is read before the dialog opens. +it('re-reads a machine on reopen while another machine is mid-resume', async () => { + let localRows = [row('l1', 'own')] + rpc.mockImplementation(async (target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: target.kind === 'local' ? localRows : [row('s1', 'own')], failed: [] } + } + if (method === 'agentSession.restartContinue') { + return new Promise(() => {}) + } + return {} + }) + await act(async () => { + await readNativeChatRestartMachine(LOCAL) + await readNativeChatRestartMachine(STUDIO) + void continueNativeChatRestartOffers([{ machine: 'environment:studio', sessionIds: ['s1'] }]) + }) + localRows = [] + const before = rpc.mock.calls.filter((call) => call[1] === 'agentSession.restartResumable').length + await act(async () => { + await reopenNativeChatRestartOffer(['local']) + }) + const after = rpc.mock.calls.filter((call) => call[1] === 'agentSession.restartResumable').length + expect(after - before).toBe(1) + expect(getNativeChatRestartOffers().has('local')).toBe(false) +}) diff --git a/src/renderer/src/components/native-chat-resume-machine-views.ts b/src/renderer/src/components/native-chat-resume-machine-views.ts new file mode 100644 index 000000000000..f2e0b3538a2e --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-machine-views.ts @@ -0,0 +1,92 @@ +import { useMemo } from 'react' +import { useAppStore } from '../store' +import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { NativeChatRestartMachineOffer } from './native-chat-resume-on-restart-store' +import { LOCAL_RESTART_MACHINE, type RestartMachineKey } from './native-chat-restart-machines' +import { restartMachineNameFromState } from './native-chat-restart-machine-name' +import { resumeCandidateOwnership } from './native-chat-resume-ownership' +import { restartListingIdentity, type ResumeSelectionMachine } from './native-chat-resume-selection' +import { getNativeChatRestartRuns, type NativeChatRestartRuns } from './native-chat-restart-runs' + +export type MachineView = ResumeSelectionMachine & { + offer: NativeChatRestartMachineOffer + name: string +} + +/** Every machine with offers, plus one whose run outlived its offers, listed as it was then. */ +function listedOffers( + offers: ReadonlyMap, + runs: NativeChatRestartRuns +): ReadonlyMap { + const listed = new Map(offers) + for (const [machine, entry] of runs) { + if (!listed.has(machine)) { + listed.set(machine, { ...entry.listing, candidates: [], failed: [] }) + } + } + return listed +} + +/** This computer first, then each paired server by name. */ +function orderedOffers( + offers: ReadonlyMap, + nameOf: (machine: RestartMachineKey) => string +): NativeChatRestartMachineOffer[] { + return [...offers.values()].sort((left, right) => + left.machine === LOCAL_RESTART_MACHINE + ? -1 + : right.machine === LOCAL_RESTART_MACHINE + ? 1 + : nameOf(left.machine).localeCompare(nameOf(right.machine)) + ) +} + +/** Each listed machine, ordered, with its name and each chat's ownership as its host judged it. + * Names are selected as one joined string so the selector returns a PRIMITIVE and the dialog + * re-renders only when a name actually changes. */ +export function useMachineViews( + currentOffers: ReadonlyMap, + runs: NativeChatRestartRuns +): MachineView[] { + // Keyed on which machines have a run, not on each progress frame of one. + const runMachines = [...runs.keys()].join('\u0000') + const offers = useMemo( + () => (runMachines ? listedOffers(currentOffers, getNativeChatRestartRuns()) : currentOffers), + [currentOffers, runMachines] + ) + const joined = useAppStore((state) => + [...offers.keys()] + .map((machine) => `${machine}\u0001${restartMachineNameFromState(state, machine)}`) + .join('\u0002') + ) + return useMemo(() => { + const names = new Map( + joined + .split('\u0002') + .filter(Boolean) + .map((entry) => { + const [machine = '', name = ''] = entry.split('\u0001') + return [machine, name] as const + }) + ) + return orderedOffers(offers, (machine) => names.get(machine) ?? machine).map((offer) => { + const rows = [...offer.candidates, ...offer.failed] + const rowById = new Map(rows.map((row) => [row.sessionId, row])) + const failureById = new Map( + offer.failed.map((failure) => [failure.sessionId, failure]) + ) + return { + machine: offer.machine, + identity: restartListingIdentity(offer.machine, offer.fence.pairingRevision), + offer, + name: names.get(offer.machine) ?? offer.machine, + rows, + failureFor: (sessionId: string) => failureById.get(sessionId), + ownershipFor: (sessionId: string) => { + const row = rowById.get(sessionId) + return row ? resumeCandidateOwnership(row) : 'unknown' + } + } + }) + }, [joined, offers]) +} diff --git a/src/renderer/src/components/native-chat-resume-machines.test-support.ts b/src/renderer/src/components/native-chat-resume-machines.test-support.ts new file mode 100644 index 000000000000..53ace618c93f --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-machines.test-support.ts @@ -0,0 +1,48 @@ +// Fixtures and DOM lookups shared by the resume dialog's per-machine test files. + +import type { RestartOfferOrigin } from '../../../shared/restart-offer-origin' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' + +/** One chat per workspace; each host says whose a chat is for this desktop. */ +export function machineRowFixture(sessionId: string, origin: RestartOfferOrigin): ResumeCandidate { + return { + sessionId, + workspaceId: `workspace-${sessionId}`, + agent: 'codex', + trigger: 'update', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1_800_000_000_000, + executionHostId: 'local', + workspaceKind: 'git-worktree', + origin + } +} + +export function machineToggle(name: string): HTMLElement { + const found = document.querySelector( + `[role="checkbox"][aria-label="Select all chats on ${name}"]` + ) + if (!found) { + throw new Error(`Missing machine checkbox: ${name}`) + } + return found +} + +/** The machine's tree node: its row carries the name, why it stopped, the count and whether it is + * open (`aria-expanded`). */ +export function machineRow(name: string): HTMLElement { + const found = machineToggle(name).closest('[role="treeitem"]') + if (!found) { + throw new Error(`Missing machine row: ${name}`) + } + return found +} + +/** The arrow that opens or closes the machine's node. */ +export function machineDisclosure(name: string): HTMLButtonElement { + const found = machineRow(name).querySelector('button[aria-expanded]') + if (!found) { + throw new Error(`Missing machine disclosure: ${name}`) + } + return found +} diff --git a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx index 3fc6d9f70218..92bcdcf2fe43 100644 --- a/src/renderer/src/components/native-chat-resume-modal.test-support.tsx +++ b/src/renderer/src/components/native-chat-resume-modal.test-support.tsx @@ -11,11 +11,12 @@ import type { 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 + getNativeChatRestartOffers, + readNativeChatRestartMachine } from './native-chat-resume-on-restart-store' +import { continueNativeChatRestartOffers } from './native-chat-restart-offer-actions' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' export const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ sessionId, @@ -25,9 +26,27 @@ export const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ latestPrompt: `Prompt ${sessionId}`, recordedAt: 1_800_000_000_000, executionHostId: 'local', - workspaceKind: 'git-worktree' + workspaceKind: 'git-worktree', + origin: 'own' })) +/** This computer's listing, empty when it lists nothing. */ +export function localOffer(): { + candidates: readonly ResumeCandidate[] + failed: readonly ResumeFailure[] +} { + return getNativeChatRestartOffers().get('local') ?? { candidates: [], failed: [] } +} + +export async function readLocalOffer(): Promise { + await readNativeChatRestartMachine({ kind: 'local' }) +} + +/** One resume of this computer's named chats, as a click or an opted-in launch sends it. */ +export function continueLocal(sessionIds: readonly string[]): Promise { + return continueNativeChatRestartOffers([{ machine: 'local', sessionIds }]) +} + /** A chat the host acted on and could not carry on, as it reports it. */ export function failure( sessionId: string, @@ -80,7 +99,13 @@ export function createResumeModalFixture(rpc: Mock, statusStream: Re } function offerIds(): string[] { - return getNativeChatRestartOffer().candidates.map((candidate) => candidate.sessionId) + return (getNativeChatRestartOffers().get('local')?.candidates ?? []).map( + (candidate) => candidate.sessionId + ) + } + + function failedIds(): string[] { + return (getNativeChatRestartOffers().get('local')?.failed ?? []).map((entry) => entry.sessionId) } /** What each toast said: its title, and its description when it has one. */ @@ -207,7 +232,6 @@ export function createResumeModalFixture(rpc: Mock, statusStream: Re statusStream.snapshot.clear() statusStream.emit = () => {} _resetNativeChatRestartOffer() - consumeNativeChatResumeOnRestartDialogRequest() vi.mocked(toast).mockClear() useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ @@ -228,7 +252,6 @@ export function createResumeModalFixture(rpc: Mock, statusStream: Re container.remove() useAppStore.setState(useAppStore.getInitialState(), true) _resetNativeChatRestartOffer() - consumeNativeChatResumeOnRestartDialogRequest() }) - return { mount, button, checkbox, offerIds, toasts, fakeHost, runStatus, calls } + return { mount, button, checkbox, offerIds, failedIds, toasts, fakeHost, runStatus, calls } } diff --git a/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts b/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts index 4b139d1c2a34..d1766dd928c5 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts @@ -1,4 +1,13 @@ -let pendingOpen = false +import type { RestartMachineKey } from './native-chat-restart-machines' + +/** An open request, and the machine it was opened for (that machine's row starts expanded). */ +export type NativeChatResumeOnRestartDialogRequest = Readonly<{ + focus: RestartMachineKey | null +}> + +let pending: NativeChatResumeOnRestartDialogRequest | null = null +/** How many resume dialogs are mounted to draw a request (the app mounts one). */ +let mountedDialogs = 0 const listeners = new Set<() => void>() function notify(): void { @@ -7,23 +16,45 @@ function notify(): void { } } -// Why: the launch load and the status-bar entry both open this dialog, and either can fire before -// it subscribes. Keeping the request as an external snapshot prevents mount ordering from losing it. -export function requestNativeChatResumeOnRestartDialog(): void { - pendingOpen = true +// Why: the launch load, the status-bar entry and a reconnect toast all open this dialog, and any +// can fire before it subscribes. Keeping the request as an external snapshot prevents mount +// ordering from losing it. +export function requestNativeChatResumeOnRestartDialog( + focus: RestartMachineKey | null = null +): void { + // An open dialog stays as it is: a later request (this computer's launch read landing under it) + // would move its focus and reset the user's ticks, and the dialog lists every machine anyway. + if (pending) { + return + } + pending = { focus } notify() } export function consumeNativeChatResumeOnRestartDialogRequest(): void { - if (!pendingOpen) { + if (!pending) { return } - pendingOpen = false + pending = null notify() } -export function getNativeChatResumeOnRestartDialogRequest(): boolean { - return pendingOpen +export function getNativeChatResumeOnRestartDialogRequest(): NativeChatResumeOnRestartDialogRequest | null { + return pending +} + +/** The dialog's own mount; returns its unmount. */ +export function mountNativeChatResumeDialog(): () => void { + mountedDialogs += 1 + return () => { + mountedDialogs -= 1 + } +} + +/** Whether a mounted dialog would draw a request right now, given whether any machine lists rows. + * Read at the moment of asking, from the state the dialog renders from: never a copy of it. */ +export function nativeChatResumeDialogOnScreen(rowsListed: boolean): boolean { + return pending !== null && rowsListed && mountedDialogs > 0 } export function subscribeNativeChatResumeOnRestartDialog(listener: () => void): () => void { @@ -32,3 +63,8 @@ export function subscribeNativeChatResumeOnRestartDialog(listener: () => void): listeners.delete(listener) } } + +/** @internal - tests need a clean module between cases. */ +export function _resetNativeChatResumeOnRestartDialog(): void { + pending = null +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts index 76c90706ba80..b4c7e7a88481 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts @@ -5,6 +5,7 @@ import { projectGroupIdFromRepoId } from '../../../shared/folder-workspace-workt import type { RepoIcon } from '../../../shared/repo-icon' import type { AgentSessionRestartActivity } from '../../../shared/agent-session-restart-activity' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import type { RestartOfferOrigin } from '../../../shared/restart-offer-origin' /** * The offered chats, arranged the way the sidebar arranges workspaces: project/repo, then workspace, @@ -29,6 +30,8 @@ export type ResumeCandidate = { model?: string /** What the chat was doing, snapshotted by the host as it stopped; an older host omits it. */ activity?: AgentSessionRestartActivity + /** Whose chat it is for this desktop, judged by the host; absent from a host that predates it. */ + origin?: RestartOfferOrigin } /** An offer that was acted on and did not end with the agent carrying on. The host keeps it until diff --git a/src/renderer/src/components/native-chat-resume-on-restart-launch-prompt.ts b/src/renderer/src/components/native-chat-resume-on-restart-launch-prompt.ts new file mode 100644 index 000000000000..12fe419bec62 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-on-restart-launch-prompt.ts @@ -0,0 +1,10 @@ +import { LOCAL_RESTART_MACHINE } from './native-chat-restart-machines' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' + +/** + * The one way the resume dialog opens BY ITSELF: this computer's own launch found the user's chats + * to resume. Every other opening follows a click (status bar, toast). + */ +export function requestLaunchResumePrompt(): void { + requestNativeChatResumeOnRestartDialog(LOCAL_RESTART_MACHINE) +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-modal.test-support.ts b/src/renderer/src/components/native-chat-resume-on-restart-modal.test-support.ts index 993a170ba0f1..ae5516f8a1ab 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-modal.test-support.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-modal.test-support.ts @@ -10,7 +10,8 @@ export const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ latestPrompt: `Prompt ${sessionId}`, recordedAt: 1_800_000_000_000, executionHostId: 'local', - workspaceKind: 'git-worktree' + workspaceKind: 'git-worktree', + origin: 'own' })) /** A chat the host acted on and could not carry on, as it reports it. */ 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 068e9ea95a78..3d1ccbef1ba2 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 @@ -1,25 +1,36 @@ import { toast } from 'sonner' import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' import { lastToastShow } from './native-chat-resume-toast.test-support' import { consumeNativeChatResumeOnRestartDialogRequest, - getNativeChatResumeOnRestartDialogRequest + getNativeChatResumeOnRestartDialogRequest, + subscribeNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' import { - _resetNativeChatRestartOffer, - continueNativeChatRestartOffer, - getNativeChatRestartOffer, - refreshNativeChatRestartOffer + forgetNativeChatRestartMachine, + getNativeChatRestartOffers, + readNativeChatRestartMachine } from './native-chat-resume-on-restart-store' +import { continueNativeChatRestartOffers } from './native-chat-restart-offer-actions' +import { _resetNativeChatRestartOffer } from './native-chat-restart-offer-triggers' +import { reopenNativeChatRestartOffer } from './native-chat-restart-offer-reopen' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { pairedEnvironment, verifiedConnection } from './native-chat-restart-offer-test-support' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', // A failed row opens the status feed; these cases never drive it. subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) + +const LOCAL = { kind: 'local' } as const +const SERVER = 'studio' +const STUDIO = { kind: 'environment', environmentId: SERVER } as const const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ sessionId, @@ -27,9 +38,45 @@ const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ agent: 'codex', trigger: 'quit', latestPrompt: `Prompt ${sessionId}`, - recordedAt: 1_800_000_000_000 + recordedAt: 1_800_000_000_000, + origin: 'own' })) +function failedIds(machine = 'local'): string[] { + return (getNativeChatRestartOffers().get(machine)?.failed ?? []).map((entry) => entry.sessionId) +} + +function offerIds(machine = 'local'): string[] { + return (getNativeChatRestartOffers().get(machine)?.candidates ?? []).map( + (entry) => entry.sessionId + ) +} + +function pairStudio(pairingRevision = 1): void { + replaceRuntimeEnvironmentRevisions([{ id: SERVER, createdAt: 1, pairingRevision }]) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment(SERVER, 'studio-mac')], + runtimeEnvironmentCatalogHydrated: true, + runtimeStatusByEnvironmentId: new Map([ + [ + SERVER, + verifiedConnection({ environmentId: SERVER, runtimeId: 'runtime-1', pairingRevision }) + ] + ]) + }) +} + +/** Every change to the dialog request from here on, so an open that closes again is seen. */ +function recordDialogRequests(): unknown[] { + const seen: unknown[] = [] + unsubscribe = subscribeNativeChatResumeOnRestartDialog(() => { + seen.push(getNativeChatResumeOnRestartDialogRequest()) + }) + return seen +} + +let unsubscribe = () => {} + beforeEach(() => { rpc.mockReset() _resetNativeChatRestartOffer() @@ -39,6 +86,8 @@ beforeEach(() => { }) afterEach(() => { + unsubscribe() + unsubscribe = () => {} vi.mocked(console.warn).mockRestore() _resetNativeChatRestartOffer() }) @@ -55,62 +104,53 @@ function hostLostAfterListing(): void { }) } -/** Clicks and opted-in launches use the same bulk action over their selection. */ -const resumes = { - click: () => continueNativeChatRestartOffer(['a', 'b']), - launch: () => continueNativeChatRestartOffer(offered.map((candidate) => candidate.sessionId)) -} +// A click and an opted-in automatic resume both name the user's own chats, so they share one path. +const resumeBoth = () => + continueNativeChatRestartOffers([{ machine: 'local', sessionIds: ['a', 'b'] }]) // With the host unreachable there is no list to narrow by: every chat the resume reported failed, // and nothing is listed for Show to open. -it.each(['click', 'launch'] as const)( - 'counts every chat of a lost %s resume when the host cannot be read either', - async (caller) => { - hostLostAfterListing() - await refreshNativeChatRestartOffer() - await resumes[caller]() - expect(vi.mocked(toast).mock.calls).toEqual([['2 chats couldn’t be resumed', {}]]) - } -) +it('counts every chat of a lost resume when the host cannot be read either', async () => { + hostLostAfterListing() + await readNativeChatRestartMachine(LOCAL) + await resumeBoth() + expect(vi.mocked(toast).mock.calls).toEqual([['2 chats couldn’t be resumed', {}]]) +}) // The re-read lists the chats the lost request reported as failed, so the one toast keeps Show. -it('answers an opted-in launch that loses its resume request with one toast and Show', async () => { +it('answers a resume that loses its request with one toast and Show', async () => { rpc.mockImplementation(async (_target, method) => { if (method === 'agentSession.restartResumable') { return { sessions: offered, failed: [] } } throw new Error('response lost') }) - await refreshNativeChatRestartOffer() - await resumes.launch() - expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a', 'b']) + await readNativeChatRestartMachine(LOCAL) + await resumeBoth() + expect(failedIds()).toEqual(['a', 'b']) expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ '2 chats couldn’t be resumed' ]) expect(lastToastShow()).toBeDefined() }) -// 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 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() +// The chats it resumed ride along under the one it could not. +it('answers a mixed resume 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 + } + ) + await readNativeChatRestartMachine(LOCAL) + await resumeBoth() expect(vi.mocked(toast).mock.calls).toEqual([ ['1 chat couldn’t be resumed', expect.objectContaining({ description: 'Resumed 1 chat' })] ]) @@ -118,18 +158,19 @@ it('answers a mixed opted-in launch with one toast', async () => { }) // Between the listing and the request every chat moved on by itself: nothing happened to report. -it('raises no toast when the host had nothing left for an opted-in launch to resume', async () => { +it('raises no toast when the host had nothing left to resume', async () => { rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' ? { sessions: offered, failed: [] } : { continued: [], sessions: [], failed: [] } ) - await refreshNativeChatRestartOffer() - await resumes.launch() + await readNativeChatRestartMachine(LOCAL) + await resumeBoth() expect(toast).not.toHaveBeenCalled() }) -// A withdrawn failure cannot recreate attention, but the unseen run summary stays available. +// The agent was seen carrying on while the toast was up, so the host retired the failure. That +// cannot bring the attention back, but the run's summary was never seen, so Show still opens it. 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) => @@ -137,15 +178,207 @@ it('opens the retained summary from Show after the host withdraws a failure', as ? { sessions: [], failed } : { continued: [{ sessionId: 'a', outcome: 'unknown' }], sessions: [], failed } ) - await refreshNativeChatRestartOffer() - await continueNativeChatRestartOffer(['a']) + await readNativeChatRestartMachine(LOCAL) + await continueNativeChatRestartOffers([{ machine: 'local', sessionIds: ['a'] }]) expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ 'Couldn’t confirm 1 chat was resumed' ]) failed = [] + const requests = recordDialogRequests() lastToastShow()?.() await new Promise((resolve) => setTimeout(resolve, 0)) expect(rpc.mock.calls.at(-1)?.[1]).toBe('agentSession.restartResumable') - expect(getNativeChatRestartOffer().failed).toEqual([]) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + expect(getNativeChatRestartOffers().get('local')).toBeUndefined() + expect(requests).toEqual([{ focus: 'local' }]) +}) + +// Session ids are each host's own: a lost request to one machine marks only that machine's rows. +it('marks a lost paired resume on that server only, never a same-id chat here', async () => { + pairStudio() + rpc.mockImplementation(async (target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: offered, failed: [] } + } + if (target.kind === 'environment') { + throw new Error('response lost') + } + return { continued: [], sessions: offered, failed: [] } + }) + await readNativeChatRestartMachine(LOCAL) + await readNativeChatRestartMachine(STUDIO) + await continueNativeChatRestartOffers([{ machine: `environment:${SERVER}`, sessionIds: ['a'] }]) + expect(failedIds(`environment:${SERVER}`)).toEqual(['a']) + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ + '1 chat on studio-mac couldn’t be resumed' + ]) + await readNativeChatRestartMachine(LOCAL) + expect(offerIds()).toEqual(['a', 'b']) + expect(failedIds()).toEqual([]) +}) + +// Re-paired or forgotten, the server's marks go with its offers: a later pairing starts clean. +it('ends a server’s marks when the desktop forgets it', async () => { + pairStudio() + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: offered, failed: [] } + } + throw new Error('response lost') + }) + await readNativeChatRestartMachine(STUDIO) + await continueNativeChatRestartOffers([{ machine: `environment:${SERVER}`, sessionIds: ['a'] }]) + expect(failedIds(`environment:${SERVER}`)).toEqual(['a']) + forgetNativeChatRestartMachine(`environment:${SERVER}`) + await readNativeChatRestartMachine(STUDIO) + expect(offerIds(`environment:${SERVER}`)).toEqual(['a', 'b']) + expect(failedIds(`environment:${SERVER}`)).toEqual([]) +}) + +// The host reserved the chats and carries on with them, but the answer never came back: the reserved +// chats leave its list, so nothing may count them as failed or drop them. One toast still says so. +it('counts a paired resume whose answer was lost as unconfirmed once the server stops listing it', async () => { + pairStudio() + let reserved = false + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: reserved ? [] : offered, failed: [] } + } + reserved = true + throw Object.assign(new Error('Timed out waiting for runtime response'), { + code: 'runtime_timeout' + }) + }) + await readNativeChatRestartMachine(STUDIO) + await continueNativeChatRestartOffers([ + { machine: `environment:${SERVER}`, sessionIds: ['a', 'b'] } + ]) + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ + 'Couldn’t confirm 2 chats on studio-mac were resumed' + ]) + expect(lastToastShow()).toBeDefined() +}) + +// The user's click is never lost to bookkeeping: a server out of contact opens on its last listing, +// without a read that would hold the dialog back until it timed out. +it('opens a disconnected server’s last listing from Show without reading it', async () => { + pairStudio() + rpc.mockResolvedValue({ sessions: offered, failed: [] }) + await readNativeChatRestartMachine(STUDIO) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + const reads = rpc.mock.calls.length + await reopenNativeChatRestartOffer([`environment:${SERVER}`]) + expect(rpc.mock.calls).toHaveLength(reads) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: `environment:${SERVER}` }) +}) + +it('opens a connected server’s last listing when its re-read fails', async () => { + pairStudio() + rpc.mockResolvedValueOnce({ sessions: offered, failed: [] }) + await readNativeChatRestartMachine(STUDIO) + rpc.mockRejectedValue(new Error('host unreachable')) + const requests = recordDialogRequests() + await reopenNativeChatRestartOffer([`environment:${SERVER}`]) + expect(rpc.mock.calls.at(-1)?.[1]).toBe('agentSession.restartResumable') + expect(requests).toEqual([{ focus: `environment:${SERVER}` }]) +}) + +it('opens nothing for a connected server whose re-read lists nothing', async () => { + pairStudio() + rpc.mockResolvedValueOnce({ sessions: offered, failed: [] }) + await readNativeChatRestartMachine(STUDIO) + rpc.mockResolvedValue({ sessions: [], failed: [] }) + const requests = recordDialogRequests() + await reopenNativeChatRestartOffer([`environment:${SERVER}`]) + expect(requests).toEqual([]) +}) + +// The server took the request, then the link dropped: neither the answer nor a re-read came back. +// It may be continuing every chat, so none is reported as failed, which would invite a second send. +it('counts a paired resume as unconfirmed when its answer and the re-read are both lost', async () => { + pairStudio() + let down = false + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable' && !down) { + return { sessions: offered, failed: [] } + } + down = true + throw new Error('Remote runtime connection closed') + }) + await readNativeChatRestartMachine(STUDIO) + await continueNativeChatRestartOffers([ + { machine: `environment:${SERVER}`, sessionIds: ['a', 'b'] } + ]) + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ + 'Couldn’t confirm 2 chats on studio-mac were resumed' + ]) + // The last listing still names them, so Show has something to open. + expect(lastToastShow()).toBeDefined() +}) + +it('offers no Show for a lost paired resume once nothing of that server is listed', async () => { + pairStudio() + rpc.mockResolvedValueOnce({ sessions: offered, failed: [] }) + await readNativeChatRestartMachine(STUDIO) + rpc.mockImplementation(async () => { + // Re-paired while the call was out: the old pairing's listing is gone with it. + forgetNativeChatRestartMachine(`environment:${SERVER}`) + throw new Error('Remote runtime connection closed') + }) + await continueNativeChatRestartOffers([{ machine: `environment:${SERVER}`, sessionIds: ['a'] }]) + expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([ + 'Couldn’t confirm 1 chat on studio-mac was resumed' + ]) + expect(lastToastShow()).toBeUndefined() +}) + +/** Transport up, but the server's runtime did not answer its status probe. */ +function studioRuntimeUnavailable(): void { + const entry = useAppStore.getState().runtimeStatusByEnvironmentId.get(SERVER)! + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + [SERVER, { ...entry, snapshot: { ...entry.snapshot!, verification: 'unavailable' } }] + ]) + }) +} + +it('opens at once on the last listing of a server whose runtime is not answering', async () => { + pairStudio() + rpc.mockResolvedValueOnce({ sessions: offered, failed: [] }) + await readNativeChatRestartMachine(STUDIO) + studioRuntimeUnavailable() + const reads = rpc.mock.calls.length + await reopenNativeChatRestartOffer([`environment:${SERVER}`]) + expect(rpc.mock.calls).toHaveLength(reads) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: `environment:${SERVER}` }) +}) + +// A click is never held for long by a slow server: past the budget the dialog opens on the last +// listing, and the late answer lands in it like any other read, keeping the same opening. +it('opens on the last listing once a connected server’s read outlasts its budget', async () => { + vi.useFakeTimers() + try { + pairStudio() + rpc.mockResolvedValueOnce({ sessions: offered, failed: [] }) + await readNativeChatRestartMachine(STUDIO) + let answer: (value: unknown) => void = () => {} + rpc.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const opening = reopenNativeChatRestartOffer([`environment:${SERVER}`]) + await vi.advanceTimersByTimeAsync(1_000) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() + await vi.advanceTimersByTimeAsync(500) + await opening + const opened = getNativeChatResumeOnRestartDialogRequest() + expect(opened).toEqual({ focus: `environment:${SERVER}` }) + answer({ sessions: [offered[1]!], failed: [] }) + await vi.advanceTimersByTimeAsync(0) + expect(offerIds(`environment:${SERVER}`)).toEqual(['b']) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(opened) + } finally { + vi.useRealTimers() + } }) 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 33e5faa58791..58e57969eedb 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,73 +1,110 @@ -import { useEffect, useSyncExternalStore } from 'react' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' -import { useAppStore } from '../store' -import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' +import { useSyncExternalStore } from 'react' import { - announceRestartResults, - restartContinuationHistory, - type RestartContinuationOutcome -} from './native-chat-restart-action-notifications' + callStructuredAgentSession, + pairedRestartOffersSupport +} from '@/runtime/structured-agent-session-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' import { - allResumeSessionIds, - type ResumeCandidate, - type ResumeFailure -} from './native-chat-resume-on-restart-grouping' + failedFrom, + hostCannotOffer, + type HostOfferPayload +} from './native-chat-restart-offer-payload' +import { consumeNativeChatResumeOnRestartDialogRequest } from './native-chat-resume-on-restart-dialog' import { - beginResumeRun, - resumeRunInFlight, - resumeRunPendingIds, - type ResumeRun -} from './native-chat-resume-run' + projectRestartMachineRows, + restartMachineKey, + restartMachineTarget, + type RestartMachineKey +} from './native-chat-restart-machines' import { + currentRestartMachineFence, + restartMachineCallFence, + sameRestartMachineFence, + type RestartMachineFence +} from './native-chat-restart-machine-fence' +import { + releaseOfferedChatWatches, + syncOfferedChatWatch +} from './native-chat-restart-offer-activity-watch' +import { + _resetUnsentResumes, forgetUnsentResumes, - markUnsentResumes, withUnsentResumes } from './native-chat-resume-unsent-requests' 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' + _resetNativeChatRestartRuns, + beginNativeChatRestartRun, + forgetNativeChatRestartRun, + getNativeChatRestartRuns, + type RestartRunAnswer +} from './native-chat-restart-runs' + +export { + getNativeChatRestartResuming, + useNativeChatRestartResuming +} from './native-chat-restart-runs' /** - * Which interrupted chats the host is still offering to resume, and every action that moves that. + * Which interrupted chats each machine is still offering to resume, and every action that moves + * that. A machine is this computer or one paired server; each answers for its own chats. * - * The offer is the HOST's answer, shared by the dialog and the status bar rather than held by - * whichever rendered first. Opening a chat is intentionally read-only; only an explicit action - * changes the durable offer. + * The offer is each HOST's answer, shared by the dialog, the status bar and the reconnect toast + * rather than held by whichever rendered first. Opening a chat is intentionally read-only; only an + * explicit action changes the durable offer. * - * What stays on this side is the user's own facts: the snooze, and the preference that decides - * whether the launch asks at all. + * One publication rule per machine: every request (read, continue, dismiss) takes a ticket and the + * machine's pairing when it is issued, and its answer is published only if no later-issued request + * has published since and the machine is still paired the same way. */ -// Structured sessions run on the machine hosting the runtime; both launch resolvers refuse anything -// else, so there is no remote target to aim this at. -const LOCAL = { kind: 'local' } as const - -export type NativeChatRestartOffer = Readonly<{ +export type NativeChatRestartMachineOffer = Readonly<{ + machine: RestartMachineKey + target: RuntimeClientTarget + /** Host ids already in this desktop's terms (a paired server's `local` is its runtime host). */ candidates: readonly ResumeCandidate[] /** Acted-on offers whose agent did not carry on, as the host still records them. */ failed: readonly ResumeFailure[] + /** The pairing this answer came from; every action on it is sent with it. */ + fence: RestartMachineFence /** Stamped when the list arrived. Row ages read against this rather than a render-time * `Date.now()`, so they stay stable across re-renders and the render stays pure. */ listedAt: number }> -const EMPTY: NativeChatRestartOffer = { candidates: [], failed: [], listedAt: 0 } -let offer: NativeChatRestartOffer = EMPTY -let launch: Promise | undefined -/** Continue and dismiss calls, begun and settled. Each ends by publishing the host's answer, which - * a re-read that raced it must neither pre-empt nor undo. */ -let actionsBegun = 0 -let actionsSettled = 0 -/** 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) +export type NativeChatRestartOffers = ReadonlyMap + +/** `unavailable` is not an answer: the machine keeps the last one it gave. `unsupported` is an + * older or chat-less host that has no offers to give. `published` says whether this answer is the + * one the machine now shows; only that one may decide anything. */ +export type RestartMachineRead = + | { + kind: 'answered' + candidates: readonly ResumeCandidate[] + failed: readonly ResumeFailure[] + published: boolean + } + | { kind: 'unsupported' } + | { kind: 'unavailable' } + +/** A request's claim on publishing its answer: issue order, and the pairing it was sent under. */ +export type RestartMachineTicket = Readonly<{ + machine: RestartMachineKey + target: RuntimeClientTarget + ticket: number + fence: RestartMachineFence +}> + +const NO_OFFERS: NativeChatRestartOffers = new Map() +let offers: NativeChatRestartOffers = NO_OFFERS +const ticketsIssued = new Map() +const ticketsPublished = new Map() +/** Continue and dismiss calls in flight per machine; a chat-activity re-read waits for them. */ +const actionsInFlight = new Map() const listeners = new Set<() => void>() -const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const +let readListener: + | ((target: RuntimeClientTarget, candidates: readonly ResumeCandidate[]) => void) + | null = null function emit(): void { for (const listener of listeners) { @@ -75,318 +112,230 @@ function emit(): void { } } +function hasRows(offer: NativeChatRestartMachineOffer | undefined): boolean { + return Boolean(offer && (offer.candidates.length > 0 || offer.failed.length > 0)) +} + /** The snapshot object is replaced HERE and nowhere else — never during a render — so every * `useSyncExternalStore` reader sees the same reference until a host answer or a user action - * actually moves the offer. */ -function publish(next: NativeChatRestartOffer): void { - offer = next - offeredChatWatch.sync() + * actually moves an offer. */ +function publish(machine: RestartMachineKey, next: NativeChatRestartMachineOffer | null): void { + const updated = new Map(offers) + if (next && hasRows(next)) { + updated.set(machine, next) + } else { + updated.delete(machine) + } + offers = updated.size === 0 ? NO_OFFERS : updated + syncOfferedChatWatch(machine, offers.get(machine), refreshAfterOfferedChatActivity) emit() + retireRequestWithNothingToDraw() } -/** A confirmed host answer. One with nothing left also retires any open request for the dialog, - * 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 && run === null) { +/** With nothing left on any machine and no run to follow, an open request has nothing to show. */ +function retireRequestWithNothingToDraw(): void { + if (offers.size === 0 && getNativeChatRestartRuns().size === 0) { consumeNativeChatResumeOnRestartDialogRequest() } } -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() +export function issueNativeChatRestartTicket(target: RuntimeClientTarget): RestartMachineTicket { + const machine = restartMachineKey(target) + const ticket = (ticketsIssued.get(machine) ?? 0) + 1 + ticketsIssued.set(machine, ticket) + return { machine, target, ticket, fence: currentRestartMachineFence(target) } } -// 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) - } - } -}) - -export function getNativeChatRestartOffer(): NativeChatRestartOffer { - return offer +/** Whether the machine is still paired the way the request was sent. */ +export function restartTicketPairingCurrent(ticket: RestartMachineTicket): boolean { + return sameRestartMachineFence(ticket.fence, currentRestartMachineFence(ticket.target)) } -export function getNativeChatRestartResuming(): readonly string[] { - return resuming +/** Publishes under the one rule; false when a later request already published or the pairing moved. */ +function publishUnder( + ticket: RestartMachineTicket, + rows: { candidates: readonly ResumeCandidate[]; failed: readonly ResumeFailure[] } | null +): boolean { + if ( + ticket.ticket <= (ticketsPublished.get(ticket.machine) ?? 0) || + !restartTicketPairingCurrent(ticket) + ) { + return false + } + ticketsPublished.set(ticket.machine, ticket.ticket) + if (!rows) { + forgetUnsentResumes(ticket.machine, undefined) + } + const shown = rows && withUnsentResumes(ticket.machine, rows) + publish( + ticket.machine, + shown && { + machine: ticket.machine, + target: ticket.target, + fence: ticket.fence, + candidates: projectRestartMachineRows(ticket.target, shown.candidates), + failed: projectRestartMachineRows(ticket.target, shown.failed), + listedAt: Date.now() + } + ) + return true } -export function getNativeChatRestartRun(): ResumeRun | null { - return run +/** A host answer to an action; false when it was superseded and the caller should read again. */ +export function publishNativeChatRestartAnswer( + ticket: RestartMachineTicket, + candidates: readonly ResumeCandidate[], + failed: readonly ResumeFailure[] +): boolean { + return publishUnder(ticket, { candidates, failed }) } -/** 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 refreshAfterOfferedChatActivity(machine: RestartMachineKey): void { + // An action in flight ends with the host's answer, or a read of its own if a later one won. + if ((actionsInFlight.get(machine) ?? 0) === 0) { + void readNativeChatRestartMachine(restartMachineTarget(machine)) } } -export function releaseFinishedNativeChatRestartRun(): void { - if (run?.finishedViewShown && !resumeRunInFlight(run)) { - setRun(null) - } +export function getNativeChatRestartOffers(): NativeChatRestartOffers { + return offers } -function subscribe(listener: () => void): () => void { +export function subscribeNativeChatRestartOffers(listener: () => void): () => void { listeners.add(listener) return () => { listeners.delete(listener) } } -/** - * Re-reads the host's answer. - * - * Called before the dialog is reopened, so a count can never name a chat the host would now refuse. - */ -type HostOfferRead = { - candidates: readonly ResumeCandidate[] - failed: readonly ResumeFailure[] - available: boolean +/** Told of every read whose answer was published, so decisions follow the answer itself. */ +export function setNativeChatRestartReadListener( + listener: ((target: RuntimeClientTarget, candidates: readonly ResumeCandidate[]) => void) | null +): void { + readListener = listener } -/** The host's answer as this side understands it. `failed` is optional on the wire: an older host - * never sends it, and its absence means nothing to show, not an invalid answer. */ -type HostOfferPayload = { sessions?: unknown; failed?: unknown } - -function failedFrom(payload: HostOfferPayload): ResumeFailure[] { - // SAFETY: the host is the single writer of this shape; a malformed row is a host bug, not input. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above. - return Array.isArray(payload.failed) ? (payload.failed as ResumeFailure[]) : [] +async function readPairedSupport( + ticket: RestartMachineTicket, + environmentId: string +): Promise { + // Asking a paired host that cannot answer without building its chat host would open its journal + // on a server that may never have run a chat; such a host is never asked. + const support = await pairedRestartOffersSupport(environmentId) + if (!restartTicketPairingCurrent(ticket) || support === 'unknown') { + // Re-paired while probing, or the probe failed: nothing is known about this server. + return { kind: 'unavailable' } + } + if (support === 'unsupported') { + // A server rolled back to a build without it keeps nothing listed that it can no longer act on. + publishUnder(ticket, null) + return { kind: 'unsupported' } + } + return null } -async function readNativeChatRestartOffer(current = () => true): Promise { +/** + * Re-reads one machine's answer. + * + * A failed read is not an answer and leaves that machine's last answer in place: loss of contact + * is never evidence the offer is gone, and an action re-derives eligibility on the host anyway. + */ +export async function readNativeChatRestartMachine( + target: RuntimeClientTarget +): Promise { + // Taken before any await, so the probe and the read both answer for the pairing they asked. + const ticket = issueNativeChatRestartTicket(target) + if (target.kind === 'environment') { + const refused = await readPairedSupport(ticket, target.environmentId) + if (refused) { + return refused + } + } + const callFence = restartMachineCallFence(target, ticket.fence) try { - const offered = await callStructuredAgentSession( - LOCAL, - 'agentSession.restartResumable' - ) + const offered = await (callFence + ? callStructuredAgentSession( + target, + 'agentSession.restartResumable', + undefined, + callFence + ) + : callStructuredAgentSession(target, 'agentSession.restartResumable')) if (!Array.isArray(offered.sessions)) { throw new Error('agent_session_restart_offer_invalid') } + const candidates: ResumeCandidate[] = offered.sessions const failed = failedFrom(offered) - if (current()) { - publishAnswer({ candidates: offered.sessions, failed, listedAt: Date.now() }) + const published = publishUnder(ticket, { candidates, failed }) + const projected = projectRestartMachineRows(target, candidates) + if (published) { + readListener?.(target, projected) } - return { candidates: offered.sessions, failed, available: true } - } catch { - // A failed read is not an answer. Hide the last snapshot so a modal can never present a - // candidate the host has not confirmed; the durable record remains and a later refresh can - // restore it. - if (current()) { - publish({ ...EMPTY, listedAt: Date.now() }) + return { + kind: 'answered', + candidates: projected, + failed: projectRestartMachineRows(target, failed), + published } - return { candidates: [], failed: [], available: false } - } -} - -export async function refreshNativeChatRestartOffer(): Promise { - await readNativeChatRestartOffer() -} - -/** The status bar entry and a toast's Show: re-read, then open only over rows, since the dialog - * draws nothing without them. Opening a chat from it is read-only and keeps the offer. */ -export async function reopenNativeChatRestartOffer(): Promise { - // Mid-resume the host's answer is already on its way; a re-read racing it could undo it. - if (resuming.length === 0) { - await readNativeChatRestartOffer() - } - if (run !== null || offer.candidates.length > 0 || offer.failed.length > 0) { - requestNativeChatResumeOnRestartDialog() - } -} - -/** 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(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[]; 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() - } - skipped = new Set(result.skipped) - const actedOn = requested.filter((sessionId) => !skipped.has(sessionId)) - continued = restartContinuationHistory(actedOn, result.continued, listed) - outcome = [actedOn, continued, listed, reopenNativeChatRestartOffer] } catch (error) { - console.warn('[native-chat-resume] resume request failed before reaching the chats', error) - markUnsentResumes(requested, Date.now()) - const read = await readNativeChatRestartOffer() - outcome = [ - requested, - [], - read.available ? offer.failed : undefined, - reopenNativeChatRestartOffer - ] - } finally { - release() - if (releaseRunStatus === release) { - releaseRunStatus = undefined - } - actionsSettled += 1 - // Publish the offer first: reopening must never expose the old selection as actionable. - if (run === actionRun) { - setRun({ - ...actionRun, - entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)), - inFlight: false, - continued - }) + if (hostCannotOffer(error)) { + publishUnder(ticket, null) + return { kind: 'unsupported' } } + return { kind: 'unavailable' } } - announceRestartResults(...outcome) } -/** - * Turning the offer down for good, which explicitly deletes the pending durable records. - * - * A failed write or unreachable host leaves the durable record untouched. The re-read puts it back - * in the status bar; if that read fails too, the entry stays hidden until the next launch reads it. - */ -export async function dismissNativeChatRestartOffer(sessionIds?: readonly string[]): Promise { - actionsBegun += 1 - try { - const result = await callStructuredAgentSession( - LOCAL, - 'agentSession.restartResumableDismiss', - // Named only for rows the dialog lists as failed: the host's own failures, or chats this side - // marked after a lost resume request, which the host still lists as offers. The host is this - // build, and it forgets named records of any state. - sessionIds ? { sessionIds: [...sessionIds] } : {} - ) - // Only a dismissal the host took ends the mark; a failed one leaves the chat shown as failed. - forgetUnsentResumes(sessionIds) - if (Array.isArray(result.sessions)) { - publishAnswer({ - candidates: result.sessions, - failed: failedFrom(result), - listedAt: Date.now() - }) - } else { - await refreshNativeChatRestartOffer() - } - } catch { - await refreshNativeChatRestartOffer() - } finally { - actionsSettled += 1 +/** A machine this desktop no longer pairs with: its offers are not this desktop's to show, and no + * answer to a request already in flight may bring them back. */ +export function forgetNativeChatRestartMachine(machine: RestartMachineKey): void { + const ticket = issueNativeChatRestartTicket(restartMachineTarget(machine)) + ticketsPublished.set(machine, ticket.ticket) + forgetUnsentResumes(machine, undefined) + forgetNativeChatRestartRun(machine) + if (offers.has(machine)) { + publish(machine, null) + } else { + // Its run may have been all an open dialog showed. + retireRequestWithNothingToDraw() } } -/** - * This launch's single read of the offer, and the one decision the preference makes: ask, or - * resume without asking. - * - * "Resume automatically" runs the identical call the button runs — reattach AND ask each agent to - * carry on. Opening a chat remains a separate, read-only inspection action. - * - * Runs once however many surfaces mount, so the count and the dialog describe the same answer and - * an opted-in launch cannot dispatch twice. - */ -async function loadLaunchOffer(): Promise { - // The preference belongs to this launch's request; later saves cannot dispatch another. - const autoResume = useAppStore.getState().settings?.nativeChatResumeWorkOnRestart === true - let read = await readNativeChatRestartOffer() - // Host startup can race the renderer. Retry only failed reads, never a confirmed empty result, - // so a transient startup gap does not strand a durable offer or add steady-state polling. - for (const delay of LAUNCH_READ_RETRY_DELAYS_MS) { - if (read.available) { - break +/** Bookkeeping for an action (continue or dismiss) on one machine: its ticket, and for a resume, the + * run that follows its chats until the host answers. Used by the action module. */ +export function beginNativeChatRestartAction( + target: RuntimeClientTarget, + resume?: { listing: NativeChatRestartMachineOffer; sessionIds: readonly string[] } +): { ticket: RestartMachineTicket; settle: (answer?: RestartRunAnswer) => void } { + const ticket = issueNativeChatRestartTicket(target) + actionsInFlight.set(ticket.machine, (actionsInFlight.get(ticket.machine) ?? 0) + 1) + const finishRun = resume && beginNativeChatRestartRun(resume.listing, resume.sessionIds) + return { + ticket, + settle: (answer) => { + actionsInFlight.set(ticket.machine, (actionsInFlight.get(ticket.machine) ?? 1) - 1) + finishRun?.(answer) } - await new Promise((resolve) => setTimeout(resolve, delay)) - read = await readNativeChatRestartOffer() - } - const offered = read.candidates - // Failures left from an earlier launch are the status bar's to show; only a fresh offer asks. - if (offered.length === 0) { - return } - if (!autoResume) { - requestNativeChatResumeOnRestartDialog() - return - } - await continueNativeChatRestartOffer(allResumeSessionIds(offered)) } -/** - * The offer, fetching it on first use. - * - * `enabled` is a gate, not a trigger: settings arrive after the first render, so the fetch waits - * for the flag rather than being lost when it was still undefined. - */ -export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOffer { - useEffect(() => { - if (enabled) { - // Fetched after mount, never awaited by startup: the workspace is usable first. - launch ??= loadLaunchOffer().finally(markNativeChatLaunchResumeDecided) - } - }, [enabled]) - return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer) -} - -/** The chats a resume is carrying on right now, whichever surface started it. */ -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) +export function useNativeChatRestartOffers(): NativeChatRestartOffers { + return useSyncExternalStore( + subscribeNativeChatRestartOffers, + getNativeChatRestartOffers, + getNativeChatRestartOffers + ) } /** @internal - tests need a clean module between cases. */ -export function _resetNativeChatRestartOffer(): void { - releaseRunStatus?.() - releaseRunStatus = undefined - offeredChatWatch.release() - offer = EMPTY - forgetUnsentResumes(undefined) - run = null - resuming = resumeRunPendingIds(null) - actionsBegun = 0 - actionsSettled = 0 - launch = undefined +export function _resetNativeChatRestartOfferState(): void { + releaseOfferedChatWatches() + offers = NO_OFFERS + _resetUnsentResumes() + _resetNativeChatRestartRuns() + actionsInFlight.clear() + ticketsIssued.clear() + ticketsPublished.clear() listeners.clear() + readListener = null } diff --git a/src/renderer/src/components/native-chat-resume-ownership.test.ts b/src/renderer/src/components/native-chat-resume-ownership.test.ts new file mode 100644 index 000000000000..52fb996256fe --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-ownership.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { resumeCandidateOwnership, resumeOwnershipLabel } from './native-chat-resume-ownership' + +describe('whose interrupted chat this is', () => { + it('takes the host’s answer, and never reads a missing or unknown one as the user’s', () => { + expect(resumeCandidateOwnership({ origin: 'own' })).toBe('own') + expect(resumeCandidateOwnership({ origin: 'server-made' })).toBe('server-made') + expect(resumeCandidateOwnership({})).toBe('unknown') + }) + + it('labels every chat that does not start ticked, and none that does', () => { + expect(resumeOwnershipLabel('own', 'studio-mac')).toBeUndefined() + expect(resumeOwnershipLabel('automation', 'studio-mac')).toBe('Automation') + expect(resumeOwnershipLabel('other-device', 'studio-mac')).toBe('Another device') + expect(resumeOwnershipLabel('server-made', 'studio-mac')).toBe('Made on studio-mac') + expect(resumeOwnershipLabel('unknown', 'studio-mac')).toBe('Unknown origin') + }) +}) diff --git a/src/renderer/src/components/native-chat-resume-ownership.ts b/src/renderer/src/components/native-chat-resume-ownership.ts new file mode 100644 index 000000000000..03c437fba152 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-ownership.ts @@ -0,0 +1,52 @@ +import { translate } from '@/i18n/i18n' +import { + parseRestartOfferOrigin, + type RestartOfferOrigin +} from '../../../shared/restart-offer-origin' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' + +/** + * Whose interrupted chat this is, as its host judged it for this desktop (see `restartOfferOrigin`): + * by workspace, with the sidebar's rule. Only `own` is ticked by default, announced by a reconnect + * toast, or continued without asking; every other answer is still listed, unticked, with where it + * came from. `unknown` is a host that sent no answer, which is never treated as the user's. + */ +export type ResumeWorkspaceOwnership = RestartOfferOrigin | 'unknown' + +export function resumeCandidateOwnership( + candidate: Pick +): ResumeWorkspaceOwnership { + return parseRestartOfferOrigin(candidate.origin) ?? 'unknown' +} + +/** The label beside a chat that does not start ticked; none for the user's own. */ +export function resumeOwnershipLabel( + ownership: ResumeWorkspaceOwnership, + machineName: string +): string | undefined { + switch (ownership) { + case 'own': + return undefined + case 'automation': + return translate( + 'auto.components.NativeChatResumeOnRestartModal.originAutomation', + 'Automation' + ) + case 'other-device': + return translate( + 'auto.components.NativeChatResumeOnRestartModal.originOtherDevice', + 'Another device' + ) + case 'server-made': + return translate( + 'auto.components.NativeChatResumeOnRestartModal.originServer', + 'Made on {{value0}}', + { value0: machineName } + ) + case 'unknown': + return translate( + 'auto.components.NativeChatResumeOnRestartModal.originUnknown', + 'Unknown origin' + ) + } +} 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 index 08e2d28763ff..9c4e4f964c5a 100644 --- a/src/renderer/src/components/native-chat-resume-run-status-watch.ts +++ b/src/renderer/src/components/native-chat-resume-run-status-watch.ts @@ -1,12 +1,15 @@ import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { observeResumeRun, type ResumeRun } from './native-chat-resume-run' -/** Capture every frame, even while the dialog is closed or React batches renders. */ +/** Capture every frame, even while the dialog is closed or React batches renders. Watches the feed + * of the machine running the resume, since its host publishes each chat's progress. */ export function watchResumeRunStatus( + target: RuntimeClientTarget, current: () => ResumeRun | null, publish: (run: ResumeRun) => void ): () => void { - const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' }) + const feed = getStructuredAgentSessionStatusFeed(target) const notice = () => { const run = current() if (!run) { diff --git a/src/renderer/src/components/native-chat-resume-selection.ts b/src/renderer/src/components/native-chat-resume-selection.ts new file mode 100644 index 000000000000..8dac4dbfb636 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-selection.ts @@ -0,0 +1,86 @@ +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + resumeFailureGuidance, + resumeFailureSelectable +} from './native-chat-resume-failure-guidance' +import type { ResumeWorkspaceOwnership } from './native-chat-resume-ownership' +import type { RestartMachineKey } from './native-chat-restart-machines' + +/** + * Which offered chats the dialog has ticked, across machines. + * + * Tracked as the user's OVERRIDES over each row's default rather than as a selection, because each + * machine's list is its host's and arrives — and shrinks — under an open dialog; a stored selection + * would need re-seeding every time one changed. Keyed by machine and chat: two servers may hold + * chats under the same id. + */ +export type ResumeSelectionOverrides = ReadonlyMap + +/** Ticks are keyed by the listing's identity, so a re-paired machine never inherits the old + * pairing's ticks for a chat that happens to share an id. */ +export function resumeRowKey(identity: string, sessionId: string): string { + return `${identity}\u0000${sessionId}` +} + +/** One machine's listing: the machine and the pairing it was read under. */ +export function restartListingIdentity( + machine: RestartMachineKey, + pairingRevision: number | undefined +): string { + return `${machine}\u0000${pairingRevision ?? ''}` +} + +/** Ticked by default: the user's own chat, unless it is a failure a retry cannot fix. */ +export function resumeRowSelectedByDefault( + ownership: ResumeWorkspaceOwnership, + failure: ResumeFailure | undefined +): boolean { + if (ownership !== 'own') { + return false + } + if (!failure) { + return true + } + const guidance = resumeFailureGuidance(failure) + return guidance.primary === 'retry' || guidance.secondary === 'retry' +} + +export type ResumeSelectionMachine = { + machine: RestartMachineKey + /** See `restartListingIdentity`. */ + identity: string + rows: readonly ResumeCandidate[] + failureFor: (sessionId: string) => ResumeFailure | undefined + ownershipFor: (sessionId: string) => ResumeWorkspaceOwnership +} + +/** The chats a tick can include on one machine; a failure the host marks unretryable never is. */ +export function selectableResumeRows(machine: ResumeSelectionMachine): string[] { + return machine.rows + .filter((row) => { + const failure = machine.failureFor(row.sessionId) + return !failure || resumeFailureSelectable(failure) + }) + .map((row) => row.sessionId) +} + +/** Derived from each host's own list, so an action can never name a chat its host did not list. */ +export function chosenResumeRows( + machine: ResumeSelectionMachine, + overrides: ResumeSelectionOverrides +): string[] { + return selectableResumeRows(machine).filter( + (sessionId) => + overrides.get(resumeRowKey(machine.identity, sessionId)) ?? + resumeRowSelectedByDefault(machine.ownershipFor(sessionId), machine.failureFor(sessionId)) + ) +} + +/** What Dismiss forgets on one machine, each chat named with the interruption it listed: only the + * user's own. Any other chat ends when it moves on, when its tab is closed, or by its own row's + * dismiss. */ +export function dismissedRows(machine: ResumeSelectionMachine): string[] { + return machine.rows + .filter((row) => machine.ownershipFor(row.sessionId) === 'own') + .map((row) => row.sessionId) +} 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 index acdc13bda73e..094802ce4789 100644 --- a/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx +++ b/src/renderer/src/components/native-chat-resume-skipped-progress.test.tsx @@ -6,12 +6,12 @@ import { offered, failure, type RestartRpc, - type ResumeStatusStream + type ResumeStatusStream, + localOffer } 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()) @@ -21,6 +21,7 @@ const statusStream: ResumeStatusStream = vi.hoisted(() => ({ })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', subscribeStructuredAgentSessionStatus: async ( _target: unknown, emit: ResumeStatusStream['emit'] @@ -33,7 +34,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: vi.fn(async () => true) })) -vi.mock('sonner', () => ({ toast: vi.fn() })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) const { mount, button, toasts, runStatus } = createResumeModalFixture(rpc, statusStream) const dialog = () => document.querySelector('[role="dialog"]')?.textContent @@ -134,7 +135,7 @@ it.each(['returned', 'lost'] as const)( reply.resolve({ sessions: [], failed: reconciled, skipped: ['a'], continued: [] }) } }) - expect(getNativeChatRestartOffer().failed.map((row) => row.sessionId)).toEqual(['a', 'b']) + expect(localOffer().failed.map((row) => row.sessionId)).toEqual(['a', 'b']) expect(dialog()).toContain('Prompt a') expect(dialog()).toContain('Prompt b') expect(dialog()).toContain('All2') diff --git a/src/renderer/src/components/native-chat-resume-tree-keyboard.ts b/src/renderer/src/components/native-chat-resume-tree-keyboard.ts new file mode 100644 index 000000000000..77afb2e68118 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-tree-keyboard.ts @@ -0,0 +1,51 @@ +// Keyboard movement inside the resume dialog's tree. + +/** + * Tree keys. On a row's checkbox: Up/Down step between the enabled checkboxes, Home/End jump to the + * first/last, Left collapses and Right expands the node through its own disclosure; Space toggles + * natively. On the tree itself (its Tab stop): Down/Home enter at the first checkbox, Up/End at + * the last. + */ +export function moveInResumeTree(event: React.KeyboardEvent): void { + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + const boxes = [ + ...event.currentTarget.querySelectorAll('[role="checkbox"]:not(:disabled)') + ] + const onTree = target === event.currentTarget + if (!onTree && target.getAttribute('role') !== 'checkbox') { + return + } + const jump = + event.key === 'Home' || (onTree && event.key === 'ArrowDown') + ? boxes[0] + : event.key === 'End' || (onTree && event.key === 'ArrowUp') + ? boxes.at(-1) + : undefined + if (jump) { + event.preventDefault() + jump.focus() + return + } + if (onTree) { + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + boxes[boxes.indexOf(target) + (event.key === 'ArrowDown' ? 1 : -1)]?.focus() + return + } + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + // Rows are flat treeitems, so the nearest one is this checkbox's own node. + const disclosure = target + .closest('[role="treeitem"]') + ?.querySelector('button[aria-expanded]') + const open = disclosure?.getAttribute('aria-expanded') === 'true' + if (disclosure && open === (event.key === 'ArrowLeft')) { + disclosure.click() + } + } +} diff --git a/src/renderer/src/components/native-chat-resume-tree-machines.ts b/src/renderer/src/components/native-chat-resume-tree-machines.ts new file mode 100644 index 000000000000..6c8cf568b6bd --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-tree-machines.ts @@ -0,0 +1,131 @@ +import { translate } from '@/i18n/i18n' +import { formatShortTimeAgo } from '@/lib/short-time-ago' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { ResumeFailureAction } from './native-chat-resume-failure-guidance' +import type { MachineView } from './native-chat-resume-machine-views' +import type { RestartMachineKey } from './native-chat-restart-machines' +import { resumeCandidateOwnership, resumeOwnershipLabel } from './native-chat-resume-ownership' +import { resumeRowKey, resumeRowSelectedByDefault } from './native-chat-resume-selection' + +/** + * The resume tree over every listed machine. The tree groups by each chat's host; a paired server's + * chats all carry its runtime host, while this computer's may also sit on its SSH hosts. Each chat + * is keyed by its machine's listing identity and session id, since two servers may hold one id. + */ +export type ResumeTreeMachines = { + rows: ResumeCandidate[] + /** The newest listing's time, which each chat's age is measured to. */ + listedAt: number + rowKey: (candidate: ResumeCandidate) => string + busyFor: (hostId: ExecutionHostId) => boolean + failureFor: (key: string) => ResumeFailure | undefined + originLabelFor: (key: string) => string | undefined + defaultExpanded: (nodeKey: string) => boolean + machineSubtitle: (hostId: ExecutionHostId) => string | undefined + /** The listing a host's chats came from: its machine and the pairing it was read under. */ + listingOf: (hostId: ExecutionHostId) => string + /** The machine and session a key names, for the row's own actions. */ + rowOf: (key: string) => { machine: MachineView; sessionId: string } | undefined +} + +/** Why a machine's chats stopped and how long ago, beside its name. */ +function machineSubtitleFor(machine: MachineView, rows: readonly ResumeCandidate[]): string { + const cause = rows.some((row) => row.trigger === 'update') + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.machineCauseUpdate', + 'Installed an update' + ) + : translate('auto.components.NativeChatResumeOnRestartModal.machineCauseQuit', 'Was quit') + const latest = Math.max(...rows.map((row) => row.recordedAt)) + return `${cause} · ${formatShortTimeAgo(latest, machine.offer.listedAt)}` +} + +/** `rowsOf` is what each machine shows: by default its listed rows, or a run's rows while the + * dialog follows one, which keep a chat the host no longer lists. */ +export function resumeTreeMachines( + machines: readonly MachineView[], + resuming: ReadonlyMap, + focus: RestartMachineKey | null, + rowsOf: (machine: MachineView) => readonly ResumeCandidate[] = (machine) => machine.rows +): ResumeTreeMachines { + const machineOfRow = new Map() + // Keyed by plain string: the tree names a machine node by its host id inside a string key. + const machineByHost = new Map() + const rowByKey = new Map< + string, + { machine: MachineView; sessionId: string; candidate: ResumeCandidate } + >() + const shown = new Map(machines.map((machine) => [machine, rowsOf(machine)])) + for (const [machine, rows] of shown) { + for (const row of rows) { + machineOfRow.set(row, machine) + machineByHost.set(row.executionHostId ?? LOCAL_EXECUTION_HOST_ID, machine) + rowByKey.set(resumeRowKey(machine.identity, row.sessionId), { + machine, + sessionId: row.sessionId, + candidate: row + }) + } + } + const lookup = (key: string, read: (machine: MachineView, sessionId: string) => T) => { + const row = rowByKey.get(key) + return row ? read(row.machine, row.sessionId) : undefined + } + return { + rows: [...shown.values()].flat(), + listedAt: Math.max(...machines.map((machine) => machine.offer.listedAt)), + rowKey: (candidate) => { + const machine = machineOfRow.get(candidate) + return machine ? resumeRowKey(machine.identity, candidate.sessionId) : candidate.sessionId + }, + busyFor: (hostId) => { + const machine = machineByHost.get(hostId) + return machine !== undefined && resuming.has(machine.machine) + }, + failureFor: (key) => lookup(key, (machine, sessionId) => machine.failureFor(sessionId)), + // Read from the row shown, which may be a run's copy of a chat its host no longer lists. + originLabelFor: (key) => { + const row = rowByKey.get(key) + return row + ? resumeOwnershipLabel(resumeCandidateOwnership(row.candidate), row.machine.name) + : undefined + }, + // A machine starts open when the dialog was opened for it, when it is the only one listed, or + // when nothing on it starts ticked (so its empty box is explained); every other node is open. + defaultExpanded: (nodeKey) => { + const hostId = nodeKey.startsWith('machine:') ? nodeKey.slice('machine:'.length) : null + const machine = hostId === null ? undefined : machineByHost.get(hostId) + return ( + machine === undefined || + focus === machine.machine || + machines.length === 1 || + !machine.rows.some((row) => + resumeRowSelectedByDefault( + machine.ownershipFor(row.sessionId), + machine.failureFor(row.sessionId) + ) + ) + ) + }, + machineSubtitle: (hostId) => { + const machine = machineByHost.get(hostId) + return machine ? machineSubtitleFor(machine, shown.get(machine) ?? []) : undefined + }, + listingOf: (hostId) => machineByHost.get(hostId)?.identity ?? hostId, + rowOf: (key) => rowByKey.get(key) + } +} + +/** A row's own action, routed to the machine that listed it. */ +export function resumeTreeRowAction( + tree: ResumeTreeMachines, + key: string, + act: (machine: MachineView, action: ResumeFailureAction, sessionId: string) => void, + action: ResumeFailureAction +): void { + const row = tree.rowOf(key) + if (row) { + act(row.machine, action, row.sessionId) + } +} 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 74b809eb8d7c..dbca52cd9150 100644 --- a/src/renderer/src/components/native-chat-resume-tree-state.ts +++ b/src/renderer/src/components/native-chat-resume-tree-state.ts @@ -1,4 +1,5 @@ import { createContext, useState } from 'react' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { ResumeCandidate, ResumeFailure, @@ -12,27 +13,46 @@ import { // The resume tree's shared state: which nodes are open, how deep a chat row sits, and how a chat // is read by its key. +/** Separates a choice's listing from its node key; neither contains it. */ +const LISTING_SEPARATOR = '\u0001' + /** - * Which nodes are open. Everything starts expanded; the state is this mount's own, so a dialog that - * unmounts its tree on close reopens it fully expanded. Never persisted. + * Which nodes are open. Everything starts expanded unless `defaultExpanded` says otherwise; what the + * user opens or closes then wins, also over a default that changes as answers arrive. The state is + * this mount's own, so a dialog that unmounts its tree on close reopens it from the defaults. Never + * persisted. + * + * With `listings`, a choice belongs to the listing its node's host was shown under (a paired server + * re-paired is a new listing) and goes when that listing is no longer shown, so a machine that + * leaves and comes back starts from its defaults again. */ -export function useResumeTreeExpansion(): { - isExpanded: (key: string) => boolean - setExpanded: (key: string, expanded: boolean) => void +export function useResumeTreeExpansion( + defaultExpanded?: (key: string) => boolean, + listings?: { listingOf: (hostId: ExecutionHostId) => string; shown: readonly string[] } +): { + isExpanded: (key: string, hostId?: ExecutionHostId) => boolean + setExpanded: (key: string, expanded: boolean, hostId?: ExecutionHostId) => void } { - const [collapsed, setCollapsed] = useState>(() => new Set()) + const [chosen, setChosen] = useState>(() => new Map()) + const entry = (key: string, hostId: ExecutionHostId | undefined) => + listings && hostId !== undefined + ? `${listings.listingOf(hostId)}${LISTING_SEPARATOR}${key}` + : key + if (listings) { + const shown = new Set(listings.shown) + const kept = [...chosen].filter(([stored]) => { + const at = stored.indexOf(LISTING_SEPARATOR) + return at === -1 || shown.has(stored.slice(0, at)) + }) + // Render-time adjustment of this hook's own state: a listing gone takes its choices with it. + if (kept.length !== chosen.size) { + setChosen(new Map(kept)) + } + } return { - isExpanded: (key) => !collapsed.has(key), - setExpanded: (key, expanded) => - setCollapsed((current) => { - const next = new Set(current) - if (expanded) { - next.delete(key) - } else { - next.add(key) - } - return next - }) + isExpanded: (key, hostId) => chosen.get(entry(key, hostId)) ?? defaultExpanded?.(key) ?? true, + setExpanded: (key, expanded, hostId) => + setChosen((current) => new Map(current).set(entry(key, hostId), expanded)) } } @@ -48,30 +68,61 @@ export type FailureProps = { selectableIds?: ReadonlySet } +/** What a caller listing several machines adds: chats are keyed by `rowKey` (two machines may + * hold the same session id), so `selected`, `onToggle`, `failureFor`, `onFailureAction` and + * `originLabelFor` all take that key; each machine can be busy on its own, start open or closed, + * and carry a line beside its name. */ +export type MachineProps = { + /** A chat's key in every lookup; the session id when absent. */ + rowKey?: (candidate: ResumeCandidate) => string + /** One machine mid-resume locks only its own nodes. */ + busyFor?: (hostId: ExecutionHostId) => boolean + /** Where a chat that does not start ticked came from ("Automation", "Another device"). */ + originLabelFor?: (key: string) => string | undefined + /** Whether a node starts open; the user's own opening and closing then wins. */ + defaultExpanded?: (nodeKey: string) => boolean + /** A line beside a machine's name, e.g. why it stopped and when. */ + machineSubtitle?: (hostId: ExecutionHostId) => string | undefined + /** The listing a host's chats were shown under (a re-paired server is a new one): what the user + * opened or closed under it holds only for it. */ + listingOf?: (hostId: ExecutionHostId) => string +} + /** What every node needs from the tree as a whole. */ export type TreeProps = { listedAt: number busy: boolean selected: ReadonlySet onToggle: (sessionId: string, checked: boolean) => void - isExpanded: (key: string) => boolean - setExpanded: (key: string, expanded: boolean) => void + isExpanded: (key: string, hostId?: ExecutionHostId) => boolean + setExpanded: (key: string, expanded: boolean, hostId?: ExecutionHostId) => void repoIdOf: (group: ResumeWorkspaceGroup) => string | null ancestorsOf: (group: ResumeWorkspaceGroup) => readonly string[] -} & FailureProps +} & FailureProps & + Pick + +/** Whether a node on this host is locked: the whole tree, or this machine, is mid-resume. */ +export function treeBusy(tree: TreeProps, hostId: ExecutionHostId): boolean { + return tree.busy || tree.busyFor?.(hostId) === true +} /** * The one place a chat's key is read: its row's tick, toggle and failure, and every group's * coverage all go through here, so a change of key stays in this function. */ export function chatState(candidate: ResumeCandidate, tree: TreeProps) { - const key = candidate.sessionId + const key = tree.rowKey?.(candidate) ?? candidate.sessionId const failure = tree.failureFor?.(key) return { key, checked: tree.selected.has(key), onCheckedChange: (checked: boolean) => tree.onToggle(key, checked), failure, + // The row's own actions, where it came from and its run status, named by the same key. + onFailureAction: + tree.onFailureAction && + ((action: ResumeFailureAction) => tree.onFailureAction?.(action, key)), + originLabel: tree.originLabelFor?.(key), renderStatus: tree.renderStatus ? (_sessionId: string, title: string) => tree.renderStatus?.(key, title) : undefined, diff --git a/src/renderer/src/components/native-chat-resume-unsent-requests.ts b/src/renderer/src/components/native-chat-resume-unsent-requests.ts index 49eb0d5c816a..e60a6aec9e6d 100644 --- a/src/renderer/src/components/native-chat-resume-unsent-requests.ts +++ b/src/renderer/src/components/native-chat-resume-unsent-requests.ts @@ -1,40 +1,70 @@ import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import type { RestartMachineKey } from './native-chat-restart-machines' // A resume request that failed before the host reserved anything leaves it nothing to record, so -// its chats are shown failed from here: by id, when it failed. Each host answer re-derives it. +// its chats are shown failed from here: by machine and id, when it failed. Session ids are each +// host's own, so a mark only ever applies to the machine it was made for. Each host answer from +// that machine re-derives it. -const unsentResumes = new Map() +const unsentResumes = new Map>() const UNSENT_RESUME_REASON = 'agent_session_restart_request_failed' type HostAnswer = { candidates: readonly ResumeCandidate[]; failed: readonly ResumeFailure[] } -export function markUnsentResumes(sessionIds: readonly string[], failedAt: number): void { - sessionIds.forEach((sessionId) => unsentResumes.set(sessionId, failedAt)) +export function markUnsentResumes( + machine: RestartMachineKey, + sessionIds: readonly string[], + failedAt: number +): void { + const marks = unsentResumes.get(machine) ?? new Map() + sessionIds.forEach((sessionId) => marks.set(sessionId, failedAt)) + unsentResumes.set(machine, marks) } -/** Retry, Dismiss and a launch's own resume each settle the chats they name afresh. */ -export function forgetUnsentResumes(sessionIds: readonly string[] | undefined): void { +/** Retry, Dismiss and a launch's own resume each settle the chats they name afresh; unnamed, the + * machine's every mark ends (its offer was dismissed whole, or it was re-paired or forgotten). */ +export function forgetUnsentResumes( + machine: RestartMachineKey, + sessionIds: readonly string[] | undefined +): void { + const marks = unsentResumes.get(machine) + if (!marks) { + return + } if (sessionIds === undefined) { - unsentResumes.clear() + unsentResumes.delete(machine) return } - sessionIds.forEach((sessionId) => unsentResumes.delete(sessionId)) + sessionIds.forEach((sessionId) => marks.delete(sessionId)) + if (marks.size === 0) { + unsentResumes.delete(machine) + } } -/** The host's answer with each unsent resume's chat moved from its offers to its failures. A chat - * the host no longer offers drops its mark, so a host failure row is never shown beside it. */ -export function withUnsentResumes(answer: T): T { +/** @internal - tests need a clean module between cases. */ +export function _resetUnsentResumes(): void { + unsentResumes.clear() +} + +/** The machine's answer with each unsent resume's chat moved from its offers to its failures. A + * chat the host no longer offers drops its mark, so a host failure row is never shown beside it. */ +export function withUnsentResumes(machine: RestartMachineKey, answer: T): T { + const marks = unsentResumes.get(machine) + if (!marks) { + return answer + } const offered = new Set(answer.candidates.map((candidate) => candidate.sessionId)) - for (const sessionId of unsentResumes.keys()) { + for (const sessionId of marks.keys()) { if (!offered.has(sessionId)) { - unsentResumes.delete(sessionId) + marks.delete(sessionId) } } - if (unsentResumes.size === 0) { + if (marks.size === 0) { + unsentResumes.delete(machine) return answer } const unsent = answer.candidates.flatMap((candidate): ResumeFailure[] => { - const failedAt = unsentResumes.get(candidate.sessionId) + const failedAt = marks.get(candidate.sessionId) return failedAt === undefined ? [] : [ @@ -49,7 +79,7 @@ export function withUnsentResumes(answer: T): T { }) return { ...answer, - candidates: answer.candidates.filter((candidate) => !unsentResumes.has(candidate.sessionId)), + candidates: answer.candidates.filter((candidate) => !marks.has(candidate.sessionId)), failed: [...answer.failed, ...unsent] } } diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 6c35294a4ffe..904cefdcad20 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const hostLabel = (): string | null => 'studio-mac' const capability = (): 'unknown' | 'supported' | 'unsupported' => 'supported' - const resuming: readonly string[] = [] + const resuming: ReadonlyMap = new Map() return { call: vi.fn(), capability: capability(), @@ -41,6 +41,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { restartMachineKey } from '../native-chat-restart-machines' import { NativeChatInterruptedContinue, useNativeChatInterruptedContinuation @@ -105,7 +106,7 @@ const ATTACHMENTS = 'Remove attachments before using a chat-session command.' beforeEach(() => { mocks.capability = 'supported' - mocks.resuming = [] + mocks.resuming = new Map() mocks.launchPending = false mocks.hostLabel = 'studio-mac' mocks.call.mockReset() @@ -168,11 +169,18 @@ describe('Continue on a reply an Orca stop cut off', () => { }) it('is not offered while the restart prompt or the launch is resuming this chat', () => { - mocks.resuming = ['session-1'] + mocks.resuming = new Map([[restartMachineKey(PAIRED), ['session-1']]]) render() expect(continueButton()).toBeNull() }) + // Resumes are per machine: a chat of the same id resuming elsewhere is another chat. + it('is still offered while a chat of the same id resumes on another machine', () => { + mocks.resuming = new Map([['local', ['session-1']]]) + render() + expect(continueButton()).toBeInTheDocument() + }) + it("is not offered on this machine's chats while the launch may still resume them", () => { mocks.launchPending = true render() diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index ae8a9a503adb..d83b3509c645 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -15,6 +15,7 @@ import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../ import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context' import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label' import { useNativeChatRestartResuming } from '../native-chat-resume-on-restart-store' +import { restartMachineKey } from '../native-chat-restart-machines' import { useNativeChatLaunchResumePending } from '../native-chat-launch-resume-decision' import type { NativeChatComposerNoticeContent } from './native-chat-composer-notice' @@ -51,10 +52,11 @@ export function useNativeChatInterruptedContinuation(input: { target, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY ) - // The restart prompt or the launch's own resume is carrying this chat on, or the launch may still - // decide to; it resumes only this machine's chats. + // The restart prompt or a resume is carrying this chat on, on its own machine, or the launch may + // still decide to; the launch resumes only this computer's chats. const launchPending = useNativeChatLaunchResumePending() && target.kind === 'local' - const resuming = useNativeChatRestartResuming().includes(sessionId) || launchPending + const resumingHere = useNativeChatRestartResuming().get(restartMachineKey(target)) + const resuming = resumingHere?.includes(sessionId) === true || launchPending const cut = useMemo( () => latestNativeChatOrcaStopCut(input.journalItems, input.submissions), [input.journalItems, input.submissions] diff --git a/src/renderer/src/components/sidebar/workspace-creator-visibility.ts b/src/renderer/src/components/sidebar/workspace-creator-visibility.ts index ca0b929a40cd..e7831dac3fdb 100644 --- a/src/renderer/src/components/sidebar/workspace-creator-visibility.ts +++ b/src/renderer/src/components/sidebar/workspace-creator-visibility.ts @@ -3,7 +3,7 @@ import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-e import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' -import { normalizeWorkspaceCreatorProvenance } from '../../../../shared/workspace-creator-provenance' +import { workspaceCreatorRelation } from '../../../../shared/workspace-creator-provenance' type RuntimeStatusEntry = { status: RuntimeStatus | null } @@ -28,19 +28,15 @@ export function isWorkspaceFromOtherDevice( worktree: Worktree, pairedDeviceIdsByEnvironment: ReadonlyMap ): boolean { - const creator = normalizeWorkspaceCreatorProvenance(worktree.creatorProvenance) - if (!creator) { - return false - } const environmentId = worktree.runtimeOwnerEnvironmentId if (!environmentId) { - return creator.kind !== 'host' + return workspaceCreatorRelation(worktree.creatorProvenance, null) !== 'viewer' } const pairedDeviceId = pairedDeviceIdsByEnvironment.get(environmentId) if (!pairedDeviceId) { return false } - return creator.kind !== 'paired-device' || creator.deviceId !== pairedDeviceId + return workspaceCreatorRelation(worktree.creatorProvenance, pairedDeviceId) !== 'viewer' } export function isFolderWorkspaceFromOtherDevice( diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index 108f51e17da8..85cfca599e76 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -11,15 +11,20 @@ import { consumeNativeChatResumeOnRestartDialogRequest, getNativeChatResumeOnRestartDialogRequest } from '../native-chat-resume-on-restart-dialog' -import { _resetNativeChatRestartOffer } from '../native-chat-resume-on-restart-store' +import { _resetNativeChatRestartOffer } from '../native-chat-restart-offer-triggers' import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment' +import { nativeChatResumePendingText } from './native-chat-resume-status-text' +import { readNativeChatRestartMachine } from '../native-chat-resume-on-restart-store' +import { pairedEnvironment } from '../native-chat-restart-offer-test-support' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: rpc, + pairedRestartOffersSupport: async () => 'supported', // A failed row opens the status feed; these cases never drive it. subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) })) +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { dismiss: vi.fn() }) })) const candidates: ResumeCandidate[] = [ { @@ -28,7 +33,8 @@ const candidates: ResumeCandidate[] = [ agent: 'codex', trigger: 'quit', latestPrompt: 'Fix it', - recordedAt: 1 + recordedAt: 1, + origin: 'own' }, { sessionId: 'b', @@ -36,7 +42,8 @@ const candidates: ResumeCandidate[] = [ agent: 'claude', trigger: 'update', latestPrompt: 'Review it', - recordedAt: 2 + recordedAt: 2, + origin: 'own' } ] @@ -92,14 +99,14 @@ describe('NativeChatResumeStatusSegment', () => { expect(screen.getByRole('button', { name: '2 chats available to resume' })).toBeTruthy() expect(screen.getByText('2 chats to resume')).toBeTruthy() - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() await act(async () => screen.getByRole('button').click()) // The launch read, then a second one taken before the dialog is allowed to reopen. expect(rpc.mock.calls.map((call) => call[1])).toEqual([ 'agentSession.restartResumable', 'agentSession.restartResumable' ]) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'local' }) }) // The offer is spent once acted on, so this entry is the one summary a failed resume leaves. @@ -122,7 +129,7 @@ describe('NativeChatResumeStatusSegment', () => { rpc.mockResolvedValue({ sessions: [], failed: [failed] }) await act(async () => entry.click()) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'local' }) // With the offer gone, only the failure entry is left — and it stays. expect(screen.queryByText('1 chat to resume')).toBeNull() expect(screen.getByText('1 chat failed to resume')).toBeTruthy() @@ -163,9 +170,9 @@ describe('NativeChatResumeStatusSegment', () => { rpc.mockResolvedValueOnce({ sessions: candidates }).mockResolvedValue({ sessions: [] }) await mount() - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() await act(async () => screen.getByRole('button').click()) - expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(getNativeChatResumeOnRestartDialogRequest()).toBeNull() expect(screen.queryByRole('button')).toBeNull() }) @@ -253,4 +260,156 @@ describe('NativeChatResumeStatusSegment', () => { expect(screen.getByRole('button').textContent).toContain('2') expect(screen.queryByText('2 chats to resume')).toBeNull() }) + + // One entry across machines: it names the machine only when there is just one. + it('names a paired server when it is the only machine with chats, and opens on it', async () => { + rpc.mockImplementation(async (target) => + target.kind === 'environment' ? { sessions: candidates } : { sessions: [] } + ) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment('studio', 'studio-mac')] + }) + await mount() + await act(async () => { + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) + expect(screen.getByText('2 chats to resume on studio-mac')).toBeTruthy() + await act(async () => screen.getByRole('button').click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'environment:studio' }) + }) + + // Another device's or an automation's chats are theirs to resume; the entry counts the user's. + it("counts only the user's own chats, and adds nothing for a server holding only others'", async () => { + const others = (['other-device', 'automation', 'server-made'] as const).map( + (origin, index) => ({ + ...candidates[0]!, + sessionId: `o${index}`, + origin + }) + ) + let studio = [...candidates, ...others] + rpc.mockImplementation(async (target) => + target.kind === 'environment' ? { sessions: studio } : { sessions: [] } + ) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment('studio', 'studio-mac')] + }) + await mount() + await act(async () => { + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) + expect(screen.getByText('2 chats to resume on studio-mac')).toBeTruthy() + studio = others + await act(async () => { + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) + expect(screen.queryByRole('button')).toBeNull() + }) + + // A server's provider refused to carry the user's chat on: the entry says so, counting only the + // user's own failures, and opens on that server. The failed label names no machine; the dialog does. + it('counts only the user’s own failures on a paired server, and opens on it', async () => { + const failure = (sessionId: string, origin: 'own' | 'other-device') => ({ + ...candidates[0]!, + sessionId, + origin, + failedAt: 60_000, + outcome: 'refused', + reason: 'provider_refused_continuation' + }) + rpc.mockImplementation(async (target) => + target.kind === 'environment' + ? { sessions: [], failed: [failure('a', 'own'), failure('o', 'other-device')] } + : { sessions: [] } + ) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment('studio', 'studio-mac')] + }) + await mount() + await act(async () => { + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) + const entry = screen.getByRole('button', { + name: '1 chat failed to resume. Click for details.' + }) + expect(entry.textContent).toBe('1 chat failed to resume') + await act(async () => entry.click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: 'environment:studio' }) + }) + + it('counts every machine in one entry and opens on none in particular', async () => { + rpc.mockImplementation(async (target) => + target.kind === 'environment' ? { sessions: candidates } : { sessions: [candidates[0]] } + ) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment('studio', 'studio-mac')] + }) + await mount() + await act(async () => { + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.getByText('3 chats to resume')).toBeTruthy() + await act(async () => screen.getByRole('button').click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: null }) + }) + + it('breaks the tooltip down by machine only when there is more than one', () => { + expect( + nativeChatResumePendingText(4, null, [ + { count: 1, name: 'Local Mac' }, + { count: 2, name: 'studio-mac' }, + { count: 1, name: 'build-box' } + ]).tooltip + ).toBe('4 chats to resume: 1 on Local Mac, 2 on studio-mac, 1 on build-box. Click to choose.') + expect( + nativeChatResumePendingText(2, 'studio-mac', [{ count: 2, name: 'studio-mac' }]) + ).toEqual({ + label: '2 chats to resume on studio-mac', + ariaLabel: '2 chats available to resume', + tooltip: 'Open interrupted chats available to resume' + }) + expect(nativeChatResumePendingText(1, null, [{ count: 1, name: 'Local Mac' }]).label).toBe( + '1 chat to resume' + ) + }) + + // Read first, as this computer's entry always has; a server out of contact is not waited on. + it('re-reads this computer and opens over a disconnected server’s last listing', async () => { + let hang = false + rpc.mockImplementation((target) => { + if (hang && target.kind === 'environment') { + // An unreachable server: a re-read would never answer. + return new Promise(() => {}) + } + return Promise.resolve({ sessions: candidates }) + }) + useAppStore.setState({ + runtimeEnvironments: [pairedEnvironment('studio', 'studio-mac')] + }) + await mount() + await act(async () => { + await readNativeChatRestartMachine({ kind: 'environment', environmentId: 'studio' }) + }) + hang = true + await act(async () => screen.getByRole('button').click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toEqual({ focus: null }) + // This computer was asked again; the server, with no connection, was not. + expect( + rpc.mock.calls + .filter((call) => call[1] === 'agentSession.restartResumable') + .map((call) => call[0].kind) + ).toEqual(['local', 'environment', 'local']) + }) + + // A failed re-read is loss of contact, never evidence the offers are gone: keep the last answer. + it('keeps the last answer when a refresh of this computer fails', async () => { + rpc.mockResolvedValueOnce({ sessions: candidates }).mockRejectedValue(new Error('host busy')) + await mount() + expect(screen.getByText('2 chats to resume')).toBeTruthy() + await act(async () => { + await readNativeChatRestartMachine({ kind: 'local' }) + }) + expect(screen.getByText('2 chats to resume')).toBeTruthy() + }) }) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index f51ab5fe2078..7437b41cd7cd 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -1,20 +1,33 @@ import { AlertCircle, Loader2, RotateCcw } from 'lucide-react' import { useNativeChatRestartOfferEnabled } from '../native-chat-restart-offer-gate' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' import { - reopenNativeChatRestartOffer, - useNativeChatRestartOffer, - useNativeChatRestartRun -} from '../native-chat-resume-on-restart-store' + checkText, + failedText, + nativeChatResumePendingText, + resumingText +} from './native-chat-resume-status-text' +import { useNativeChatRestartOffers } from '../native-chat-resume-on-restart-store' +import { useNativeChatRestartRuns } from '../native-chat-restart-runs' import { resumeRunInFlight } from '../native-chat-resume-run' import { resumeRunView } from '../native-chat-resume-run-view' +import { reopenNativeChatRestartOffer } from '../native-chat-restart-offer-reopen' +import { useNativeChatRestartOfferSources } from '../native-chat-restart-offer-triggers' +import { LOCAL_RESTART_MACHINE, type RestartMachineKey } from '../native-chat-restart-machines' +import { restartMachineNameFromState } from '../native-chat-restart-machine-name' +import { useAppStore } from '../../store' +import type { ResumeCandidate } from '../native-chat-resume-on-restart-grouping' +import { resumeCandidateOwnership } from '../native-chat-resume-ownership' -// Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is +// Why: closing the resume dialog is a snooze, not a decline — each host keeps its offer. This is // then the only surface left carrying it, so it is always rendered rather than gated by // `statusBarItems`. Pressing Resume closes the dialog too, so this entry carries the run while it -// is in flight. It is also the lasting summary of chats the resume could not carry on: its -// toast says so once, and each chat it reached carries its own note. +// is in flight. It is also the lasting summary of the user's own chats a resume could not carry +// on: its toast says so once, and each chat it reached carries its own note. +// +// ONE entry across every machine: the dialog covers them all. It counts only the user's own chats +// (another device's or an automation's are listed in the dialog, not counted here), names the +// machine only when there is just one, and its tooltip breaks the count down by machine. function Segment({ icon, @@ -22,7 +35,8 @@ function Segment({ ariaLabel, tooltip, iconOnly, - count + count, + machines }: { icon: React.ReactNode label: string @@ -30,13 +44,14 @@ function Segment({ tooltip: string iconOnly: boolean count: number + machines: readonly RestartMachineKey[] }): React.JSX.Element { return (