From c43ac3fc3c6c652b7490e69a5fbcaf46a07368f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:22:21 -0700 Subject: [PATCH] Resume dialog: show a finished resume as a result, not a picker (#27732) * fix(native-chat): show a finished resume as a result, not a picker Once a resume starts and no chat is left to choose, the dialog drops the Select all row and the checkbox column, so each chat's check reads as its status instead of a ticked box. The Don't-ask-again box only shows while choosing; a run started by the launch's auto-resume says so and links to the setting. The finished description is past tense. * fix(i18n): keep Agent untranslated in the ja resume copy * fix(native-chat): keep resume results actionable and keyboard accessible * Fix hidden resume preference replay after run starts * Consume resume picker preference after applying it * fix(native-chat): keep Don't ask again after Resume while auto-resume is off The box now shows on the result view too whenever the setting is off and the user started the resume, and saves only while it is visible, so a hidden tick still never replays. Automatic launches keep their 'Resumed automatically' note instead. --- .../components/NativeChatResumeAutoNote.tsx | 53 ++++ .../NativeChatResumeOnRestartGroups.tsx | 59 +++-- ...tiveChatResumeOnRestartModal.list.test.tsx | 13 +- .../NativeChatResumeOnRestartModal.test.tsx | 10 +- .../NativeChatResumeOnRestartModal.tsx | 193 +++++++-------- .../components/NativeChatResumeRunPanel.tsx | 42 +++- .../components/NativeChatResumeTreeRow.tsx | 61 +++-- .../native-chat-resume-on-restart-store.ts | 13 +- .../native-chat-resume-result-view.test.tsx | 232 ++++++++++++++++++ .../components/native-chat-resume-run.test.ts | 2 +- .../src/components/native-chat-resume-run.ts | 17 +- .../native-chat-resume-tree-keys.ts | 49 ++++ .../native-chat-resume-tree-state.ts | 3 + .../NativeChatExperimentalSetting.tsx | 6 +- .../native-chat-resume-setting-target.ts | 2 + src/renderer/src/i18n/locales/en.json | 14 +- src/renderer/src/i18n/locales/es.json | 14 +- src/renderer/src/i18n/locales/fr.json | 14 +- src/renderer/src/i18n/locales/ja.json | 14 +- src/renderer/src/i18n/locales/ko.json | 14 +- src/renderer/src/i18n/locales/zh.json | 14 +- 21 files changed, 650 insertions(+), 189 deletions(-) create mode 100644 src/renderer/src/components/NativeChatResumeAutoNote.tsx create mode 100644 src/renderer/src/components/native-chat-resume-result-view.test.tsx create mode 100644 src/renderer/src/components/native-chat-resume-tree-keys.ts create mode 100644 src/renderer/src/components/settings/native-chat-resume-setting-target.ts diff --git a/src/renderer/src/components/NativeChatResumeAutoNote.tsx b/src/renderer/src/components/NativeChatResumeAutoNote.tsx new file mode 100644 index 00000000000..d8e325994fc --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeAutoNote.tsx @@ -0,0 +1,53 @@ +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../store' +import { Button } from './ui/button' +import { NATIVE_CHAT_RESUME_SETTING_SECTION_ID } from './settings/native-chat-resume-setting-target' + +/** + * Says a run started on its own, in the place the "Don't ask again" box takes in the picker, and + * where to change that. Only a run this launch's preference started gets it. + */ +export function NativeChatResumeAutoNote({ + inFlight, + onOpenSettings +}: { + inFlight: boolean + /** Closes the dialog so the settings page is not left under it. */ + onOpenSettings: () => void +}): React.JSX.Element { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + return ( +
+ + {inFlight + ? translate( + 'auto.components.NativeChatResumeAutoNote.resuming', + 'Resuming automatically after the restart.' + ) + : translate( + 'auto.components.NativeChatResumeAutoNote.resumed', + 'Resumed automatically after the restart.' + )} + + +
+ ) +} diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index 60497e8a7b7..42d5cc23301 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useContext, useMemo } from 'react' import { Folder, FolderTree, GitBranch, Monitor } from 'lucide-react' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { WorktreeHostContextBadge } from '@/components/sidebar/WorktreeHostContextBadge' @@ -26,6 +26,7 @@ import { chatState, coveredKeys, ResumeTreeDepthContext, + ResumeTreeSelectingContext, useResumeTreeExpansion, type FailureProps, type TreeProps @@ -82,6 +83,7 @@ function GroupNode({ }): React.JSX.Element { const selection = resumeSelectionState(covered, tree.selected) const expanded = tree.isExpanded(nodeKey) + const selecting = useContext(ResumeTreeSelectingContext) return ( <> {label} - + {selecting && ( + + )} {/* Collapsing unmounts the rows only; their selection lives with the dialog. */} {expanded && children} @@ -346,13 +350,16 @@ export function ResumeOnRestartGroups({ failureFor, onFailureAction, renderStatus, - selectableIds + selectableIds, + selecting = true }: { candidates: readonly ResumeCandidate[] listedAt: number busy: boolean selected: ReadonlySet onToggle: (sessionId: string, checked: boolean) => void + /** False once every chat shows a run status, so no checkbox is left to act on. */ + selecting?: boolean } & FailureProps): React.JSX.Element { const machines = useMemo( () => @@ -388,27 +395,29 @@ export function ResumeOnRestartGroups({ const showMachines = machines.length > 1 || machines.some((machine) => machine.hostId !== LOCAL_EXECUTION_HOST_ID) return ( -
- {machines.map((machine) => - showMachines ? ( - - ) : ( - - ) - )} -
+ +
+ {machines.map((machine) => + showMachines ? ( + + ) : ( + + ) + )} +
+
) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx index eed135ccce0..45816689987 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.list.test.tsx @@ -200,7 +200,7 @@ it('leaves a failure a retry cannot fix out of Select all', async () => { expect(button('Resume 1 chat').disabled).toBe(false) }) -it('disables Select all and the workspace checkbox while a resume runs', async () => { +it('drops Select all and the checkbox column while every listed chat is resuming', async () => { const continued = Promise.withResolvers() rpc.mockImplementation((_target, method) => method === 'agentSession.restartResumable' @@ -215,12 +215,11 @@ it('disables Select all and the workspace checkbox while a resume runs', async ( ) await act(async () => button('Resume 2 chats').click()) await act(async () => button('Resuming chats 0/2').click()) - const list = document.querySelector('[aria-label="Chats that would be resumed"]')! - const boxes = [...list.querySelectorAll('[role="checkbox"]')] - expect(boxes).toHaveLength(2) - for (const box of boxes) { - expect(box.hasAttribute('disabled')).toBe(true) - } + const list = document.querySelector('[aria-label="Chats in this resume"]')! + expect(list.querySelectorAll('[role="checkbox"]')).toHaveLength(0) + expect(list.textContent).not.toContain('Select all') + expect(list.textContent).not.toContain('of 2') + expect(list.querySelectorAll('[role="img"][aria-label*="Waiting to start"]')).toHaveLength(2) await act(async () => continued.resolve({ sessions: [], failed: [], resumed: [], continued: [] })) }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 5cae482fa95..42e7e419716 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -223,13 +223,13 @@ it('closes on Resume and shows the resume in the status bar until the host answe expect(rpc.mock.calls.length).toBe(reads) expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resuming 2 chats') expect(button('Resuming…').disabled).toBe(true) - // Each chat being resumed shows a spinner where its checkbox was; tree nodes stay disabled. + // With nothing left to choose there is no checkbox column; each spinner sits beside its chat. expect(runStatus('Prompt a')).toMatch(/^Prompt a: Waiting to start · \d+s$/) expect(runStatus('Prompt b')).toMatch(/^Prompt b: Waiting to start · \d+s$/) expect(chatBoxes()).toHaveLength(0) const status = document.querySelector('[role="img"][aria-label^="Prompt a: "]') - expect(status?.parentElement?.classList.contains('w-7')).toBe(true) - expect(status?.parentElement?.parentElement?.firstElementChild?.contains(status)).toBe(true) + expect(document.querySelector('[role="tree"] .w-7')).toBeNull() + expect(status?.parentElement?.classList.contains('size-4.5')).toBe(true) expect(status?.closest('[role="treeitem"]')?.getAttribute('aria-level')).toBe('2') expect(status?.closest('label')).toBeNull() @@ -569,7 +569,9 @@ it('lists a chat the resume could not carry on when the dialog reopens, with wha const dialog = document.querySelector('[role="dialog"]') expect(dialog).not.toBeNull() expect(dialog?.textContent).toContain('Resumed 1 of 2 chats') - expect(dialog?.textContent).toContain("Don't ask again (resume automatically)") + // The setting is still off, so the box stays after Resume. + expect(dialog?.textContent).toContain("Don't ask again") + expect(dialog?.textContent).not.toContain('Resumed automatically') expect(dialog?.textContent).not.toContain('Dismiss failed') expect(runStatus('Prompt a')).toBe('Prompt a: Resumed') expect(chatBoxes()).toHaveLength(1) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 0394caa14d7..42d1b7538a0 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -42,6 +42,8 @@ import { useNativeChatRestartRun } from './native-chat-resume-on-restart-store' import { useResumeRunPanel } from './NativeChatResumeRunPanel' +import { NativeChatResumeAutoNote } from './NativeChatResumeAutoNote' +import { moveInTree } from './native-chat-resume-tree-keys' /** * What would be resumed, shown before anything runs. @@ -56,7 +58,9 @@ import { useResumeRunPanel } from './NativeChatResumeRunPanel' * 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. + * stays on that run until it is closed after the run has finished. Once no chat is left to choose, + * it is a result: no Select all and no checkbox column. The "don't ask again" box stays while + * the setting is off. * * 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. @@ -76,56 +80,6 @@ function selectedByDefault(failure: ResumeFailure | undefined): boolean { return guidance.primary === 'retry' || guidance.secondary === 'retry' } -/** - * 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() - } - } -} - export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const offerEnabled = useNativeChatRestartOfferEnabled() const { candidates, failed, listedAt } = useNativeChatRestartOffer(offerEnabled) @@ -143,6 +97,9 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { ) const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) + const autoResumeOn = useAppStore( + (store) => store.settings?.nativeChatResumeWorkOnRestart === true + ) const resumeButtonRef = useRef(null) // The store's: the resume outlives this dialog, which can close or reopen mid-run. const resuming = useNativeChatRestartResuming() @@ -194,6 +151,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { run, rows, failureFor, + selectableIds, open }) useEffect(() => { @@ -202,17 +160,21 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { } }, [offerEnabled, open, run]) const allSelection = resumeSelectionState(selectable, ticked) + const choosing = runPanel?.choosing ?? true + // While the setting is off the box stays, before and after Resume; an automatic run explains itself. + const showDontAskAgain = !autoResumeOn && runPanel?.startedBy !== 'launch' const toggleSelected = useCallback((sessionId: string, checked: boolean) => { setOverrides((current) => new Map(current).set(sessionId, checked)) }, []) - /** Applied on whichever action the user takes, so the box means the same thing every way out. */ + /** Consume the box's choice once; a hidden box must never replay. */ const persistPreference = useCallback(async (): Promise => { - if (dontAskAgain) { + if (dontAskAgain && showDontAskAgain) { + setDontAskAgain(false) await updateSettings({ nativeChatResumeWorkOnRestart: true }).catch(() => undefined) } - }, [dontAskAgain, updateSettings]) + }, [dontAskAgain, showDontAskAgain, updateSettings]) // Never closes the dialog: only the user's own ways out do, and the store once nothing is left. const resume = useCallback( @@ -327,42 +289,51 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
{/* 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. */} -
- - toggleResumeSelection(selectable, allSelection, toggleSelected) - } - checkboxLabel={translate( - 'auto.components.NativeChatResumeOnRestartModal.selectAll', - 'Select all chats' - )} - > - - {selectAllLabel} - - - {translate( - 'auto.components.NativeChatResumeOnRestartModal.selectedCount', - '{{value0}} of {{value1}} selected', - { value0: allSelection.selectedCount, value1: allSelection.total } + {choosing && ( +
+ + toggleResumeSelection(selectable, allSelection, toggleSelected) + } + checkboxLabel={translate( + 'auto.components.NativeChatResumeOnRestartModal.selectAll', + 'Select all chats' )} - - -
+ > + + {selectAllLabel} + + + {translate( + 'auto.components.NativeChatResumeOnRestartModal.selectedCount', + '{{value0}} of {{value1}} selected', + { value0: allSelection.selectedCount, value1: allSelection.total } + )} + +
+
+ )} void actOnFailure(action, sessionId)} renderStatus={runPanel?.renderStatus} selectableIds={selectableIds} + selecting={choosing} />
{/* Two controls: one deletes the offer, one acts on it. 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 ? ( + {runPanel?.startedBy === 'launch' ? ( + + ) : ( + showDontAskAgain && ( + // Why order-last: the narrow footer stacks bottom-up, so this keeps the option above the actions. + + ) + )} + {rows.length === 0 || (run?.inFlight === false && !choosing) ? ( diff --git a/src/renderer/src/components/NativeChatResumeRunPanel.tsx b/src/renderer/src/components/NativeChatResumeRunPanel.tsx index 83746c6b055..1e831d18f17 100644 --- a/src/renderer/src/components/NativeChatResumeRunPanel.tsx +++ b/src/renderer/src/components/NativeChatResumeRunPanel.tsx @@ -3,7 +3,7 @@ import { Loader2 } from 'lucide-react' import { translate } from '@/i18n/i18n' import { useNow } from '@/hooks/use-now' import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' -import { resumeRunInFlight, type ResumeRun } from './native-chat-resume-run' +import { resumeRunInFlight, type ResumeRun, type ResumeRunStarter } from './native-chat-resume-run' import { resumeRunView, type ResumeRunFilter } from './native-chat-resume-run-view' import { ResumeRunStatusIcon } from './NativeChatResumeRunStatusIcon' import { ResumeRunSummary } from './NativeChatResumeRunSummary' @@ -16,11 +16,37 @@ import { ResumeRunSummary } from './NativeChatResumeRunSummary' export type ResumeRunPanel = { title: React.ReactNode - /** Null once the run is over: the offer's own copy then describes what is left. */ - description: string | null + description: string summary: React.ReactNode rows: ResumeCandidate[] renderStatus: (sessionId: string, title: string) => React.ReactNode + /** Whether any listed chat still shows a checkbox; when none does, the dialog is a result. */ + choosing: boolean + startedBy: ResumeRunStarter +} + +/** What the run did, in the past tense: the offer's copy describes an action not yet taken. */ +function finishedDescription(everyChatResumed: boolean, byUpdate: boolean): string { + if (everyChatResumed) { + return byUpdate + ? translate( + 'auto.components.NativeChatResumeRunPanel.finishedUpdate', + 'These chats were working when Orca installed an update. Orca restored each one where it stopped, with its full context, and asked the agent to check what it was doing before carrying on. Your own prompts weren’t re-sent.' + ) + : translate( + 'auto.components.NativeChatResumeRunPanel.finished', + 'These chats were working when Orca closed. Orca restored each one where it stopped, with its full context, and asked the agent to check what it was doing before carrying on. Your own prompts weren’t re-sent.' + ) + } + return byUpdate + ? translate( + 'auto.components.NativeChatResumeRunPanel.finishedPartialUpdate', + 'These chats were working when Orca installed an update. The ones Orca resumed were restored where they stopped, with their full context, and asked to check what they were doing before carrying on. Your own prompts weren’t re-sent. Check the list for chats that still need attention or can be resumed.' + ) + : translate( + 'auto.components.NativeChatResumeRunPanel.finishedPartial', + 'These chats were working when Orca closed. The ones Orca resumed were restored where they stopped, with their full context, and asked to check what they were doing before carrying on. Your own prompts weren’t re-sent. Check the list for chats that still need attention or can be resumed.' + ) } function runTitle(run: ResumeRun, total: number, resumed: number): React.ReactNode { @@ -56,11 +82,13 @@ export function useResumeRunPanel({ run, rows, failureFor, + selectableIds, open }: { run: ResumeRun | null rows: readonly ResumeCandidate[] failureFor: (sessionId: string) => ResumeFailure | undefined + selectableIds: ReadonlySet open: boolean }): ResumeRunPanel | null { // Each opening starts on All, as the dialog's own ticks start from their defaults. @@ -94,7 +122,10 @@ export function useResumeRunPanel({ 'auto.components.NativeChatResumeRunPanel.description', 'Each chat is restored with its full context and asked to check where it stopped. You can close this; the status bar keeps track.' ) - : null, + : finishedDescription( + view.counts.resumed === view.counts.all, + run.entries.some((entry) => entry.candidate.trigger === 'update') + ), summary: ( ), rows: view.rows, + // Current selection ignores the filter, so changing tabs never moves the layout. + choosing: [...selectableIds].some((sessionId) => !view.statusBySession.has(sessionId)), + startedBy: run.startedBy, renderStatus: (sessionId, title) => { const status = view.statusBySession.get(sessionId) return status ? ( diff --git a/src/renderer/src/components/NativeChatResumeTreeRow.tsx b/src/renderer/src/components/NativeChatResumeTreeRow.tsx index 56f637e805c..ec78d1c903b 100644 --- a/src/renderer/src/components/NativeChatResumeTreeRow.tsx +++ b/src/renderer/src/components/NativeChatResumeTreeRow.tsx @@ -1,6 +1,8 @@ +import { useContext } from 'react' import { ChevronRight } from 'lucide-react' import { translate } from '@/i18n/i18n' import { Checkbox } from './ui/checkbox' +import { ResumeTreeSelectingContext } from './native-chat-resume-tree-state' /** Indent per tree level, right of the shared checkbox column. */ const RESUME_TREE_INDENT_PX = 18 @@ -8,12 +10,13 @@ const RESUME_TREE_INDENT_PX = 18 /** Width of the checkbox column (w-7). */ const RESUME_TREE_CHECKBOX_COLUMN_PX = 28 -/** Where a row's body starts: the checkbox column, the arrow slot (size-4.5) and the body's pl-1. */ -const RESUME_TREE_BODY_OFFSET_PX = RESUME_TREE_CHECKBOX_COLUMN_PX + 18 + 4 +/** Where a row's body starts after the checkbox column: the arrow slot (size-4.5) and the body's pl-1. */ +const RESUME_TREE_BODY_OFFSET_PX = 18 + 4 /** * One row of the resume tree: the checkbox in the list's single left column, the indent for its * depth, a disclosure arrow (an empty slot on a leaf, so icons line up), then the caller's body. + * With nothing left to select there is no checkbox column, and a leaf's status takes its arrow slot. * * The row is a flat `treeitem` carrying its own `aria-level`, so the tree's keyboard handling can * find a row's disclosure without walking nested groups. @@ -53,37 +56,46 @@ export function ResumeTreeRow({ below?: React.ReactNode children: React.ReactNode }): React.JSX.Element { + const selecting = useContext(ResumeTreeSelectingContext) const indent = depth * RESUME_TREE_INDENT_PX - const Content = checkboxSlot ? 'div' : 'label' + const column = selecting ? RESUME_TREE_CHECKBOX_COLUMN_PX : 0 + const Content = checkboxSlot || !selecting ? 'div' : 'label' return (
- - {checkboxSlot ?? ( - onCheckedChange(next === true)} - aria-label={checkboxLabel} - /> - )} - + {selecting && ( + + {checkboxSlot ?? ( + onCheckedChange(next === true)} + aria-label={checkboxLabel} + /> + )} + + )} {expanded !== undefined && ( - // Laid over its slot rather than inside the label, so pressing it never toggles the - // checkbox. Off the tab order: Left/Right on the row's checkbox do the same. + // Picker keys use the checkbox; result groups expose the native disclosure button. @@ -116,7 +132,10 @@ export function ResumeTreeRow({ {trailing}
{below && ( -
+
{below}
)} 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 33e5faa5879..d16371402ae 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 @@ -17,7 +17,8 @@ import { beginResumeRun, resumeRunInFlight, resumeRunPendingIds, - type ResumeRun + type ResumeRun, + type ResumeRunStarter } from './native-chat-resume-run' import { forgetUnsentResumes, @@ -211,7 +212,10 @@ export async function reopenNativeChatRestartOffer(): Promise { } /** One host action owns the selection; its status feed reports each chat before the reply. */ -export async function continueNativeChatRestartOffer(sessionIds: readonly string[]): Promise { +export async function continueNativeChatRestartOffer( + sessionIds: readonly string[], + startedBy: ResumeRunStarter = 'user' +): Promise { const requested = [...new Set(sessionIds)] if (requested.length === 0) { return @@ -221,7 +225,8 @@ export async function continueNativeChatRestartOffer(sessionIds: readonly string const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row])) let actionRun = beginResumeRun( requested.flatMap((sessionId) => rows.get(sessionId) ?? []), - Date.now() + Date.now(), + startedBy ) setRun(actionRun) releaseRunStatus?.() @@ -347,7 +352,7 @@ async function loadLaunchOffer(): Promise { requestNativeChatResumeOnRestartDialog() return } - await continueNativeChatRestartOffer(allResumeSessionIds(offered)) + await continueNativeChatRestartOffer(allResumeSessionIds(offered), 'launch') } /** diff --git a/src/renderer/src/components/native-chat-resume-result-view.test.tsx b/src/renderer/src/components/native-chat-resume-result-view.test.tsx new file mode 100644 index 00000000000..a9d352fc316 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-result-view.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { expect, it, vi, type Mock } from 'vitest' +import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal' +import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' +import { + createResumeModalFixture, + failure, + offered, + type RestartRpc, + type ResumeStatusStream +} from './native-chat-resume-modal.test-support' +import { lastToastShow } from './native-chat-resume-toast.test-support' +import { chatBox, dontAskAgain } from './native-chat-resume-on-restart-modal.test-support' +import { useAppStore } from '../store' + +const rpc: Mock = vi.hoisted(() => vi.fn()) +const statusStream: ResumeStatusStream = vi.hoisted(() => ({ + emit: (_event: Parameters[0]) => {}, + snapshot: new Map() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: async ( + _target: unknown, + emit: ResumeStatusStream['emit'] + ) => { + statusStream.emit = emit + emit({ type: 'snapshot', sessions: [...statusStream.snapshot.values()] }) + return { unsubscribe: () => {} } + } +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const { mount, button, fakeHost } = createResumeModalFixture(rpc, statusStream) +const dialog = () => document.querySelector('[role="dialog"]') +const tree = () => document.querySelector('[role="tree"]') + +async function mountSurfaces(): Promise { + await mount( + <> + + + + ) +} + +it('shows a finished resume as a result, in the past tense, once nothing is left to choose', async () => { + fakeHost() + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => lastToastShow()?.()) + expect(dialog()?.textContent).toContain('Resumed 2 of 2 chats') + expect(dialog()?.textContent).toContain( + 'Orca restored each one where it stopped, with its full context' + ) + expect(dialog()?.textContent).not.toContain('Resuming restores') + expect(tree()?.querySelectorAll('[role="checkbox"]')).toHaveLength(0) + expect(tree()?.textContent).not.toContain('Select all') + // Auto-resume is still off, so the box stays after Resume. + expect(dialog()?.textContent).toContain("Don't ask again") + // The user pressed Resume, so nothing says it happened on its own. + expect(dialog()?.textContent).not.toContain('Resumed automatically') +}) + +it('saves "Don\'t ask again" ticked on a finished resume the user started', async () => { + fakeHost() + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => lastToastShow()?.()) + expect(dialog()?.textContent).toContain('Resumed 2 of 2 chats') + await act(async () => dontAskAgain().click()) + await act(async () => button('Done').click()) + expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) +}) + +it('does not restore a hidden picker preference when closing a run', async () => { + const host = fakeHost() + host.hold('a', 'b') + await mountSurfaces() + await act(async () => dontAskAgain().click()) + await act(async () => button('Resume 2 chats').click()) + expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) + + await act(async () => button('Resuming chats 0/2').click()) + expect(dialog()?.textContent).not.toContain("Don't ask again") + await act(async () => + useAppStore.getState().updateSettings({ nativeChatResumeWorkOnRestart: false }) + ) + await act(async () => button('Close').click()) + const afterInFlightClose = useAppStore.getState().settings?.nativeChatResumeWorkOnRestart + + await host.release('a', 'b') + await act(async () => lastToastShow()?.()) + expect(dialog()?.textContent).toContain('Resumed 2 of 2 chats') + // Turned off elsewhere, the box is back, unticked: the earlier tick was consumed. + expect(dontAskAgain().getAttribute('data-state')).toBe('unchecked') + await act(async () => + useAppStore.getState().updateSettings({ nativeChatResumeWorkOnRestart: false }) + ) + await act(async () => button('Done').click()) + expect([ + afterInFlightClose, + useAppStore.getState().settings?.nativeChatResumeWorkOnRestart + ]).toEqual([false, false]) +}) + +it('starts a remaining-offer picker without the preference consumed by the previous resume', async () => { + fakeHost() + await mountSurfaces() + await act(async () => chatBox('b').click()) + await act(async () => dontAskAgain().click()) + await act(async () => button('Resume 1 chat').click()) + expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) + + await act(async () => button('1 chat to resume').click()) + expect(dialog()?.textContent).toContain('Resumed 1 of 1 chat') + await act(async () => button('Close').click()) + await act(async () => + useAppStore.getState().updateSettings({ nativeChatResumeWorkOnRestart: false }) + ) + + await act(async () => button('1 chat to resume').click()) + const pickerCheckbox = dontAskAgain().getAttribute('data-state') + await act(async () => button('Close').click()) + expect([pickerCheckbox, useAppStore.getState().settings?.nativeChatResumeWorkOnRestart]).toEqual([ + 'unchecked', + false + ]) +}) + +it('reopens a collapsed result group with tree keys', async () => { + fakeHost() + await mountSurfaces() + expect(button('Collapse workspace').tabIndex).toBe(-1) + await act(async () => button('Resume 2 chats').click()) + await act(async () => lastToastShow()?.()) + const disclosure = button('Collapse workspace') + const press = async (key: string) => + act(async () => { + document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + }) + expect(disclosure.tabIndex).toBe(0) + tree()?.focus() + await press('ArrowDown') + expect(document.activeElement).toBe(disclosure) + await press('ArrowLeft') + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(disclosure) + await press('ArrowRight') + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(document.activeElement).toBe(disclosure) +}) + +it('shows a settled nonretryable failure as a result with a Done action', async () => { + const failed = { ...failure('a'), outcome: 'unconfirmed' as const, retryable: false } + let acted = false + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: acted ? [] : [offered[0]], failed: acted ? [failed] : [] } + } + acted = true + return { + sessions: [], + failed: [failed], + continued: [{ sessionId: 'a', outcome: 'unknown' }] + } + }) + await mountSurfaces() + await act(async () => button('Resume 1 chat').click()) + await act(async () => lastToastShow()?.()) + expect(dialog()?.textContent).toContain('Resumed 0 of 1 chat') + expect(tree()?.textContent).not.toContain('Select all') + expect(tree()?.querySelectorAll('[role="checkbox"]')).toHaveLength(0) + expect(button('Open chat')).toBeTruthy() + expect(button('Dismiss "Prompt a" in workspace')).toBeTruthy() + expect(button('Done').disabled).toBe(false) + expect(dialog()?.textContent).not.toContain('Resume 0 chats') +}) + +it('does not place an untouched offer in the Need you filter', async () => { + fakeHost() + await mountSurfaces() + const secondChat = document.querySelector( + '[role="checkbox"][aria-label*="Prompt b"]' + ) + await act(async () => secondChat?.click()) + await act(async () => button('Resume 1 chat').click()) + await act(async () => lastToastShow()?.()) + expect(dialog()?.textContent).toContain('Check the list for chats that still need attention') + expect(dialog()?.textContent).not.toContain('Chats that need you are at the top') + expect(button('Need you0')).toBeTruthy() + expect(button('Resume 1 chat')).toBeTruthy() +}) + +it('says a launch resumed on its own and links to the setting', async () => { + const openSettingsPage = vi.fn() + const openSettingsTarget = vi.fn() + useAppStore.setState((state) => ({ + settings: { ...state.settings!, nativeChatResumeWorkOnRestart: true }, + openSettingsPage, + openSettingsTarget + })) + fakeHost() + await mountSurfaces() + expect(dialog()).toBeNull() + await act(async () => lastToastShow()?.()) + expect(dialog()?.textContent).toContain('Resumed automatically after the restart.') + expect(dialog()?.textContent).not.toContain("Don't ask again") + + await act(async () => button('Change in Settings').click()) + expect(openSettingsPage).toHaveBeenCalledOnce() + expect(openSettingsTarget).toHaveBeenCalledWith({ + pane: 'experimental', + repoId: null, + sectionId: 'native-chat-resume-on-restart' + }) + // The dialog gets out of the way of the page it opened. + expect(dialog()).toBeNull() +}) + +it('keeps the checkboxes while a chat can still be chosen, whichever tab is shown', async () => { + fakeHost({}, (sessionId) => (sessionId === 'b' ? 'refused' : 'continued')) + await mountSurfaces() + await act(async () => button('Resume 2 chats').click()) + await act(async () => button('1 chat failed to resume').click()) + expect(dialog()?.textContent).toContain('The ones Orca resumed were restored') + expect(tree()?.textContent).toContain('Select all') + await act(async () => button('Resumed1').click()) + // Filtering to rows that all show a status must not drop the column under the user. + expect(tree()?.textContent).toContain('Select all') +}) diff --git a/src/renderer/src/components/native-chat-resume-run.test.ts b/src/renderer/src/components/native-chat-resume-run.test.ts index 21badcab4d7..c5bba0ec7ee 100644 --- a/src/renderer/src/components/native-chat-resume-run.test.ts +++ b/src/renderer/src/components/native-chat-resume-run.test.ts @@ -18,7 +18,7 @@ const row = (sessionId: string): ResumeCandidate => ({ recordedAt: 1 }) const failure = { ...row('a'), failedAt: 2, outcome: 'unconfirmed' as const, reason: 'pending' } -const run = beginResumeRun([row('a'), row('b')], 10) +const run = beginResumeRun([row('a'), row('b')], 10, 'user') describe('the dialog over a bulk action', () => { it('never regresses a seen phase or verdict when live progress clears', () => { diff --git a/src/renderer/src/components/native-chat-resume-run.ts b/src/renderer/src/components/native-chat-resume-run.ts index 5f7cc7669dc..4bef31af5e6 100644 --- a/src/renderer/src/components/native-chat-resume-run.ts +++ b/src/renderer/src/components/native-chat-resume-run.ts @@ -7,9 +7,13 @@ export type ResumeRunHostStatus = Pick< 'restartResume' | 'hostExecutionPhase' > +/** Who started a run: this launch's "resume automatically" preference, or the user's own click. */ +export type ResumeRunStarter = 'launch' | 'user' + /** Selection and history belong to this window; live progress and failures belong to the host. */ export type ResumeRun = Readonly<{ startedAt: number + startedBy: ResumeRunStarter entries: readonly Readonly<{ candidate: ResumeCandidate; observedStatus?: ResumeRunHostStatus }>[] inFlight: boolean continued?: readonly RestartContinuationOutcome[] @@ -60,8 +64,17 @@ export function resumeRunInFlight(run: ResumeRun | null): boolean { return run?.inFlight ?? false } -export function beginResumeRun(candidates: readonly ResumeCandidate[], now: number): ResumeRun { - return { startedAt: now, entries: candidates.map((candidate) => ({ candidate })), inFlight: true } +export function beginResumeRun( + candidates: readonly ResumeCandidate[], + now: number, + startedBy: ResumeRunStarter +): ResumeRun { + return { + startedAt: now, + startedBy, + entries: candidates.map((candidate) => ({ candidate })), + inFlight: true + } } const NONE: readonly string[] = [] diff --git a/src/renderer/src/components/native-chat-resume-tree-keys.ts b/src/renderer/src/components/native-chat-resume-tree-keys.ts new file mode 100644 index 00000000000..89238270e54 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-tree-keys.ts @@ -0,0 +1,49 @@ +/** + * Tree keys. Picker checkboxes and result disclosures share arrow navigation; native Space/Enter + * still acts on the focused control. The picker keeps its disclosures outside the Tab order. + */ +export function moveInTree(event: React.KeyboardEvent): void { + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + const controls = [ + ...event.currentTarget.querySelectorAll( + '[role="checkbox"]:not(:disabled), button[aria-expanded][tabindex="0"]' + ) + ] + const onTree = target === event.currentTarget + if (!onTree && !controls.includes(target)) { + return + } + const jump = + event.key === 'Home' || (onTree && event.key === 'ArrowDown') + ? controls[0] + : event.key === 'End' || (onTree && event.key === 'ArrowUp') + ? controls.at(-1) + : undefined + if (jump) { + event.preventDefault() + jump.focus() + return + } + if (onTree) { + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + controls[controls.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 owns this control's disclosure. + 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-state.ts b/src/renderer/src/components/native-chat-resume-tree-state.ts index 74b809eb8d7..34981d0ebce 100644 --- a/src/renderer/src/components/native-chat-resume-tree-state.ts +++ b/src/renderer/src/components/native-chat-resume-tree-state.ts @@ -39,6 +39,9 @@ export function useResumeTreeExpansion(): { /** The tree depth a chat row renders at; the enclosing workspace node provides it. */ export const ResumeTreeDepthContext = createContext(0) +/** False once no chat is selectable: the tree drops its checkbox column. */ +export const ResumeTreeSelectingContext = createContext(true) + /** Lets a row that an earlier resume could not carry on show what went wrong and what to do. */ export type FailureProps = { failureFor?: (sessionId: string) => ResumeFailure | undefined diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 34c5de50a65..9ee9ba113fb 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -8,6 +8,7 @@ import { NativeChatSupportedAgents } from './NativeChatSupportedAgents' import { SearchableSetting } from './SearchableSetting' import { SettingsSwitch } from './SettingsFormControls' import { getExperimentalSearchEntry } from './experimental-search' +import { NATIVE_CHAT_RESUME_SETTING_SECTION_ID } from './native-chat-resume-setting-target' type NativeChatExperimentalSettingProps = { settings: GlobalSettings @@ -64,7 +65,10 @@ export function NativeChatExperimentalSetting({
{/* Only structured sessions have a resume cursor to continue from. */} {structuredChatActive ? ( -
+