mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
fix(native-chat): keep chats that failed to resume in the status bar and say what to do (#22448)
* fix(native-chat): keep chats that failed to resume in the status bar and say what to do After a restart, a chat whose resume did not carry on was reported only by a four-second toast that named nothing, and the status bar entry vanished because the reattach had already spent the offer. The host now files the outcome as a durable `failed` entry in the recovery capsule, with the refusal code and the prompt the offer quoted, and returns it from the restart-resume RPCs. The renderer shows a "N chats failed to resume" status bar entry, a count-only toast with Show and Dismiss, and keeps the resume dialog open with a status icon per row and a "To resume" line whose action is chosen from the reason. A failure dies on dismiss, on a successful retry, on the user's own send in that chat, or with the marker's 24h expiry. * fix(native-chat): keep the resume dialog unchanged and add failed chats as rows The failure view had replaced the resume dialog's title, checkboxes, preference box, and footer. The dialog is back to main's layout. A chat an earlier resume could not carry on is now an ordinary selectable row there, plus a status icon whose tooltip carries the reason, a dismiss control, and a "To resume" line. Selecting it and pressing Resume retries it; it is pre-selected only when a retry can succeed. Resumed chats leave the list as before. Also stubs the new failure listing on the cross-version wire host fixture, which the restart-resume RPC now reaches. * refactor(native-chat): release a failed-resume record where a send is admitted Keeps the host file at main's size, and only releases the record for a send the controller actually lets through. * fix(native-chat): note a failed restart resume in the chat and derive when it is settled The chat itself now says when Orca could not continue it after a restart, with an error (refused) or warning (unconfirmed) status row, so the failure survives the toast, a dismissed record, and another restart. A recorded failure is current only while the chat's newest user message is the one it had when the failure was filed. Listing re-derives that from the journal and prunes superseded records, replacing the in-memory set and the hook on every send. Failures move to their own optional top-level key in the recovery file, so an older build that rejects unknown entry states keeps reading its offers. A retried failure stays a failure through a rollback or a lapsed lease instead of returning as a pending offer, and the toast's Dismiss names only the chats the host listed as failed. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): check the older reader against a filed failure before any retry Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): file a failed restart resume against the chat as its attempt ended A failure was filed against the chat's newest user message read at settlement, after every chat in the action had finished. The chat's note asks the user to send a message, and one sent while other chats were still being continued became part of the filed state, so the failure stayed listed after the user had done what it asked. Each chat's newest message is now observed as its own attempt ends. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): let a reply that made a chat ineligible retire its failure When a restart resume reached a chat the user had already replied in, the attempt was refused as no longer eligible, but the failure was filed against that very reply. It then stayed listed as "finished on its own" until the user sent yet another message. An ineligible chat is no longer observed at the attempt, so its failure falls back to the reserved marker and the reply that made it ineligible supersedes it at the next listing. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): offer Retry on a failed resume only when a retry would run When the agent refused Orca's "continue" message with its own reason, the failed row fell to the generic guidance, which offers Retry and pre-selects the chat in the resume dialog. The refused message is already the chat's newest user message, so a retry is never eligible: it did nothing and the same "couldn't be resumed" toast came back. The host now reports whether a retry would run, derived at list time from the same predicate the retry applies to the failure's marker. Where it would not, the row offers Open chat and Dismiss and is not pre-selected. An older host omits the flag and the reason alone decides, as before. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): file only restart failures the user must act on A chat that stopped being resumable between listing and acting (it finished on its own or is waiting on the user) was filed as a failure with no note in the chat. It now just spends the offer. A failed reattach now writes the same in-chat note as a refused continuation, so every filed failure explains itself in the chat. An unconfirmed continuation's failure retires once the chat shows the continuation's own message opened the newest turn. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): clear a failed resume from the status bar once its chat moves on While a failed resume is listed, the restart store watches the host's status feed; when a failed chat's status or latest prompt changes after the list was read, it re-reads the host once. The host still decides whether the failure stands. Nothing is watched while nothing failed. The failure toast now counts only the requested chats the host still lists as failed, keeping the old count for a host that sends no list. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that an unjournaled continuation never retires its unconfirmed failure Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop telling the user to send a message into a chat Orca couldn't reconnect A failed reattach, or a continuation refused because another window or terminal owns the session, now leaves a note saying Orca couldn't reconnect the chat instead of advising a send that would meet the same refusal. The restart list keeps the reason-specific advice. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the failed-chat re-read from undoing an action or missing a reply The status-bar re-read no longer runs while a resume or dismiss is in flight, and its answer is dropped if one settled meanwhile, so a dismissed failure cannot come back. A change to a failed chat already seen always triggers it, whatever the host's timestamp says. A chat whose unconfirmed continuation the host already retired is now reported as resumed instead of saying nothing. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that no failed-chat re-read runs under a resume in flight Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): typecheck the unjournaled-continuation case against a nullable marker Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): drop the stale "no arguments" note on the restart-offer params The dismiss call now names sessions, so the older comment contradicted the schema below it. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): count unconfirmed resumes apart from refused ones in the action toast The post-action toast said "N chats couldn't be resumed" for chats whose continuation may well have gone out, while the list and the chat itself say Orca couldn't confirm it. That wording invites a duplicate "continue" send. Unconfirmed chats now get their own count, classified by the outcome the host filed, so the toast matches the row it points to. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop offering Resume on a failure the host says cannot retry A failed row the host marks unretryable could still be ticked, sending a resume that could only fail again; its checkbox is now disabled and it never joins the action. An older host that omits the flag keeps today's selectable row. The status bar no longer calls a chat "failed to resume" when the host only couldn't confirm the resume, matching the dialog's own wording, and the mixed toast's second line now says "other" so it cannot read as the same chat. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): skip unreadable failure records instead of rejecting the capsule A failure record this build cannot parse (a newer outcome, say) made the whole recovery file unreadable, so a downgraded build listed no restart offers and could not record new teardowns. Failures are advisory: parse each one on its own and drop what does not parse. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a failure record with an unreadable marker is skipped The skip-unreadable-failure test only covered an unknown outcome, so going back to the throwing marker parser for failure records still passed. A failure record usually outlives its offer entry, so a newer marker shape can appear only there. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,9 @@
|
||||
--color-status-success: var(--status-success);
|
||||
--color-status-success-background: var(--status-success-background);
|
||||
--color-status-success-border: var(--status-success-border);
|
||||
--color-status-warning: var(--status-warning);
|
||||
--color-status-warning-background: var(--status-warning-background);
|
||||
--color-status-warning-border: var(--status-warning-border);
|
||||
--color-workspace-status-done: var(--workspace-status-done);
|
||||
--color-workspace-status-review: var(--workspace-status-review);
|
||||
--color-workspace-status-progress: var(--workspace-status-progress);
|
||||
@@ -185,6 +188,11 @@
|
||||
--status-success: #15803d;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
/* Needs-attention state that is not an error: a resume that did not carry on, an update that
|
||||
failed. Yellow, so it stays apart from destructive red and from the orange working state. */
|
||||
--status-warning: #ca8a04;
|
||||
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
|
||||
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
|
||||
/* Workspace PR-state tones: same hue in both themes so a status keeps its
|
||||
identity when the theme flips. */
|
||||
--workspace-status-done: #c7a594;
|
||||
@@ -299,6 +307,9 @@
|
||||
--status-success: #86efac;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
--status-warning: #eab308;
|
||||
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
|
||||
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
|
||||
--workspace-status-done: #c7a594;
|
||||
--workspace-status-review: #16a34a;
|
||||
--workspace-status-progress: #d4a300;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AlertCircle, Clock, X } from 'lucide-react'
|
||||
import { Button } from './ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
resumeFailureGuidance,
|
||||
type ResumeFailureAction
|
||||
} from './native-chat-resume-failure-guidance'
|
||||
import type { ResumeFailure } from './native-chat-resume-on-restart-grouping'
|
||||
|
||||
/**
|
||||
* What a failed row adds to an ordinary offered row: one status icon whose tooltip carries the
|
||||
* status and the host's reason, a dismiss control, and a "To resume" line with the button that does
|
||||
* it. Retry is offered only where a retry can succeed.
|
||||
*/
|
||||
|
||||
function actionLabel(action: ResumeFailureAction): string {
|
||||
return action === 'open'
|
||||
? translate('auto.components.NativeChatResumeOutcomeRow.openChat', 'Open chat')
|
||||
: action === 'retry'
|
||||
? translate('auto.components.NativeChatResumeOutcomeRow.retry', 'Retry')
|
||||
: translate('auto.components.NativeChatResumeOutcomeRow.dismiss', 'Dismiss')
|
||||
}
|
||||
|
||||
export function ResumeFailureStatus({
|
||||
failure,
|
||||
title,
|
||||
workspaceName,
|
||||
disabled,
|
||||
onAction
|
||||
}: {
|
||||
failure: ResumeFailure
|
||||
title: string
|
||||
workspaceName: string
|
||||
disabled: boolean
|
||||
onAction: (action: ResumeFailureAction) => void
|
||||
}): React.JSX.Element {
|
||||
const status =
|
||||
failure.outcome === 'unconfirmed'
|
||||
? translate(
|
||||
'auto.components.NativeChatResumeOutcomeRow.unconfirmed',
|
||||
'Couldn’t confirm the chat was resumed'
|
||||
)
|
||||
: translate('auto.components.NativeChatResumeOutcomeRow.failed', 'Couldn’t resume')
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Focusable so keyboard users reach the same tooltip; the accessible name says which chat. */}
|
||||
<span
|
||||
tabIndex={0}
|
||||
role="img"
|
||||
aria-label={translate(
|
||||
'auto.components.NativeChatResumeOutcomeRow.statusFor',
|
||||
'{{value0}}: {{value1}}',
|
||||
{ value0: title, value1: status }
|
||||
)}
|
||||
className="inline-flex shrink-0 rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{failure.outcome === 'unconfirmed' ? (
|
||||
<Clock className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<AlertCircle className="size-3.5 text-status-warning" />
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="max-w-72">
|
||||
{status}
|
||||
{/* The code verbatim, so it can be quoted in a report. */}
|
||||
<span className="mt-0.5 block font-mono text-[10px] opacity-75">{failure.reason}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.NativeChatResumeOutcomeRow.dismissChat',
|
||||
'Dismiss "{{value0}}" in {{value1}}',
|
||||
{ value0: title, value1: workspaceName }
|
||||
)}
|
||||
onClick={() => onAction('dismiss')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResumeFailureGuidanceLine({
|
||||
failure,
|
||||
disabled,
|
||||
onAction
|
||||
}: {
|
||||
failure: ResumeFailure
|
||||
disabled: boolean
|
||||
onAction: (action: ResumeFailureAction) => void
|
||||
}): React.JSX.Element {
|
||||
const guidance = resumeFailureGuidance(failure)
|
||||
return (
|
||||
<div className="ml-6 mb-1 flex items-center gap-2 rounded-md border border-status-warning-border bg-status-warning-background px-2 py-1.5 text-[11px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-semibold">
|
||||
{translate('auto.components.NativeChatResumeOutcomeRow.toResume', 'To resume:')}
|
||||
</span>{' '}
|
||||
{guidance.text}
|
||||
</span>
|
||||
<Button size="xs" disabled={disabled} onClick={() => onAction(guidance.primary)}>
|
||||
{actionLabel(guidance.primary)}
|
||||
</Button>
|
||||
{guidance.secondary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={() => onAction(guidance.secondary!)}
|
||||
>
|
||||
{actionLabel(guidance.secondary)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { formatShortTimeAgo } from '@/lib/short-time-ago'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping'
|
||||
import {
|
||||
resumeFailureSelectable,
|
||||
type ResumeFailureAction
|
||||
} from './native-chat-resume-failure-guidance'
|
||||
import { ResumeFailureGuidanceLine, ResumeFailureStatus } from './NativeChatResumeFailureDetails'
|
||||
|
||||
/**
|
||||
* One offered chat, laid out like the sidebar's compact agent row: provider glyph, the chat's name,
|
||||
@@ -18,6 +23,9 @@ import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
* No state dot, deliberately. Every `AgentDotState` would mislead: `idle` and `unverifiable` both
|
||||
* presuppose a live pane, `interrupted` renders red like an error, `done` green, `working` a
|
||||
* spinner. A missing dot beats a dot that says these agents are running.
|
||||
*
|
||||
* A chat an earlier resume could not carry on is the same row — selectable where a retry can run,
|
||||
* so Resume retries it — plus a status icon, a dismiss control, and a line saying what to do.
|
||||
*/
|
||||
export function ResumeCandidateRow({
|
||||
candidate,
|
||||
@@ -25,7 +33,9 @@ export function ResumeCandidateRow({
|
||||
listedAt,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange
|
||||
onCheckedChange,
|
||||
failure,
|
||||
onFailureAction
|
||||
}: {
|
||||
candidate: ResumeCandidate
|
||||
/** Named in the checkbox's accessible name: several rows otherwise read identically. */
|
||||
@@ -34,46 +44,67 @@ export function ResumeCandidateRow({
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
/** Present when an earlier resume of this chat did not carry on. */
|
||||
failure?: ResumeFailure
|
||||
onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void
|
||||
}): React.JSX.Element {
|
||||
const agentLabel = formatAgentTypeLabel(candidate.agent)
|
||||
const title =
|
||||
candidate.latestPrompt.trim() ||
|
||||
translate('auto.components.NativeChatResumeOnRestartModal.untitled', 'Untitled chat')
|
||||
const model = candidate.model?.trim() ?? ''
|
||||
return (
|
||||
<li>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-accent/50">
|
||||
{/* Identifies the agent AND its workspace: the accessible name has to distinguish rows that
|
||||
const row = (
|
||||
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded px-1 py-1 hover:bg-accent/50">
|
||||
{/* Identifies the agent AND its workspace: the accessible name has to distinguish rows that
|
||||
would otherwise all read the same. */}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(next) => onCheckedChange(next === true)}
|
||||
className="shrink-0"
|
||||
aria-label={translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.selectAgent',
|
||||
'Resume {{value0}} chat "{{value1}}" in {{value2}}',
|
||||
{ value0: agentLabel, value1: title, value2: workspaceName }
|
||||
)}
|
||||
/>
|
||||
{/* AgentIcon carries no label of its own, so the provider was invisible to assistive tech. */}
|
||||
<span role="img" aria-label={agentLabel} className="inline-flex shrink-0">
|
||||
<AgentIcon agent={agentTypeToIconAgent(candidate.agent)} size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{title}</span>
|
||||
{model && (
|
||||
<span
|
||||
className="min-w-0 max-w-24 shrink-0 truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={model}
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled || (failure !== undefined && !resumeFailureSelectable(failure))}
|
||||
onCheckedChange={(next) => onCheckedChange(next === true)}
|
||||
className="shrink-0"
|
||||
aria-label={translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.selectAgent',
|
||||
'Resume {{value0}} chat "{{value1}}" in {{value2}}',
|
||||
{ value0: agentLabel, value1: title, value2: workspaceName }
|
||||
)}
|
||||
{/* `formatShortTimeAgo` takes (timestamp, now) and subtracts internally — NOT a delta. */}
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatShortTimeAgo(candidate.recordedAt, listedAt)}
|
||||
/>
|
||||
{/* AgentIcon carries no label of its own, so the provider was invisible to assistive tech. */}
|
||||
<span role="img" aria-label={agentLabel} className="inline-flex shrink-0">
|
||||
<AgentIcon agent={agentTypeToIconAgent(candidate.agent)} size={14} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{title}</span>
|
||||
{model && (
|
||||
<span
|
||||
className="min-w-0 max-w-24 shrink-0 truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={model}
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{/* `formatShortTimeAgo` takes (timestamp, now) and subtracts internally — NOT a delta. */}
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{formatShortTimeAgo(candidate.recordedAt, listedAt)}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
if (!failure) {
|
||||
return <li>{row}</li>
|
||||
}
|
||||
const act = (action: ResumeFailureAction) => onFailureAction?.(action, candidate.sessionId)
|
||||
return (
|
||||
<li className="flex flex-col">
|
||||
{/* Outside the label, so pressing them never toggles the checkbox. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{row}
|
||||
<ResumeFailureStatus
|
||||
failure={failure}
|
||||
title={title}
|
||||
workspaceName={workspaceName}
|
||||
disabled={disabled}
|
||||
onAction={act}
|
||||
/>
|
||||
</div>
|
||||
<ResumeFailureGuidanceLine failure={failure} disabled={disabled} onAction={act} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
resolveResumeGroupHeader,
|
||||
resumeWorkspaceKind,
|
||||
type ResumeCandidate,
|
||||
type ResumeFailure,
|
||||
type ResumeWorkspaceGroup
|
||||
} from './native-chat-resume-on-restart-grouping'
|
||||
import type { ResumeFailureAction } from './native-chat-resume-failure-guidance'
|
||||
|
||||
export type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
|
||||
@@ -32,6 +34,12 @@ export type { ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
/** Lets a row that an earlier resume could not carry on show what went wrong and what to do. */
|
||||
type FailureProps = {
|
||||
failureFor?: (sessionId: string) => ResumeFailure | undefined
|
||||
onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void
|
||||
}
|
||||
|
||||
function resolveWorkspaceWorktree(store: StoreState, workspaceId: string) {
|
||||
return (
|
||||
store.getKnownWorktreeById(workspaceId) ??
|
||||
@@ -110,14 +118,16 @@ function WorkspaceGroup({
|
||||
listedAt,
|
||||
busy,
|
||||
selected,
|
||||
onToggle
|
||||
onToggle,
|
||||
failureFor,
|
||||
onFailureAction
|
||||
}: {
|
||||
group: ResumeWorkspaceGroup
|
||||
listedAt: number
|
||||
busy: boolean
|
||||
selected: ReadonlySet<string>
|
||||
onToggle: (sessionId: string, checked: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
} & FailureProps): React.JSX.Element {
|
||||
const name = useWorkspaceName(group.workspaceId)
|
||||
const first = group.candidates[0]
|
||||
const kind = first ? resumeWorkspaceKind(first) : 'git-worktree'
|
||||
@@ -141,6 +151,8 @@ function WorkspaceGroup({
|
||||
checked={selected.has(candidate.sessionId)}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => onToggle(candidate.sessionId, checked)}
|
||||
failure={failureFor?.(candidate.sessionId)}
|
||||
onFailureAction={onFailureAction}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -153,14 +165,16 @@ export function ResumeOnRestartGroups({
|
||||
listedAt,
|
||||
busy,
|
||||
selected,
|
||||
onToggle
|
||||
onToggle,
|
||||
failureFor,
|
||||
onFailureAction
|
||||
}: {
|
||||
candidates: readonly ResumeCandidate[]
|
||||
listedAt: number
|
||||
busy: boolean
|
||||
selected: ReadonlySet<string>
|
||||
onToggle: (sessionId: string, checked: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
} & FailureProps): React.JSX.Element {
|
||||
const workspaces = groupResumeCandidates(candidates)
|
||||
const repoIdFor = useRepoIdByWorkspace(workspaces.map((group) => group.workspaceId))
|
||||
const repoGroups = groupResumeWorkspacesByRepo(workspaces, repoIdFor)
|
||||
@@ -178,6 +192,8 @@ export function ResumeOnRestartGroups({
|
||||
busy={busy}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
failureFor={failureFor}
|
||||
onFailureAction={onFailureAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,15 +10,25 @@ 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 } from './native-chat-resume-on-restart-dialog'
|
||||
import {
|
||||
consumeNativeChatResumeOnRestartDialogRequest,
|
||||
requestNativeChatResumeOnRestartDialog
|
||||
} from './native-chat-resume-on-restart-dialog'
|
||||
import {
|
||||
_resetNativeChatRestartOffer,
|
||||
getNativeChatRestartOffer
|
||||
getNativeChatRestartOffer,
|
||||
refreshNativeChatRestartOffer
|
||||
} from './native-chat-resume-on-restart-store'
|
||||
|
||||
const rpc = vi.hoisted(() => vi.fn())
|
||||
const activate = vi.hoisted(() => vi.fn(async () => true))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: rpc
|
||||
callStructuredAgentSession: rpc,
|
||||
// A failed row opens the status feed; these cases never drive it.
|
||||
subscribeStructuredAgentSessionStatus: () => new Promise(() => {})
|
||||
}))
|
||||
vi.mock('@/lib/activate-ai-vault-structured-session', () => ({
|
||||
activateAiVaultStructuredSession: activate
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: vi.fn() }))
|
||||
|
||||
@@ -36,9 +46,34 @@ const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({
|
||||
workspaceKind: 'git-worktree'
|
||||
}))
|
||||
|
||||
/** A chat the host acted on and could not carry on, as it reports it. */
|
||||
function failure(sessionId: string, reason = 'agent_session_restart_work_superseded') {
|
||||
const candidate = offered.find((entry) => entry.sessionId === sessionId)!
|
||||
return { ...candidate, failedAt: candidate.recordedAt + 60_000, outcome: 'refused', reason }
|
||||
}
|
||||
|
||||
/** Outcome rows carry tooltips, so every mount needs the provider the app shell supplies. */
|
||||
async function mount(node: React.ReactNode): Promise<void> {
|
||||
await act(async () => root.render(<TooltipProvider>{node}</TooltipProvider>))
|
||||
}
|
||||
|
||||
/** Sonner types a toast action as either 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 action is not clickable')
|
||||
}
|
||||
entry.onClick()
|
||||
}
|
||||
|
||||
function button(text: string): HTMLButtonElement {
|
||||
const found = [...document.querySelectorAll('button')].find(
|
||||
(entry) => entry.textContent?.trim() === text
|
||||
(entry) => entry.textContent?.trim() === text || entry.getAttribute('aria-label') === text
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Missing button: ${text}`)
|
||||
@@ -93,7 +128,7 @@ it('keeps next-launch preference out of the current resume action', async () =>
|
||||
}
|
||||
return action.promise
|
||||
})
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => checkbox(1).click())
|
||||
await act(async () => checkbox(2).click())
|
||||
await act(async () => button('Resume 1 chat').click())
|
||||
@@ -115,7 +150,7 @@ it('keeps next-launch preference out of the current resume action', async () =>
|
||||
// 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 act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
// Row and preference checkboxes are buttons too; the controls are what is left after them.
|
||||
const controls = document.querySelectorAll('[role="dialog"] button:not([role="checkbox"])')
|
||||
expect([...controls].map((entry) => entry.textContent?.trim())).toEqual([
|
||||
@@ -129,7 +164,7 @@ it('offers exactly Dismiss all and the resume action', async () => {
|
||||
// other way out, and calls NOTHING — the offer is the host's and stays exactly where it was.
|
||||
it('snoozes to the status-bar offer when the dialog is closed', async () => {
|
||||
rpc.mockResolvedValue({ sessions: offered })
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => checkbox(2).click())
|
||||
await act(async () => button('Close').click())
|
||||
expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true)
|
||||
@@ -144,7 +179,7 @@ it('fully dismisses the offer only through Dismiss all', async () => {
|
||||
? { sessions: offered }
|
||||
: { dismissed: 2, sessions: [] }
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Dismiss all').click())
|
||||
expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([
|
||||
['agentSession.restartResumable', undefined],
|
||||
@@ -162,7 +197,7 @@ it('reports a dismissal the host never confirmed instead of trapping the dialog'
|
||||
}
|
||||
throw new Error('response lost')
|
||||
})
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Dismiss all').click())
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
expect(toast).toHaveBeenCalledWith(expect.stringContaining('was not confirmed'))
|
||||
@@ -174,7 +209,7 @@ it('saves Don’t ask again when the offer is dismissed outright', async () => {
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable' ? { sessions: offered } : { dismissed: 2 }
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => checkbox(2).click())
|
||||
await act(async () => button('Dismiss all').click())
|
||||
expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true)
|
||||
@@ -196,13 +231,11 @@ it('never re-offers a resumed chat when the status entry reopens the dialog', as
|
||||
sessions: remaining
|
||||
}
|
||||
})
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<NativeChatResumeOnRestartModal />
|
||||
<NativeChatResumeStatusSegment iconOnly={false} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await mount(
|
||||
<>
|
||||
<NativeChatResumeOnRestartModal />
|
||||
<NativeChatResumeStatusSegment iconOnly={false} />
|
||||
</>
|
||||
)
|
||||
await act(async () => checkbox(1).click())
|
||||
await act(async () => button('Resume 1 chat').click())
|
||||
@@ -230,7 +263,7 @@ it('settles the offer for the chats a resume reattached', async () => {
|
||||
sessions: [offered[1]!]
|
||||
}
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => checkbox(1).click())
|
||||
await act(async () => button('Resume 1 chat').click())
|
||||
expect(offerIds()).toEqual(['b'])
|
||||
@@ -255,12 +288,10 @@ it('resumes and continues once when the launch begins opted in', async () => {
|
||||
sessions: []
|
||||
}
|
||||
)
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<NativeChatResumeOnRestartModal />
|
||||
</StrictMode>
|
||||
)
|
||||
await mount(
|
||||
<StrictMode>
|
||||
<NativeChatResumeOnRestartModal />
|
||||
</StrictMode>
|
||||
)
|
||||
await act(async () =>
|
||||
useAppStore.getState().updateSettings({ nativeChatResumeWorkOnRestart: false })
|
||||
@@ -302,10 +333,14 @@ it('reports refused and newly ineligible chats on an opted-in launch', async ()
|
||||
sessions: offered
|
||||
}
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
// One count, no names: the modal has the list. The host listed no failure, so there is nothing
|
||||
// the toast may forget — a chat that only dropped out of the answer is still an offer.
|
||||
expect(toast).toHaveBeenCalledWith(
|
||||
'2 chats could not be continued. Open them to continue manually.'
|
||||
'2 chats couldn’t be resumed',
|
||||
expect.objectContaining({ action: expect.objectContaining({ label: 'Show' }) })
|
||||
)
|
||||
expect(vi.mocked(toast).mock.calls.at(-1)?.[1]).not.toHaveProperty('cancel')
|
||||
})
|
||||
|
||||
it('dispatches the selected action while a future preference save is still pending', async () => {
|
||||
@@ -320,7 +355,7 @@ it('dispatches the selected action while a future preference save is still pendi
|
||||
sessions: []
|
||||
}
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => checkbox(1).click())
|
||||
await act(async () => checkbox(2).click())
|
||||
await act(async () => button('Resume 1 chat').click())
|
||||
@@ -344,14 +379,16 @@ it.each(['pending', 'unknown', 'refused', 'missing'])(
|
||||
sessions: offered
|
||||
}
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
const notices = vi
|
||||
.mocked(toast)
|
||||
.mock.calls.map(([text]) => text)
|
||||
.join(' ')
|
||||
expect(notices).toContain(
|
||||
outcome === 'pending' || outcome === 'unknown' ? 'unconfirmed' : 'could not be continued'
|
||||
outcome === 'pending' || outcome === 'unknown'
|
||||
? 'Couldn’t confirm 2 chats were resumed'
|
||||
: '2 chats couldn’t be resumed'
|
||||
)
|
||||
expect(notices).not.toContain('asked them to continue')
|
||||
expect(rpc).toHaveBeenCalledTimes(2)
|
||||
@@ -364,7 +401,7 @@ it('reports an unreadable resume response as an unconfirmed delivery', async ()
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable' ? { sessions: offered } : { sessions: offered }
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
expect(toast).toHaveBeenCalledWith(expect.stringContaining('unconfirmed'))
|
||||
expect(offerIds()).toEqual(['a', 'b'])
|
||||
@@ -378,7 +415,7 @@ it('reports a lost resume response without retrying the action', async () => {
|
||||
}
|
||||
throw new Error('response lost')
|
||||
})
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
expect(toast).toHaveBeenCalledWith(expect.stringContaining('unconfirmed'))
|
||||
// A lost action response is followed by a read-only reconciliation, never a retry.
|
||||
@@ -390,7 +427,7 @@ it('reports a lost resume response without retrying the action', async () => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unconfirmed delivery visible when another chat was refused', async () => {
|
||||
it('counts an unconfirmed delivery apart from a refusal in one notice', async () => {
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: offered }
|
||||
@@ -402,10 +439,184 @@ it('keeps an unconfirmed delivery visible when another chat was refused', async
|
||||
sessions: offered
|
||||
}
|
||||
)
|
||||
await act(async () => root.render(<NativeChatResumeOnRestartModal />))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
expect(toast).toHaveBeenCalledWith('1 chat could not be continued. Open it to continue manually.')
|
||||
expect(vi.mocked(toast).mock.calls.at(-1)?.[0]).toBe(
|
||||
'Continuation delivery is unconfirmed for 1 chat. Open it to check before sending another message.'
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
[
|
||||
'1 chat couldn’t be resumed',
|
||||
expect.objectContaining({ description: 'Couldn’t confirm 1 other chat was resumed' })
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
// The toast is gone in seconds; what it can do has to land somewhere durable: the list, or the
|
||||
// host's records.
|
||||
it('lets the failure notice open the list or forget the chats it counted', async () => {
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: offered }
|
||||
: method === 'agentSession.restartContinue'
|
||||
? // `b` was requested too but dropped out of the answer without the host failing it.
|
||||
{
|
||||
continued: [{ sessionId: 'a', outcome: 'refused' }],
|
||||
sessions: [offered[1]!],
|
||||
failed: [failure('a')]
|
||||
}
|
||||
: { dismissed: 1, sessions: [offered[1]!], failed: [] }
|
||||
)
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
const options = vi.mocked(toast).mock.calls.at(-1)?.[1]
|
||||
consumeNativeChatResumeOnRestartDialogRequest()
|
||||
await act(async () => press(options?.action))
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
await act(async () => press(options?.cancel))
|
||||
expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([
|
||||
'agentSession.restartResumableDismiss',
|
||||
{ sessionIds: ['a'] }
|
||||
])
|
||||
})
|
||||
|
||||
/** Every button in the dialog, in order; row checkboxes are buttons too, so they are left out. */
|
||||
function dialogControls(): (string | null)[] {
|
||||
return [...document.querySelectorAll('[role="dialog"] button:not([role="checkbox"])')].map(
|
||||
(entry) => entry.textContent?.trim() || entry.getAttribute('aria-label')
|
||||
)
|
||||
}
|
||||
|
||||
// The old toast said "1 chat could not be continued" and vanished. The chat now stays in the same
|
||||
// dialog — same title, checkboxes and footer — with its row saying what went wrong and what to do.
|
||||
it('keeps a chat the resume could not carry on in the same dialog, with what to do', async () => {
|
||||
let remaining: unknown[] = []
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: offered, failed: remaining }
|
||||
: ((remaining = [failure('b')]),
|
||||
{
|
||||
resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })),
|
||||
continued: [
|
||||
{ sessionId: 'a', outcome: 'continued' },
|
||||
{ sessionId: 'b', outcome: 'refused', reason: 'agent_session_restart_work_superseded' }
|
||||
],
|
||||
sessions: [],
|
||||
failed: remaining
|
||||
})
|
||||
)
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => button('Resume 2 chats').click())
|
||||
const dialog = document.querySelector('[role="dialog"]')
|
||||
expect(dialog).not.toBeNull()
|
||||
// Unchanged chrome: the title, the preference box, and the two footer actions.
|
||||
expect(dialog?.textContent).toContain('Resume interrupted chats?')
|
||||
expect(dialog?.textContent).toContain("Don't ask again (resume automatically)")
|
||||
expect(dialog?.textContent).not.toContain('Dismiss failed')
|
||||
// The resumed chat left the list as it always did; the failed one is a row with a checkbox.
|
||||
expect(dialog?.textContent).not.toContain('Prompt a')
|
||||
expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2)
|
||||
expect(document.querySelector('[aria-label="Prompt b: Couldn’t resume"]')).not.toBeNull()
|
||||
expect(dialog?.textContent).toContain('To resume:')
|
||||
expect(dialog?.textContent).toContain('Open the chat and reply.')
|
||||
expect(dialogControls()).toEqual([
|
||||
'Dismiss "Prompt b" in workspace',
|
||||
'Open chat',
|
||||
'Dismiss all',
|
||||
'Resume 0 chats',
|
||||
'Close'
|
||||
])
|
||||
// A retry cannot fix newer work in the chat, so it is not pre-selected for one.
|
||||
expect(checkbox(0).getAttribute('data-state')).toBe('unchecked')
|
||||
|
||||
await act(async () => button('Open chat').click())
|
||||
expect(activate).toHaveBeenCalledWith({
|
||||
structuredSession: { workspaceId: 'workspace', sessionId: 'b' }
|
||||
})
|
||||
// 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'
|
||||
)
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
// Selecting a failed row and pressing Resume is the retry; the row's own Retry does the same.
|
||||
it.each(['footer', 'row'] as const)(
|
||||
'retries a failed chat by name from the %s when a retry can succeed',
|
||||
async (from) => {
|
||||
let failed = [failure('b', 'agent_session_conflict')]
|
||||
rpc.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: [], failed }
|
||||
: ((failed = []),
|
||||
{
|
||||
resumed: [{ sessionId: 'b', outcome: 'resumed' }],
|
||||
continued: [{ sessionId: 'b', outcome: 'continued' }],
|
||||
sessions: [],
|
||||
failed
|
||||
})
|
||||
)
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
// Old failures never raise the launch dialog by themselves; the status entry does.
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
await act(async () => requestNativeChatResumeOnRestartDialog())
|
||||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||||
'Close it, then retry.'
|
||||
)
|
||||
// A retry can fix an ownership clash, so the row starts selected.
|
||||
expect(checkbox(0).getAttribute('data-state')).toBe('checked')
|
||||
|
||||
await act(async () => button(from === 'footer' ? 'Resume 1 chat' : 'Retry').click())
|
||||
expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([
|
||||
'agentSession.restartContinue',
|
||||
{ sessionIds: ['b'] }
|
||||
])
|
||||
expect(toast).toHaveBeenCalledWith('Resumed 1 chat and asked it to continue')
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
// The user's case: the only row is a failure the host says a retry cannot fix. Ticking it could
|
||||
// only fail again, so the row's own action is the way on and the box cannot be ticked.
|
||||
it('keeps a failure the host marks unretryable out of Resume, even after a tick', async () => {
|
||||
let failed: unknown[] = [failure('b')]
|
||||
rpc.mockImplementation(async () => ({ sessions: [], failed }))
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => requestNativeChatResumeOnRestartDialog())
|
||||
// An older host sends no flag, and the row stays selectable as it always was.
|
||||
expect(checkbox(0).hasAttribute('disabled')).toBe(false)
|
||||
await act(async () => checkbox(0).click())
|
||||
expect(button('Resume 1 chat').disabled).toBe(false)
|
||||
|
||||
failed = [{ ...failure('b'), retryable: false }]
|
||||
await act(async () => void (await refreshNativeChatRestartOffer()))
|
||||
expect(checkbox(0).hasAttribute('disabled')).toBe(true)
|
||||
expect(button('Resume 0 chats').disabled).toBe(true)
|
||||
expect(button('Open chat').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('dismisses one failed chat by name, and every record through Dismiss all', async () => {
|
||||
rpc.mockImplementation(async (_target, method, params: { sessionIds?: string[] } | undefined) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? { sessions: [], failed: [failure('a'), failure('b')] }
|
||||
: {
|
||||
dismissed: 1,
|
||||
sessions: [],
|
||||
failed: params?.sessionIds
|
||||
? [failure('a'), failure('b')].filter(
|
||||
(entry) => !params.sessionIds?.includes(entry.sessionId)
|
||||
)
|
||||
: []
|
||||
}
|
||||
)
|
||||
await mount(<NativeChatResumeOnRestartModal />)
|
||||
await act(async () => requestNativeChatResumeOnRestartDialog())
|
||||
expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(3)
|
||||
await act(async () => button('Dismiss "Prompt a" in workspace').click())
|
||||
expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([
|
||||
'agentSession.restartResumableDismiss',
|
||||
{ sessionIds: ['a'] }
|
||||
])
|
||||
expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Prompt a')
|
||||
expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2)
|
||||
await act(async () => button('Dismiss all').click())
|
||||
expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual(['agentSession.restartResumableDismiss', {}])
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -12,7 +12,14 @@ 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 {
|
||||
resumeFailureGuidance,
|
||||
resumeFailureSelectable,
|
||||
type ResumeFailureAction
|
||||
} from './native-chat-resume-failure-guidance'
|
||||
import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping'
|
||||
import {
|
||||
consumeNativeChatResumeOnRestartDialogRequest,
|
||||
getNativeChatResumeOnRestartDialogRequest,
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
import {
|
||||
continueNativeChatRestartOffer,
|
||||
dismissNativeChatRestartOffer,
|
||||
getNativeChatRestartOffer,
|
||||
useNativeChatRestartOffer
|
||||
} from './native-chat-resume-on-restart-store'
|
||||
|
||||
@@ -34,15 +42,33 @@ import {
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* The dialog stays open while any remain, so the outcome is never left to a toast.
|
||||
*
|
||||
* Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss all is
|
||||
* the explicit path that deletes the durable records.
|
||||
*/
|
||||
|
||||
/** 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
|
||||
}
|
||||
const guidance = resumeFailureGuidance(failure)
|
||||
return guidance.primary === 'retry' || guidance.secondary === 'retry'
|
||||
}
|
||||
|
||||
export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
const structuredEnabled = useAppStore(
|
||||
(store) => store.settings?.experimentalStructuredNativeChat === true
|
||||
)
|
||||
const { candidates, listedAt } = useNativeChatRestartOffer(structuredEnabled)
|
||||
const { candidates, failed, listedAt } = useNativeChatRestartOffer(structuredEnabled)
|
||||
const rows = useMemo<ResumeCandidate[]>(() => [...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(
|
||||
@@ -53,30 +79,29 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
const updateSettings = useAppStore((store) => store.updateSettings)
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
/** Which of the OFFERED chats to leave out. Tracked as EXCLUSIONS 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 [excluded, setExcluded] = useState<ReadonlySet<string>>(() => new Set())
|
||||
/** Derived from the host's own list, so an action can never name a chat it did not offer. */
|
||||
/** 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 [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(() => new Map())
|
||||
/** Derived from the host's own list, so an action can never name a chat it did not list. */
|
||||
const chosen = useMemo(
|
||||
() =>
|
||||
candidates
|
||||
.map((candidate) => candidate.sessionId)
|
||||
.filter((sessionId) => !excluded.has(sessionId)),
|
||||
[candidates, excluded]
|
||||
rows
|
||||
.filter((row) => {
|
||||
const failure = failureBySession.get(row.sessionId)
|
||||
// A tick made before the host marked it unretryable must not carry into the action.
|
||||
return (
|
||||
(!failure || resumeFailureSelectable(failure)) &&
|
||||
(overrides.get(row.sessionId) ?? selectedByDefault(failure))
|
||||
)
|
||||
})
|
||||
.map((row) => row.sessionId),
|
||||
[rows, overrides, failureBySession]
|
||||
)
|
||||
const selected = useMemo(() => new Set(chosen), [chosen])
|
||||
|
||||
const toggleSelected = useCallback((sessionId: string, checked: boolean) => {
|
||||
setExcluded((current) => {
|
||||
const next = new Set(current)
|
||||
if (checked) {
|
||||
next.delete(sessionId)
|
||||
} else {
|
||||
next.add(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
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. */
|
||||
@@ -94,7 +119,10 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
await continueNativeChatRestartOffer(sessionIds)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
consumeNativeChatResumeOnRestartDialogRequest()
|
||||
// Stays open when a chat did not carry on: its row now says what to do about it.
|
||||
if (getNativeChatRestartOffer().failed.length === 0) {
|
||||
consumeNativeChatResumeOnRestartDialogRequest()
|
||||
}
|
||||
}
|
||||
},
|
||||
[persistPreference]
|
||||
@@ -114,11 +142,32 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
await dismissNativeChatRestartOffer()
|
||||
}, [persistPreference])
|
||||
|
||||
if (!structuredEnabled || !open || candidates.length === 0) {
|
||||
const actOnFailure = async (action: ResumeFailureAction, sessionId: string): Promise<void> => {
|
||||
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()
|
||||
await activateAiVaultStructuredSession({
|
||||
structuredSession: { workspaceId: failure.workspaceId, sessionId }
|
||||
})
|
||||
}
|
||||
|
||||
if (!structuredEnabled || !open || rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const interruptedByUpdate = candidates.some((candidate) => candidate.trigger === 'update')
|
||||
const interruptedByUpdate = rows.some((row) => row.trigger === 'update')
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -165,11 +214,13 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
className="min-h-0 overflow-y-auto scrollbar-sleek rounded-md border bg-muted/35 p-1.5"
|
||||
>
|
||||
<ResumeOnRestartGroups
|
||||
candidates={candidates}
|
||||
candidates={rows}
|
||||
listedAt={listedAt}
|
||||
busy={busy}
|
||||
selected={selected}
|
||||
onToggle={toggleSelected}
|
||||
failureFor={(sessionId) => failureBySession.get(sessionId)}
|
||||
onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { toast } from 'sonner'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { announceRestartResults } from './native-chat-restart-action-notifications'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: vi.fn() }))
|
||||
|
||||
const actions = { show: vi.fn(), dismiss: vi.fn() }
|
||||
const refusedBoth = [
|
||||
{ sessionId: 'a', outcome: 'refused' as const },
|
||||
{ sessionId: 'b', outcome: 'refused' as const }
|
||||
]
|
||||
|
||||
beforeEach(() => vi.mocked(toast).mockClear())
|
||||
|
||||
// `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' }], actions)
|
||||
expect(vi.mocked(toast).mock.calls.map(([text]) => text)).toEqual(['1 chat couldn’t be resumed'])
|
||||
})
|
||||
|
||||
it('says nothing when the host lists none of them as failed', () => {
|
||||
announceRestartResults(['a', 'b'], refusedBoth, [], actions)
|
||||
expect(toast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts every chat not carried on when an older host sends no failure list', () => {
|
||||
announceRestartResults(['a', 'b'], refusedBoth, undefined, actions)
|
||||
expect(vi.mocked(toast).mock.calls.map(([text]) => text)).toEqual(['2 chats couldn’t be resumed'])
|
||||
})
|
||||
|
||||
// The host retires an unconfirmed send once the agent is seen carrying on it; the action 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' }], [], actions)
|
||||
expect(vi.mocked(toast).mock.calls.map(([text]) => text)).toEqual([
|
||||
'Resumed 1 chat and asked it to continue'
|
||||
])
|
||||
})
|
||||
|
||||
// Unconfirmed means the agent may well be working; "couldn't be resumed" would invite a second send.
|
||||
// `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' }
|
||||
],
|
||||
actions
|
||||
)
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
[
|
||||
'1 chat couldn’t be resumed',
|
||||
expect.objectContaining({ description: 'Couldn’t confirm 1 other chat was resumed' })
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('leads with the unconfirmed count when nothing was refused', () => {
|
||||
announceRestartResults(
|
||||
['a', 'b'],
|
||||
[
|
||||
{ sessionId: 'a', outcome: 'unknown' },
|
||||
{ sessionId: 'b', outcome: 'pending' }
|
||||
],
|
||||
undefined,
|
||||
actions
|
||||
)
|
||||
expect(vi.mocked(toast).mock.calls).toEqual([
|
||||
[
|
||||
'Couldn’t confirm 2 chats were resumed',
|
||||
expect.not.objectContaining({ description: expect.anything() })
|
||||
]
|
||||
])
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ResumeFailure } from './native-chat-resume-on-restart-grouping'
|
||||
|
||||
/**
|
||||
* What Orca tells the user after acting on a restart offer.
|
||||
@@ -46,6 +47,90 @@ export function announceRestartUnconfirmed(count: number): void {
|
||||
)
|
||||
}
|
||||
|
||||
/** What the failure toast can do: open the modal that lists the chats, or forget them. Passed in
|
||||
* because the offer store owns both and this module must not import it back. */
|
||||
export type RestartFailureActions = {
|
||||
show: () => void
|
||||
dismiss: (sessionIds: readonly string[]) => void
|
||||
}
|
||||
|
||||
function refusedCountText(count: number): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.notContinuedOne',
|
||||
'1 chat couldn’t be resumed'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.notContinuedMany',
|
||||
'{{value0}} chats couldn’t be resumed',
|
||||
{ value0: count }
|
||||
)
|
||||
}
|
||||
|
||||
function unconfirmedCountText(count: number): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.notConfirmedOne',
|
||||
'Couldn’t confirm 1 chat was resumed'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.notConfirmedMany',
|
||||
'Couldn’t confirm {{value0}} chats were resumed',
|
||||
{ value0: count }
|
||||
)
|
||||
}
|
||||
|
||||
/** Beneath a refused count, so it cannot read as the same chat restated. */
|
||||
function otherUnconfirmedCountText(count: number): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.notConfirmedOtherOne',
|
||||
'Couldn’t confirm 1 other chat was resumed'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.notConfirmedOtherMany',
|
||||
'Couldn’t confirm {{value0}} other chats were resumed',
|
||||
{ value0: count }
|
||||
)
|
||||
}
|
||||
|
||||
/** The chats an action did not carry on. No names here: the modal has the list, and the count is
|
||||
* the same shape whether it is one chat or ten. Unconfirmed chats get their own count because the
|
||||
* agent may well be working; "couldn't be resumed" would invite a duplicate send. Dismiss forgets
|
||||
* only the chats the host listed as failed — a chat that merely dropped out of the answer may
|
||||
* still be a live offer. */
|
||||
function announceNotContinued(
|
||||
refused: readonly string[],
|
||||
unconfirmed: readonly string[],
|
||||
hostFailed: ReadonlySet<string>,
|
||||
actions: RestartFailureActions
|
||||
): void {
|
||||
const counted = [...refused, ...unconfirmed]
|
||||
if (counted.length === 0) {
|
||||
return
|
||||
}
|
||||
const dismissable = counted.filter((sessionId) => hostFailed.has(sessionId))
|
||||
const title =
|
||||
refused.length > 0 ? refusedCountText(refused.length) : unconfirmedCountText(unconfirmed.length)
|
||||
toast(title, {
|
||||
...(refused.length > 0 && unconfirmed.length > 0
|
||||
? { description: otherUnconfirmedCountText(unconfirmed.length) }
|
||||
: {}),
|
||||
action: {
|
||||
label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'),
|
||||
onClick: actions.show
|
||||
},
|
||||
...(dismissable.length === 0
|
||||
? {}
|
||||
: {
|
||||
cancel: {
|
||||
label: translate('auto.components.NativeChatResumeOnRestartModal.dismiss', 'Dismiss'),
|
||||
onClick: () => actions.dismiss(dismissable)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** A dismissal Orca could not confirm. The offer belongs to the host, so say it may still be there. */
|
||||
export function announceRestartDismissUnconfirmed(): void {
|
||||
toast(
|
||||
@@ -56,34 +141,48 @@ export function announceRestartDismissUnconfirmed(): void {
|
||||
)
|
||||
}
|
||||
|
||||
export function announceRestartResults(
|
||||
/** 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(
|
||||
requested: readonly string[],
|
||||
results: readonly RestartContinuationOutcome[]
|
||||
): void {
|
||||
): string[] {
|
||||
const bySession = new Map(results.map((result) => [result.sessionId, result.outcome]))
|
||||
let succeeded = 0
|
||||
let unconfirmed = 0
|
||||
let refused = 0
|
||||
for (const sessionId of new Set(requested)) {
|
||||
const outcome = bySession.get(sessionId)
|
||||
if (outcome === 'continued') {
|
||||
succeeded += 1
|
||||
} else if (outcome === 'pending' || outcome === 'unknown') {
|
||||
unconfirmed += 1
|
||||
} else {
|
||||
// Eligibility can change after listing, so an omitted row was not acted on either.
|
||||
refused += 1
|
||||
}
|
||||
}
|
||||
announceContinued(succeeded)
|
||||
if (refused > 0) {
|
||||
toast(
|
||||
translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.continueRefused',
|
||||
'{{value0}} chats could not be continued. Open them to continue manually.',
|
||||
{ value0: refused, count: refused }
|
||||
)
|
||||
)
|
||||
}
|
||||
announceRestartUnconfirmed(unconfirmed)
|
||||
return [...new Set(requested)].filter((sessionId) => bySession.get(sessionId) !== 'continued')
|
||||
}
|
||||
|
||||
export function announceRestartResults(
|
||||
requested: readonly string[],
|
||||
results: readonly RestartContinuationOutcome[],
|
||||
/** The host's own failure list after the action; undefined from an older host. */
|
||||
hostFailed: readonly Pick<ResumeFailure, 'sessionId' | 'outcome'>[] | undefined,
|
||||
actions: RestartFailureActions
|
||||
): void {
|
||||
const notContinued = restartChatsNotContinued(requested, results)
|
||||
const failed = new Map(hostFailed?.map((failure) => [failure.sessionId, failure.outcome]))
|
||||
// A host that lists failures has already dropped chats that moved on by themselves or that the
|
||||
// user answered; counting those would report a failure nothing on screen can show.
|
||||
const reported =
|
||||
hostFailed === undefined
|
||||
? notContinued
|
||||
: notContinued.filter((sessionId) => failed.has(sessionId))
|
||||
const outcomes = new Map(results.map((result) => [result.sessionId, result.outcome]))
|
||||
const sentUnconfirmed = (sessionId: string): boolean =>
|
||||
outcomes.get(sessionId) === 'pending' || outcomes.get(sessionId) === 'unknown'
|
||||
// An unconfirmed send the host no longer lists was seen carrying on (or answered by the user), so
|
||||
// it was resumed and asked to continue; left out of both counts, the action would say nothing.
|
||||
const seenCarryingOn = notContinued.filter(
|
||||
(sessionId) => !reported.includes(sessionId) && sentUnconfirmed(sessionId)
|
||||
)
|
||||
// The host's filed outcome is what the list shows, so the toast uses it too.
|
||||
const unconfirmed = (sessionId: string): boolean =>
|
||||
(failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) ===
|
||||
'unconfirmed'
|
||||
announceContinued(new Set(requested).size - notContinued.length + seenCarryingOn.length)
|
||||
announceNotContinued(
|
||||
reported.filter((sessionId) => !unconfirmed(sessionId)),
|
||||
reported.filter(unconfirmed),
|
||||
new Set(failed.keys()),
|
||||
actions
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionStatusSummary
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed'
|
||||
import {
|
||||
_resetNativeChatRestartOffer,
|
||||
continueNativeChatRestartOffer,
|
||||
dismissNativeChatRestartOffer,
|
||||
getNativeChatRestartOffer,
|
||||
refreshNativeChatRestartOffer
|
||||
} from './native-chat-resume-on-restart-store'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rpc: vi.fn(),
|
||||
subscribeStatus: vi.fn(),
|
||||
unsubscribe: vi.fn()
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.rpc,
|
||||
subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: vi.fn() }))
|
||||
|
||||
// After a restart action leaves a chat failed, the user's reply in that chat must retire the status
|
||||
// bar entry without the user reopening anything, and nothing may run while nothing failed.
|
||||
|
||||
const failure = {
|
||||
sessionId: 'a',
|
||||
workspaceId: 'workspace',
|
||||
agent: 'codex',
|
||||
trigger: 'update',
|
||||
latestPrompt: 'Fix it',
|
||||
recordedAt: 1,
|
||||
failedAt: 2,
|
||||
outcome: 'refused',
|
||||
reason: 'agent_session_restart_work_superseded'
|
||||
}
|
||||
|
||||
function summary(
|
||||
status: AgentSessionStatusSummary['status'],
|
||||
latestPrompt: string,
|
||||
updatedAt: number
|
||||
): AgentSessionStatusSummary {
|
||||
return {
|
||||
sessionId: 'a',
|
||||
workspaceId: 'workspace',
|
||||
agent: 'codex',
|
||||
status,
|
||||
latestPrompt,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function hostEmit(): (event: AgentSessionStatusEvent) => void {
|
||||
const call = mocks.subscribeStatus.mock.calls[0]
|
||||
if (!call) {
|
||||
throw new Error('status feed not subscribed')
|
||||
}
|
||||
return call[1]
|
||||
}
|
||||
|
||||
function offerReads(): number {
|
||||
return mocks.rpc.mock.calls.filter(([, method]) => method === 'agentSession.restartResumable')
|
||||
.length
|
||||
}
|
||||
|
||||
/** Lists one failed chat, then answers every later read with `later`. */
|
||||
async function listFailure(later: { failed: unknown[] } = { failed: [] }): Promise<void> {
|
||||
mocks.rpc
|
||||
.mockResolvedValueOnce({ sessions: [], failed: [failure] })
|
||||
.mockResolvedValue({ sessions: [], ...later })
|
||||
await refreshNativeChatRestartOffer()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
vi.clearAllMocks()
|
||||
mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe })
|
||||
_resetNativeChatRestartOffer()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetNativeChatRestartOffer()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens no status stream while nothing failed', async () => {
|
||||
mocks.rpc.mockResolvedValue({ sessions: [], failed: [] })
|
||||
await refreshNativeChatRestartOffer()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mocks.subscribeStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-reads once when the user replies in a failed chat, then lets the stream go', async () => {
|
||||
await listFailure()
|
||||
expect(mocks.subscribeStatus).toHaveBeenCalledOnce()
|
||||
const later = Date.now() + 10_000
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Carry on please', later) })
|
||||
hostEmit()({ type: 'status', session: summary('idle', 'Carry on please', later + 1) })
|
||||
expect(offerReads()).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
expect(offerReads()).toBe(2)
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
expect(mocks.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not re-read for an agent streaming in a failed chat or for news older than the list', async () => {
|
||||
await listFailure({ failed: [failure] })
|
||||
const later = Date.now() + 10_000
|
||||
// Already reflected in the listing the store holds.
|
||||
hostEmit()({ type: 'snapshot', sessions: [summary('working', 'Replayed note', 1)] })
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Replayed note', later) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(offerReads()).toBe(1)
|
||||
|
||||
hostEmit()({ type: 'status', session: summary('idle', 'Replayed note', later + 1) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
// Still failed, so the watch stays for the user's own reply.
|
||||
expect(offerReads()).toBe(2)
|
||||
expect(mocks.unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Host and renderer stamp time separately, and the host can answer a list just before a reply is
|
||||
// delivered here: a change to a chat already seen is news whatever its timestamp says.
|
||||
it('re-reads for a known failed chat that changes even when its stamp predates the list', async () => {
|
||||
await listFailure()
|
||||
hostEmit()({ type: 'snapshot', sessions: [summary('idle', 'Fix it', 1)] })
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Carry on please', 2) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(offerReads()).toBe(2)
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
})
|
||||
|
||||
it('never lets a re-read that was already in flight bring back a dismissed failure', async () => {
|
||||
await listFailure({ failed: [failure] })
|
||||
const stale = Promise.withResolvers<unknown>()
|
||||
mocks.rpc.mockImplementation((_target: unknown, method: string) =>
|
||||
method === 'agentSession.restartResumable'
|
||||
? stale.promise
|
||||
: Promise.resolve({ sessions: [], failed: [] })
|
||||
)
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(offerReads()).toBe(2)
|
||||
|
||||
await dismissNativeChatRestartOffer(['a'])
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
stale.resolve({ sessions: [], failed: [failure] })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
})
|
||||
|
||||
// A retry changes the chat it acts on; its own answer follows, so a read mid-action would only
|
||||
// flash a half-finished list.
|
||||
it('waits for a resume in flight instead of re-reading under it', async () => {
|
||||
await listFailure({ failed: [failure] })
|
||||
const acting = Promise.withResolvers<unknown>()
|
||||
mocks.rpc.mockImplementation((_target: unknown, method: string) =>
|
||||
method === 'agentSession.restartContinue'
|
||||
? acting.promise
|
||||
: Promise.resolve({ sessions: [], failed: [failure] })
|
||||
)
|
||||
const retry = continueNativeChatRestartOffer(['a'])
|
||||
hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) })
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(offerReads()).toBe(1)
|
||||
|
||||
acting.resolve({
|
||||
sessions: [],
|
||||
failed: [],
|
||||
continued: [{ sessionId: 'a', outcome: 'continued' }]
|
||||
})
|
||||
await retry
|
||||
expect(getNativeChatRestartOffer().failed).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resumeFailureGuidance } from './native-chat-resume-failure-guidance'
|
||||
|
||||
// Retry is offered as the primary action only where a second attempt can succeed. A refusal that
|
||||
// stands sends the user to the chat, the only place it can be continued.
|
||||
describe('resumeFailureGuidance', () => {
|
||||
it.each([
|
||||
['agent_session_restart_work_superseded', 'open', null],
|
||||
['agent_session_dispatch_rejected', 'open', null],
|
||||
['agent_session_conflict', 'retry', 'open'],
|
||||
['agent_session_ownership_unknown', 'retry', 'open'],
|
||||
['execution_owner_reconciling', 'retry', 'open'],
|
||||
['agent_session_not_attached', 'retry', 'open'],
|
||||
['agent_session_send_failed', 'retry', 'open'],
|
||||
['structured_agent_session_unsupported', 'open', 'dismiss'],
|
||||
['agent_session_identity_required', 'open', 'dismiss'],
|
||||
['something_this_build_has_never_seen', 'open', 'retry']
|
||||
] as const)('maps %s to %s / %s', (reason, primary, secondary) => {
|
||||
const guidance = resumeFailureGuidance({ outcome: 'refused', reason })
|
||||
expect(guidance).toMatchObject({ primary, secondary })
|
||||
expect(guidance.text.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// The provider's own refusal text lands on the fallback, and the chat then already holds the
|
||||
// refused continuation, so the host reports a retry would not run.
|
||||
it.each(['codex said no', 'agent_session_conflict'])(
|
||||
'offers no retry for %s once the host says a retry would not run',
|
||||
(reason) => {
|
||||
expect(resumeFailureGuidance({ outcome: 'refused', reason, retryable: false })).toMatchObject(
|
||||
{ primary: 'open', secondary: 'dismiss' }
|
||||
)
|
||||
expect(resumeFailureGuidance({ outcome: 'refused', reason, retryable: true })).toMatchObject(
|
||||
resumeFailureGuidance({ outcome: 'refused', reason })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('never offers a retry for a delivery nobody could confirm', () => {
|
||||
expect(
|
||||
resumeFailureGuidance({ outcome: 'unconfirmed', reason: 'agent_session_conflict' })
|
||||
).toMatchObject({ primary: 'open', secondary: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { ResumeFailure } from './native-chat-resume-on-restart-grouping'
|
||||
|
||||
/**
|
||||
* What the user can do about a chat Orca could not resume, decided from the host's reason.
|
||||
*
|
||||
* One sentence saying what to do and why, and the button that does it. Retry is the primary action
|
||||
* only where a second attempt can actually succeed — an ownership clash or a reconnect that never
|
||||
* happened. A refusal that stands (newer work in the chat, the provider saying no) sends the user to
|
||||
* the chat itself, because that is the only place it can be continued.
|
||||
*/
|
||||
|
||||
export type ResumeFailureAction = 'open' | 'retry' | 'dismiss'
|
||||
|
||||
export type ResumeFailureGuidance = {
|
||||
text: string
|
||||
primary: ResumeFailureAction
|
||||
secondary: ResumeFailureAction | null
|
||||
}
|
||||
|
||||
const RETRYABLE_OWNERSHIP = new Set([
|
||||
'agent_session_ownership_unknown',
|
||||
'execution_owner_reconciling'
|
||||
])
|
||||
const RETRYABLE_DELIVERY = new Set(['agent_session_not_attached', 'agent_session_send_failed'])
|
||||
const NOT_RESUMABLE = new Set([
|
||||
'structured_agent_session_unsupported',
|
||||
'agent_session_identity_required'
|
||||
])
|
||||
|
||||
/** Whether Resume may name this chat at all. The host already knows a retry would not run; an
|
||||
* older host omits the flag and the row stays selectable. */
|
||||
export function resumeFailureSelectable(failure: Pick<ResumeFailure, 'retryable'>): boolean {
|
||||
return failure.retryable !== false
|
||||
}
|
||||
|
||||
export function resumeFailureGuidance(
|
||||
failure: Pick<ResumeFailure, 'outcome' | 'reason' | 'retryable'>
|
||||
): ResumeFailureGuidance {
|
||||
const guidance = reasonGuidance(failure)
|
||||
// The host already knows a retry would not run, whatever the reason suggests.
|
||||
return !resumeFailureSelectable(failure) &&
|
||||
(guidance.primary === 'retry' || guidance.secondary === 'retry')
|
||||
? { text: manualContinuationText(), primary: 'open', secondary: 'dismiss' }
|
||||
: guidance
|
||||
}
|
||||
|
||||
function manualContinuationText(): string {
|
||||
return translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.fallback',
|
||||
'Orca couldn’t resume this chat. Open it to continue manually.'
|
||||
)
|
||||
}
|
||||
|
||||
function reasonGuidance(failure: Pick<ResumeFailure, 'outcome' | 'reason'>): ResumeFailureGuidance {
|
||||
if (failure.outcome === 'unconfirmed') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.unconfirmed',
|
||||
'Orca couldn’t confirm the “continue” message was delivered. Open the chat and check before sending another.'
|
||||
),
|
||||
primary: 'open',
|
||||
secondary: null
|
||||
}
|
||||
}
|
||||
const reason = failure.reason
|
||||
if (reason === 'agent_session_restart_work_superseded') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.superseded',
|
||||
'Open the chat and reply. New work arrived in it right after the restart, so Orca didn’t send its “continue” message.'
|
||||
),
|
||||
primary: 'open',
|
||||
secondary: null
|
||||
}
|
||||
}
|
||||
if (reason === 'agent_session_conflict') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.conflict',
|
||||
'Another Orca window or terminal still owns this session. Close it, then retry.'
|
||||
),
|
||||
primary: 'retry',
|
||||
secondary: 'open'
|
||||
}
|
||||
}
|
||||
if (RETRYABLE_OWNERSHIP.has(reason)) {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.ownershipUnknown',
|
||||
'Orca is still working out which process owns this session. Wait a moment, then retry.'
|
||||
),
|
||||
primary: 'retry',
|
||||
secondary: 'open'
|
||||
}
|
||||
}
|
||||
if (RETRYABLE_DELIVERY.has(reason)) {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.notAttached',
|
||||
'The chat didn’t reconnect. Retry, or open it to start a fresh turn.'
|
||||
),
|
||||
primary: 'retry',
|
||||
secondary: 'open'
|
||||
}
|
||||
}
|
||||
if (reason === 'agent_session_dispatch_rejected') {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.dispatchRejected',
|
||||
'The agent refused the “continue” message. Open the chat and send one yourself.'
|
||||
),
|
||||
primary: 'open',
|
||||
secondary: null
|
||||
}
|
||||
}
|
||||
if (NOT_RESUMABLE.has(reason)) {
|
||||
return {
|
||||
text: translate(
|
||||
'auto.components.NativeChatResumeFailureGuidance.unsupported',
|
||||
'This chat can’t be resumed by Orca. Open it to see where it stopped.'
|
||||
),
|
||||
primary: 'open',
|
||||
secondary: 'dismiss'
|
||||
}
|
||||
}
|
||||
return { text: manualContinuationText(), primary: 'open', secondary: 'retry' }
|
||||
}
|
||||
@@ -25,6 +25,17 @@ export type ResumeCandidate = {
|
||||
model?: string
|
||||
}
|
||||
|
||||
/** An offer that was acted on and did not end with the agent carrying on. The host keeps it until
|
||||
* the user opens the chat and sends, retries successfully, dismisses it, or it expires. */
|
||||
export type ResumeFailure = ResumeCandidate & {
|
||||
failedAt: number
|
||||
outcome: 'refused' | 'unconfirmed'
|
||||
/** The host's or provider's refusal code, verbatim. */
|
||||
reason: string
|
||||
/** Whether a retry would run at all; an older host omits it and the reason decides alone. */
|
||||
retryable?: boolean
|
||||
}
|
||||
|
||||
export type ResumeWorkspaceGroup = {
|
||||
workspaceId: string
|
||||
candidates: ResumeCandidate[]
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import {
|
||||
getStructuredAgentSessionStatusFeed,
|
||||
type StructuredAgentSessionStatusFeedOwner
|
||||
} from '@/runtime/structured-agent-session-status-feed'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import { useAppStore } from '../store'
|
||||
import {
|
||||
announceRestartDismissUnconfirmed,
|
||||
@@ -7,7 +12,11 @@ import {
|
||||
announceRestartUnconfirmed,
|
||||
type RestartContinuationOutcome
|
||||
} from './native-chat-restart-action-notifications'
|
||||
import { allResumeSessionIds, type ResumeCandidate } from './native-chat-resume-on-restart-grouping'
|
||||
import {
|
||||
allResumeSessionIds,
|
||||
type ResumeCandidate,
|
||||
type ResumeFailure
|
||||
} from './native-chat-resume-on-restart-grouping'
|
||||
import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog'
|
||||
|
||||
/**
|
||||
@@ -27,14 +36,20 @@ const LOCAL = { kind: 'local' } as const
|
||||
|
||||
export type NativeChatRestartOffer = Readonly<{
|
||||
candidates: readonly ResumeCandidate[]
|
||||
/** Acted-on offers whose agent did not carry on, as the host still records them. */
|
||||
failed: readonly ResumeFailure[]
|
||||
/** 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: [], listedAt: 0 }
|
||||
const EMPTY: NativeChatRestartOffer = { candidates: [], failed: [], listedAt: 0 }
|
||||
let offer: NativeChatRestartOffer = EMPTY
|
||||
let launch: Promise<void> | 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
|
||||
const listeners = new Set<() => void>()
|
||||
const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const
|
||||
|
||||
@@ -43,11 +58,106 @@ const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const
|
||||
* actually moves the offer. */
|
||||
function publish(next: NativeChatRestartOffer): void {
|
||||
offer = next
|
||||
syncFailedChatWatch()
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the host once a failed chat shows new activity, so a reply the user sent there retires
|
||||
* its entry here too. The host stays the judge; this only asks again.
|
||||
*
|
||||
* Held only while something failed. Keyed on status and prompt rather than every summary, so an
|
||||
* agent streaming in a failed chat costs one re-read, not one per tool call.
|
||||
*/
|
||||
const FAILED_CHAT_REFRESH_DELAY_MS = 500
|
||||
let failedChatWatch: {
|
||||
feed: StructuredAgentSessionStatusFeedOwner
|
||||
seen: Map<string, string>
|
||||
release: () => void
|
||||
} | null = null
|
||||
let failedChatRefresh: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function failedChatActivityKey(summary: AgentSessionStatusSummary): string {
|
||||
return `${summary.status ?? ''}\u0000${summary.latestPrompt}`
|
||||
}
|
||||
|
||||
function syncFailedChatWatch(): void {
|
||||
const failedIds = new Set(offer.failed.map((failure) => failure.sessionId))
|
||||
if (failedIds.size === 0) {
|
||||
releaseFailedChatWatch()
|
||||
return
|
||||
}
|
||||
if (!failedChatWatch) {
|
||||
const feed = getStructuredAgentSessionStatusFeed(LOCAL)
|
||||
const unsubscribe = feed.subscribe(noticeFailedChatActivity)
|
||||
const deactivate = feed.activate()
|
||||
failedChatWatch = {
|
||||
feed,
|
||||
seen: new Map(),
|
||||
release: () => {
|
||||
unsubscribe()
|
||||
deactivate()
|
||||
}
|
||||
}
|
||||
}
|
||||
const { feed, seen } = failedChatWatch
|
||||
for (const sessionId of seen.keys()) {
|
||||
if (!failedIds.has(sessionId)) {
|
||||
seen.delete(sessionId)
|
||||
}
|
||||
}
|
||||
// What the feed already holds is what this listing answered.
|
||||
for (const sessionId of failedIds) {
|
||||
const summary = feed.getSnapshot().get(sessionId)
|
||||
if (summary && !seen.has(sessionId)) {
|
||||
seen.set(sessionId, failedChatActivityKey(summary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function noticeFailedChatActivity(): void {
|
||||
if (!failedChatWatch) {
|
||||
return
|
||||
}
|
||||
const { feed, seen } = failedChatWatch
|
||||
const snapshot = feed.getSnapshot()
|
||||
let changed = false
|
||||
for (const failure of offer.failed) {
|
||||
const summary = snapshot.get(failure.sessionId)
|
||||
if (!summary) {
|
||||
continue
|
||||
}
|
||||
const key = failedChatActivityKey(summary)
|
||||
const previous = seen.get(failure.sessionId)
|
||||
if (previous !== key) {
|
||||
seen.set(failure.sessionId, key)
|
||||
// A first sighting is news only if newer than the list; a change to a known chat always is,
|
||||
// since the host may have answered the list just before the change was delivered here.
|
||||
changed ||= previous !== undefined || summary.updatedAt > offer.listedAt
|
||||
}
|
||||
}
|
||||
if (changed && failedChatRefresh === null) {
|
||||
failedChatRefresh = setTimeout(() => {
|
||||
failedChatRefresh = null
|
||||
if (actionsBegun === actionsSettled) {
|
||||
const issued = actionsBegun
|
||||
void readNativeChatRestartOffer(() => actionsBegun === issued)
|
||||
}
|
||||
}, FAILED_CHAT_REFRESH_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function releaseFailedChatWatch(): void {
|
||||
if (failedChatRefresh !== null) {
|
||||
clearTimeout(failedChatRefresh)
|
||||
failedChatRefresh = null
|
||||
}
|
||||
failedChatWatch?.release()
|
||||
failedChatWatch = null
|
||||
}
|
||||
|
||||
export function getNativeChatRestartOffer(): NativeChatRestartOffer {
|
||||
return offer
|
||||
}
|
||||
@@ -66,31 +176,59 @@ function subscribe(listener: () => void): () => void {
|
||||
*/
|
||||
type HostOfferRead = {
|
||||
candidates: readonly ResumeCandidate[]
|
||||
failed: readonly ResumeFailure[]
|
||||
available: boolean
|
||||
}
|
||||
|
||||
async function readNativeChatRestartOffer(): Promise<HostOfferRead> {
|
||||
/** 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 readNativeChatRestartOffer(current = () => true): Promise<HostOfferRead> {
|
||||
try {
|
||||
const offered = await callStructuredAgentSession<{ sessions: ResumeCandidate[] }>(
|
||||
const offered = await callStructuredAgentSession<HostOfferPayload>(
|
||||
LOCAL,
|
||||
'agentSession.restartResumable'
|
||||
)
|
||||
if (!Array.isArray(offered.sessions)) {
|
||||
throw new Error('agent_session_restart_offer_invalid')
|
||||
}
|
||||
publish({ candidates: offered.sessions, listedAt: Date.now() })
|
||||
return { candidates: offered.sessions, available: true }
|
||||
const failed = failedFrom(offered)
|
||||
if (current()) {
|
||||
publish({ candidates: offered.sessions, failed, listedAt: Date.now() })
|
||||
}
|
||||
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.
|
||||
publish({ candidates: [], listedAt: Date.now() })
|
||||
return { candidates: [], available: false }
|
||||
if (current()) {
|
||||
publish({ ...EMPTY, listedAt: Date.now() })
|
||||
}
|
||||
return { candidates: [], failed: [], available: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshNativeChatRestartOffer(): Promise<readonly ResumeCandidate[]> {
|
||||
return (await readNativeChatRestartOffer()).candidates
|
||||
export async function refreshNativeChatRestartOffer(): Promise<
|
||||
Pick<HostOfferRead, 'candidates' | 'failed'>
|
||||
> {
|
||||
const read = await readNativeChatRestartOffer()
|
||||
return { candidates: read.candidates, failed: read.failed }
|
||||
}
|
||||
|
||||
/** What the failure toast can do. The dialog request is external state the toast may raise after
|
||||
* the dialog that started the action has closed. */
|
||||
const failureToastActions = {
|
||||
show: () => requestNativeChatResumeOnRestartDialog(),
|
||||
dismiss: (sessionIds: readonly string[]) => {
|
||||
void dismissNativeChatRestartOffer([...sessionIds])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,22 +247,32 @@ export async function continueNativeChatRestartOffer(
|
||||
sessionIds: readonly string[] | undefined,
|
||||
reported: readonly string[] = sessionIds ?? []
|
||||
): Promise<void> {
|
||||
actionsBegun += 1
|
||||
try {
|
||||
const result = await callStructuredAgentSession<{
|
||||
/** Which chats the host reattached. */
|
||||
resumed?: { sessionId: string }[]
|
||||
continued: RestartContinuationOutcome[]
|
||||
sessions?: ResumeCandidate[]
|
||||
}>(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {})
|
||||
announceRestartResults(reported, result.continued)
|
||||
const result = await callStructuredAgentSession<
|
||||
HostOfferPayload & {
|
||||
/** Which chats the host reattached. */
|
||||
resumed?: { sessionId: string }[]
|
||||
continued: RestartContinuationOutcome[]
|
||||
}
|
||||
>(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {})
|
||||
const failed = failedFrom(result)
|
||||
announceRestartResults(
|
||||
reported,
|
||||
result.continued,
|
||||
Array.isArray(result.failed) ? failed : undefined,
|
||||
failureToastActions
|
||||
)
|
||||
if (Array.isArray(result.sessions)) {
|
||||
publish({ candidates: result.sessions, listedAt: Date.now() })
|
||||
publish({ candidates: result.sessions, failed, listedAt: Date.now() })
|
||||
} else {
|
||||
await refreshNativeChatRestartOffer()
|
||||
}
|
||||
} catch {
|
||||
await refreshNativeChatRestartOffer()
|
||||
announceRestartUnconfirmed(reported.length)
|
||||
} finally {
|
||||
actionsSettled += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,21 +282,26 @@ export async function continueNativeChatRestartOffer(
|
||||
* A failed write or unreachable host leaves the durable record untouched; a later read can restore
|
||||
* the offer after the host is available again.
|
||||
*/
|
||||
export async function dismissNativeChatRestartOffer(): Promise<void> {
|
||||
export async function dismissNativeChatRestartOffer(sessionIds?: readonly string[]): Promise<void> {
|
||||
actionsBegun += 1
|
||||
try {
|
||||
const result = await callStructuredAgentSession<{ sessions?: ResumeCandidate[] }>(
|
||||
const result = await callStructuredAgentSession<HostOfferPayload>(
|
||||
LOCAL,
|
||||
'agentSession.restartResumableDismiss',
|
||||
{}
|
||||
// Named only for rows the host itself listed as failures, which an older host never does,
|
||||
// so it is never asked to understand the key.
|
||||
sessionIds ? { sessionIds: [...sessionIds] } : {}
|
||||
)
|
||||
if (Array.isArray(result.sessions)) {
|
||||
publish({ candidates: result.sessions, listedAt: Date.now() })
|
||||
publish({ candidates: result.sessions, failed: failedFrom(result), listedAt: Date.now() })
|
||||
} else {
|
||||
await refreshNativeChatRestartOffer()
|
||||
}
|
||||
} catch {
|
||||
await refreshNativeChatRestartOffer()
|
||||
announceRestartDismissUnconfirmed()
|
||||
} finally {
|
||||
actionsSettled += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +329,7 @@ async function loadLaunchOffer(): Promise<void> {
|
||||
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
|
||||
}
|
||||
@@ -204,7 +358,10 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf
|
||||
|
||||
/** @internal - tests need a clean module between cases. */
|
||||
export function _resetNativeChatRestartOffer(): void {
|
||||
releaseFailedChatWatch()
|
||||
offer = EMPTY
|
||||
actionsBegun = 0
|
||||
actionsSettled = 0
|
||||
launch = undefined
|
||||
listeners.clear()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment'
|
||||
|
||||
const rpc = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: rpc
|
||||
callStructuredAgentSession: rpc,
|
||||
// A failed row opens the status feed; these cases never drive it.
|
||||
subscribeStructuredAgentSessionStatus: () => new Promise(() => {})
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: vi.fn() }))
|
||||
|
||||
@@ -85,6 +87,54 @@ describe('NativeChatResumeStatusSegment', () => {
|
||||
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
|
||||
})
|
||||
|
||||
// The offer is spent once acted on, so without this entry a failed resume would leave the bar
|
||||
// empty seconds after the toast went. The two are different facts and stay two entries.
|
||||
it('keeps a failed resume as its own entry beside any remaining offer', async () => {
|
||||
const failed = {
|
||||
...candidates[0]!,
|
||||
failedAt: 60_000,
|
||||
outcome: 'refused',
|
||||
reason: 'agent_session_restart_work_superseded'
|
||||
}
|
||||
rpc.mockResolvedValue({ sessions: candidates.slice(1), failed: [failed] })
|
||||
await mount()
|
||||
|
||||
expect(screen.getByText('1 chat to resume')).toBeTruthy()
|
||||
const entry = screen.getByRole('button', {
|
||||
name: '1 chat failed to resume. Click for details.'
|
||||
})
|
||||
expect(entry.textContent).toBe('1 chat failed to resume')
|
||||
|
||||
rpc.mockResolvedValue({ sessions: [], failed: [failed] })
|
||||
await act(async () => entry.click())
|
||||
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
|
||||
// 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()
|
||||
})
|
||||
|
||||
// The agent may be working on an unconfirmed one, so the entry must not call it failed — the
|
||||
// dialog says "couldn't confirm" for that row, and "failed" would invite a second "continue".
|
||||
it('does not call an unconfirmed resume failed', async () => {
|
||||
const failure = (sessionId: 'a' | 'b', outcome: 'refused' | 'unconfirmed') => ({
|
||||
...candidates.find((entry) => entry.sessionId === sessionId)!,
|
||||
failedAt: 60_000,
|
||||
outcome,
|
||||
reason: outcome === 'refused' ? 'agent_session_restart_work_superseded' : 'pending'
|
||||
})
|
||||
rpc.mockResolvedValue({
|
||||
sessions: [],
|
||||
failed: [failure('a', 'refused'), failure('b', 'unconfirmed')]
|
||||
})
|
||||
await mount()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '2 chats to check after resuming. Click for details.' })
|
||||
.textContent
|
||||
).toBe('2 chats to check')
|
||||
expect(screen.queryByText(/failed to resume/)).toBeNull()
|
||||
})
|
||||
|
||||
it('names a single chat in the singular', async () => {
|
||||
rpc.mockResolvedValue({ sessions: candidates.slice(0, 1) })
|
||||
await mount()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { AlertCircle, RotateCcw } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -10,17 +10,118 @@ import {
|
||||
|
||||
// Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is
|
||||
// then the only surface left carrying it, so it is always rendered rather than gated by
|
||||
// `statusBarItems`.
|
||||
// `statusBarItems`. A chat the resume could not carry on is kept the same way: the toast that
|
||||
// reported it is gone in seconds, and this entry is what still names it.
|
||||
|
||||
/** Re-reads the host before opening so the dialog always reflects the current durable offer. Opening
|
||||
* the chat itself is read-only and does not retire the offer. */
|
||||
/** Re-reads the host before opening so the dialog always reflects the current durable records.
|
||||
* Opening the chat itself is read-only and does not retire the offer. */
|
||||
async function reopenOffer(): Promise<void> {
|
||||
const offered = await refreshNativeChatRestartOffer()
|
||||
if (offered.length > 0) {
|
||||
const { candidates, failed } = await refreshNativeChatRestartOffer()
|
||||
if (candidates.length > 0 || failed.length > 0) {
|
||||
requestNativeChatResumeOnRestartDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function Segment({
|
||||
icon,
|
||||
label,
|
||||
ariaLabel,
|
||||
tooltip,
|
||||
iconOnly,
|
||||
count
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
ariaLabel: string
|
||||
tooltip: string
|
||||
iconOnly: boolean
|
||||
count: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reopenOffer()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent/70"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-[11px]">{iconOnly ? count : label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type SegmentText = { label: string; ariaLabel: string; tooltip: string }
|
||||
|
||||
function failedText(count: number): SegmentText {
|
||||
return {
|
||||
label:
|
||||
count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.failedLabelOne',
|
||||
'1 chat failed to resume'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.failedLabel',
|
||||
'{{value0}} chats failed to resume',
|
||||
{ value0: count }
|
||||
),
|
||||
ariaLabel:
|
||||
count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.failedAriaOne',
|
||||
'1 chat failed to resume. Click for details.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.failedAria',
|
||||
'{{value0}} chats failed to resume. Click for details.',
|
||||
{ value0: count }
|
||||
),
|
||||
tooltip: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.failedTooltip',
|
||||
'Chats Orca could not resume after the restart. Click for details.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** True of a refused chat and an unconfirmed one alike, for a list holding either. */
|
||||
function checkText(count: number): SegmentText {
|
||||
return {
|
||||
label:
|
||||
count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.checkLabelOne',
|
||||
'1 chat to check'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.checkLabel',
|
||||
'{{value0}} chats to check',
|
||||
{ value0: count }
|
||||
),
|
||||
ariaLabel:
|
||||
count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.checkAriaOne',
|
||||
'1 chat to check after resuming. Click for details.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.checkAria',
|
||||
'{{value0}} chats to check after resuming. Click for details.',
|
||||
{ value0: count }
|
||||
),
|
||||
tooltip: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.checkTooltip',
|
||||
'Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function NativeChatResumeStatusSegment({
|
||||
iconOnly
|
||||
}: {
|
||||
@@ -29,32 +130,36 @@ export function NativeChatResumeStatusSegment({
|
||||
const structuredEnabled = useAppStore(
|
||||
(store) => store.settings?.experimentalStructuredNativeChat === true
|
||||
)
|
||||
const { candidates } = useNativeChatRestartOffer(structuredEnabled)
|
||||
if (!structuredEnabled || candidates.length === 0) {
|
||||
const { candidates, failed } = useNativeChatRestartOffer(structuredEnabled)
|
||||
if (!structuredEnabled || (candidates.length === 0 && failed.length === 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const count = candidates.length
|
||||
const label =
|
||||
count === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.labelOne',
|
||||
'1 chat to resume'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.label',
|
||||
'{{value0}} chats to resume',
|
||||
{ value0: count }
|
||||
)
|
||||
const pending = candidates.length
|
||||
const failures = failed.length
|
||||
// An unconfirmed chat may be working, so "failed" would invite a duplicate "continue".
|
||||
const unconfirmed = failed.some((failure) => failure.outcome === 'unconfirmed')
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reopenOffer()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent/70"
|
||||
aria-label={
|
||||
count === 1
|
||||
<>
|
||||
{pending > 0 && (
|
||||
<Segment
|
||||
iconOnly={iconOnly}
|
||||
count={pending}
|
||||
icon={<RotateCcw className="size-3 text-muted-foreground" />}
|
||||
label={
|
||||
pending === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.labelOne',
|
||||
'1 chat to resume'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.label',
|
||||
'{{value0}} chats to resume',
|
||||
{ value0: pending }
|
||||
)
|
||||
}
|
||||
ariaLabel={
|
||||
pending === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.ariaLabelOne',
|
||||
'1 chat available to resume'
|
||||
@@ -62,20 +167,25 @@ export function NativeChatResumeStatusSegment({
|
||||
: translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.ariaLabel',
|
||||
'{{value0}} chats available to resume',
|
||||
{ value0: count }
|
||||
{ value0: pending }
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="size-3 text-muted-foreground" />
|
||||
<span className="text-[11px]">{iconOnly ? count : label}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.tooltip',
|
||||
'Open interrupted chats available to resume'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
tooltip={translate(
|
||||
'auto.components.status.bar.NativeChatResumeStatusSegment.tooltip',
|
||||
'Open interrupted chats available to resume'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{failures > 0 && (
|
||||
// A different fact from the offer — the outcome of acting on it — so a second entry, not a
|
||||
// merged count. Same yellow the skill-update segment uses for its own failed state.
|
||||
<Segment
|
||||
iconOnly={iconOnly}
|
||||
count={failures}
|
||||
icon={<AlertCircle className="size-3 text-status-warning" />}
|
||||
{...(unconfirmed ? checkText(failures) : failedText(failures))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
-2
@@ -73,8 +73,6 @@
|
||||
"f5a6b38a14": "sheet"
|
||||
},
|
||||
"NativeChatResumeOnRestartModal": {
|
||||
"continueRefused_one": "{{value0}} chat could not be continued. Open it to continue manually.",
|
||||
"continueRefused_other": "{{value0}} chats could not be continued. Open them to continue manually.",
|
||||
"continueUnconfirmed_one": "Continuation delivery is unconfirmed for {{value0}} chat. Open it to check before sending another message.",
|
||||
"continueUnconfirmed_other": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message."
|
||||
},
|
||||
|
||||
@@ -1970,11 +1970,16 @@
|
||||
"continueUnconfirmed": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.",
|
||||
"continueUnconfirmed_one": "Continuation delivery is unconfirmed for {{value0}} chat. Open it to check before sending another message.",
|
||||
"continueUnconfirmed_other": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.",
|
||||
"continueRefused": "{{value0}} chats could not be continued. Open them to continue manually.",
|
||||
"continueRefused_one": "{{value0}} chat could not be continued. Open it to continue manually.",
|
||||
"continueRefused_other": "{{value0}} chats could not be continued. Open them to continue manually.",
|
||||
"dismissAll": "Dismiss all",
|
||||
"dismissUnconfirmed": "Dismissing the resume offer was not confirmed — it may still be in the status bar."
|
||||
"dismissUnconfirmed": "Dismissing the resume offer was not confirmed — it may still be in the status bar.",
|
||||
"notContinuedOne": "1 chat couldn’t be resumed",
|
||||
"notContinuedMany": "{{value0}} chats couldn’t be resumed",
|
||||
"notConfirmedOne": "Couldn’t confirm 1 chat was resumed",
|
||||
"notConfirmedMany": "Couldn’t confirm {{value0}} chats were resumed",
|
||||
"notConfirmedOtherOne": "Couldn’t confirm 1 other chat was resumed",
|
||||
"notConfirmedOtherMany": "Couldn’t confirm {{value0}} other chats were resumed",
|
||||
"show": "Show",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"StarNagCard": {
|
||||
"92b0f9d921": "is authenticated and try again.",
|
||||
@@ -4075,7 +4080,17 @@
|
||||
"label": "{{value0}} chats to resume",
|
||||
"ariaLabelOne": "1 chat available to resume",
|
||||
"ariaLabel": "{{value0}} chats available to resume",
|
||||
"tooltip": "Open interrupted chats available to resume"
|
||||
"tooltip": "Open interrupted chats available to resume",
|
||||
"failedLabelOne": "1 chat failed to resume",
|
||||
"failedLabel": "{{value0}} chats failed to resume",
|
||||
"failedAriaOne": "1 chat failed to resume. Click for details.",
|
||||
"failedAria": "{{value0}} chats failed to resume. Click for details.",
|
||||
"failedTooltip": "Chats Orca could not resume after the restart. Click for details.",
|
||||
"checkLabelOne": "1 chat to check",
|
||||
"checkLabel": "{{value0}} chats to check",
|
||||
"checkAriaOne": "1 chat to check after resuming. Click for details.",
|
||||
"checkAria": "{{value0}} chats to check after resuming. Click for details.",
|
||||
"checkTooltip": "Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17221,6 +17236,26 @@
|
||||
"9a4c6b2d7e": "Skill sharing",
|
||||
"3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.",
|
||||
"c5b3e8a17d": "Sign in to Orca"
|
||||
},
|
||||
"NativeChatResumeOutcomeRow": {
|
||||
"openChat": "Open chat",
|
||||
"retry": "Retry",
|
||||
"dismiss": "Dismiss",
|
||||
"unconfirmed": "Couldn’t confirm the chat was resumed",
|
||||
"failed": "Couldn’t resume",
|
||||
"statusFor": "{{value0}}: {{value1}}",
|
||||
"dismissChat": "Dismiss \"{{value0}}\" in {{value1}}",
|
||||
"toResume": "To resume:"
|
||||
},
|
||||
"NativeChatResumeFailureGuidance": {
|
||||
"unconfirmed": "Orca couldn’t confirm the “continue” message was delivered. Open the chat and check before sending another.",
|
||||
"superseded": "Open the chat and reply. New work arrived in it right after the restart, so Orca didn’t send its “continue” message.",
|
||||
"conflict": "Another Orca window or terminal still owns this session. Close it, then retry.",
|
||||
"ownershipUnknown": "Orca is still working out which process owns this session. Wait a moment, then retry.",
|
||||
"notAttached": "The chat didn’t reconnect. Retry, or open it to start a fresh turn.",
|
||||
"dispatchRejected": "The agent refused the “continue” message. Open the chat and send one yourself.",
|
||||
"unsupported": "This chat can’t be resumed by Orca. Open it to see where it stopped.",
|
||||
"fallback": "Orca couldn’t resume this chat. Open it to continue manually."
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
||||
Reference in New Issue
Block a user