mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
refactor(native-chat): drop the reconnect vocabulary the resume action left behind
Resuming became one action — reattach and ask the agent to carry on — so the notification helpers no longer need to be told which action they are reporting. Every caller passed `continue`; the `reconnect` branch, its helper and its catalog keys are gone. The dialog and the launch path had grown two copies of the same call: same RPC, same response shape, same announce-and-settle. That now lives once in the store module that owns the offer, which also takes the dismiss call, leaving the modal presentational. The two copies had drifted — only the dialog's caught a malformed payload — and the unified one keeps the defensive reading. No behaviour change. `agentSession.restartResume` stays: it is a published wire method even though nothing in the app calls it.
This commit is contained in:
+4
-6
@@ -1,11 +1,9 @@
|
||||
// Asking an interrupted agent to carry on, on the user's opt-in.
|
||||
//
|
||||
// Reattaching and continuing are still SEPARATE operations: `resume` reattaches and sends nothing;
|
||||
// this adds one message on top of it. What changed is WHO may ask. Resuming from the restart prompt
|
||||
// comes here, and so does an opted-in launch, so this module is no longer unreachable from a
|
||||
// setting — do not restate that old guarantee. It is acceptable because the work is the user's own,
|
||||
// the message asks the agent to verify its last action before repeating it, and the launch toast
|
||||
// reports what happened.
|
||||
// Reattaching and continuing are SEPARATE operations: `resume` reattaches and sends nothing; this
|
||||
// adds one message on top of it. Both the restart prompt and an opted-in launch come here, so a
|
||||
// SETTING can reach this send — acceptable because the work is the user's own, the message asks the
|
||||
// agent to verify its last action before repeating it, and the launch toast reports what happened.
|
||||
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire'
|
||||
|
||||
+3
-5
@@ -40,11 +40,9 @@ export type StructuredAgentSessionRestartResumeSurfaces = {
|
||||
/** The resume-capable hold; see the runner for why a hold and not a send. */
|
||||
hold: (sessionId: string, holderId: string) => Promise<void>
|
||||
release: (sessionId: string, holderId: string) => void
|
||||
/** The host's own send. Reached ONLY from `continueAfterRestart`; `resume` still never calls it,
|
||||
* so reattaching on its own sends nothing. That is no longer a guarantee about SETTINGS, though:
|
||||
* a launch the user opted into calls `continueAfterRestart` directly, which is acceptable
|
||||
* because the work is the user's own, the message asks the agent to verify its last action
|
||||
* before repeating it, and the launch toast reports what happened.
|
||||
/** The host's own send. Reached ONLY from `continueAfterRestart`, so reattaching on its own sends
|
||||
* nothing — but an opted-in launch calls `continueAfterRestart` too, so this is not a guarantee
|
||||
* about settings; see the continuation module for why that is acceptable.
|
||||
*
|
||||
* Typed against the wire result rather than a hand-written subset: a narrower local shape hid
|
||||
* `value.submission` here once, and the continuation reads it. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// `agentSession.restartResumable` / `agentSession.restartResume` — the restart-resume offer.
|
||||
// The restart-resume offer: list it, act on it, or turn it down.
|
||||
//
|
||||
// Both reach for records on disk this process may not have opened yet, so they build the host the
|
||||
// way hold and reveal do. Listing is read-only and takes nothing live; resuming goes through the
|
||||
// host's single resume path, which re-derives eligibility rather than trusting the ids it is given.
|
||||
// Each method reaches for records on disk this process may not have opened yet, so each builds the
|
||||
// host the way hold and reveal do. Listing is read-only and takes nothing live; acting goes through
|
||||
// the host's single resume path, which re-derives eligibility rather than trusting the ids it is
|
||||
// given.
|
||||
|
||||
import { defineMethod } from '../core'
|
||||
import {
|
||||
@@ -32,9 +33,9 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
// Reattach AND ask each reattached agent to carry on — what the desktop prompt now calls
|
||||
// resuming, and what an opted-in launch runs without asking. Still a separate method from
|
||||
// `restartResume`, which sends nothing, but no longer one that only a button can reach.
|
||||
// Reattach AND ask each reattached agent to carry on — what the desktop prompt calls resuming,
|
||||
// and what an opted-in launch runs without asking. Separate from `restartResume`, which sends
|
||||
// nothing, but reachable from a setting rather than only from a button.
|
||||
name: 'agentSession.restartContinue',
|
||||
params: RestartResumeParams,
|
||||
handler: async (params, ctx) => {
|
||||
@@ -47,9 +48,9 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
// Reattach only, no send. The desktop prompt stopped calling this once its single action became
|
||||
// resume-and-continue, but it stays: it is a published wire method, and its absence is what an
|
||||
// older or non-desktop client would be met with.
|
||||
// Reattach only, no send. No Orca surface calls it now — the desktop prompt's single action is
|
||||
// resume-and-continue — but it is a PUBLISHED wire method, so dropping it is a wire removal an
|
||||
// older or non-desktop client would meet as an unknown method.
|
||||
name: 'agentSession.restartResume',
|
||||
params: RestartResumeParams,
|
||||
handler: async (params, ctx) => {
|
||||
|
||||
@@ -111,8 +111,7 @@ it('keeps next-launch preference out of the current resume action', async () =>
|
||||
expect(rpc).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// One primary action and one way out of it. The vacuous plain-reconnect button, the Not now button
|
||||
// and the "what gets sent" popover are gone; the body copy carries the transparency now.
|
||||
// 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.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable' ? { sessions: offered } : { results: [] }
|
||||
@@ -127,9 +126,8 @@ it('offers exactly Dismiss all and the resume action', async () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Snoozing saves the preference like every other way out of the dialog, and calls NOTHING: the
|
||||
// offer is the host's and stays exactly where it was. Closing the dialog is the only snooze left
|
||||
// now that Not now is gone, so it has to keep doing all of that.
|
||||
// Closing is the only snooze, so it carries the whole of one: saves the preference like every
|
||||
// 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.mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.restartResumable' ? { sessions: offered } : { results: [] }
|
||||
|
||||
@@ -11,15 +11,8 @@ import {
|
||||
DialogTitle
|
||||
} from './ui/dialog'
|
||||
import { useAppStore } from '../store'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ResumeOnRestartGroups } from './NativeChatResumeOnRestartGroups'
|
||||
import {
|
||||
announceRestartDismissUnconfirmed,
|
||||
announceRestartResults,
|
||||
announceRestartUnconfirmed,
|
||||
type RestartActionOutcome
|
||||
} from './native-chat-restart-action-notifications'
|
||||
import { selectedResumeSessionIds } from './native-chat-resume-on-restart-grouping'
|
||||
import {
|
||||
consumeNativeChatResumeOnRestartDialogRequest,
|
||||
@@ -27,8 +20,8 @@ import {
|
||||
subscribeNativeChatResumeOnRestartDialog
|
||||
} from './native-chat-resume-on-restart-dialog'
|
||||
import {
|
||||
clearNativeChatRestartOffer,
|
||||
settleNativeChatRestartOffer,
|
||||
continueNativeChatRestartOffer,
|
||||
dismissNativeChatRestartOffer,
|
||||
useNativeChatRestartOffer
|
||||
} from './native-chat-resume-on-restart-store'
|
||||
|
||||
@@ -37,24 +30,17 @@ import {
|
||||
*
|
||||
* The list is the point. Resuming a chat that was not working starts a provider the user never
|
||||
* asked for and puts a misleading row in front of them, so they see exactly which chats the last
|
||||
* teardown recorded as mid-turn and decide. The checkbox is the opt-in to skipping this prompt in
|
||||
* future — it removes the PROMPT, never a safety check: automatic mode calls the same RPC, which
|
||||
* re-derives the same predicate and staggers the same way.
|
||||
* teardown recorded as mid-turn and decide. The checkbox removes the PROMPT, never a safety check:
|
||||
* automatic mode calls the same RPC, which re-derives the same predicate and staggers the same way.
|
||||
*
|
||||
* Resuming reattaches each session where it stopped AND asks that agent to carry on, which is the
|
||||
* only reason the prompt is worth showing: reattaching alone is what simply opening the chat does.
|
||||
* The user's own prompt is never re-sent, and every string here has to keep saying so.
|
||||
*
|
||||
* Closing is a SNOOZE: the host keeps the offer and the status bar keeps a way back to it, so
|
||||
* looking around before deciding cannot cost the recovery. Dismiss all is the only path that spends
|
||||
* it, and even that loses nothing — opening a chat takes a resume-capable hold, which re-acquires
|
||||
* the provider at the same cursor and retires the offer for it.
|
||||
* Closing is a SNOOZE, so looking around before deciding cannot cost the recovery. Dismiss all is
|
||||
* the only path that spends the offer.
|
||||
*/
|
||||
|
||||
// Structured sessions run on the machine hosting the runtime; both launch resolvers refuse anything
|
||||
// else, so there is no remote target to aim this at.
|
||||
const LOCAL = { kind: 'local' } as const
|
||||
|
||||
export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
const structuredEnabled = useAppStore(
|
||||
(store) => store.settings?.experimentalStructuredNativeChat === true
|
||||
@@ -70,14 +56,9 @@ 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.
|
||||
*
|
||||
* This changes which eligible chats are acted on, never what is eligible: the ids below are
|
||||
* intersected back against the host's own list, and the host re-derives the predicate regardless.
|
||||
*/
|
||||
/** 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())
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
@@ -108,30 +89,12 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
}
|
||||
}, [dontAskAgain, updateSettings])
|
||||
|
||||
/**
|
||||
* The one action: reattach, then ask each agent to carry on.
|
||||
*
|
||||
* `restartContinue` is the host method behind it — reattaching without a send is a separate RPC
|
||||
* this surface no longer calls, because opening the chat already does exactly that.
|
||||
*/
|
||||
const resume = useCallback(
|
||||
async (sessionIds: string[]): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
void persistPreference()
|
||||
const result = await callStructuredAgentSession<{
|
||||
/** Which chats the host actually reattached, and so which claims it spent. Optional
|
||||
* because the payload is unvalidated: a shape this side did not expect must not turn a
|
||||
* delivered continuation into a failure report. */
|
||||
resumed?: RestartActionOutcome[]
|
||||
continued: RestartActionOutcome[]
|
||||
}>(LOCAL, 'agentSession.restartContinue', { sessionIds })
|
||||
announceRestartResults(sessionIds, result.continued, 'continue')
|
||||
// Resuming spends the claims, so the offer has to shrink with it — otherwise the status bar
|
||||
// keeps counting chats the host has already handed back.
|
||||
settleNativeChatRestartOffer((result.resumed ?? []).map((entry) => entry.sessionId))
|
||||
} catch {
|
||||
announceRestartUnconfirmed(sessionIds.length, 'continue')
|
||||
await continueNativeChatRestartOffer(sessionIds)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
consumeNativeChatResumeOnRestartDialogRequest()
|
||||
@@ -146,18 +109,14 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
void persistPreference()
|
||||
}, [persistPreference])
|
||||
|
||||
/** The only path that spends the markers. */
|
||||
const dismissAll = useCallback(async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
void persistPreference()
|
||||
// The dismissal is the user's and lands here, whatever the host answers. A write Orca cannot
|
||||
// confirm is reported rather than allowed to trap the dialog open behind a rejected promise.
|
||||
// Bookkeeping never gates the user's own action: the dialog closes here whatever the host
|
||||
// answers, rather than being trapped open behind a rejected promise.
|
||||
consumeNativeChatResumeOnRestartDialogRequest()
|
||||
try {
|
||||
await callStructuredAgentSession(LOCAL, 'agentSession.restartResumableDismiss', {})
|
||||
clearNativeChatRestartOffer()
|
||||
} catch {
|
||||
announceRestartDismissUnconfirmed()
|
||||
await dismissNativeChatRestartOffer()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -180,8 +139,8 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Height is capped, never the data: seeing WHICH chats would be resumed is the whole
|
||||
point, so the list scrolls inside the dialog while the header and primary action stay. */}
|
||||
{/* Height is capped, never the data: the list scrolls inside the dialog so the header and
|
||||
the primary action stay put however many chats were interrupted. */}
|
||||
<DialogContent className="grid-rows-[auto_minmax(0,1fr)_auto_auto] sm:max-w-xl max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -194,8 +153,8 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
)}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
{/* Carries the transparency an explainer popover used to hide behind an icon: what the
|
||||
agent is told, and what is NOT re-sent. */}
|
||||
{/* The transparency, in the copy rather than behind a disclosure: what the agent is
|
||||
told, and what is NOT re-sent. */}
|
||||
<DialogDescription>
|
||||
{interruptedByUpdate
|
||||
? translate(
|
||||
@@ -250,8 +209,8 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Two controls, and they are opposites: one spends the offer, one acts on it. Closing the
|
||||
dialog is neither — it snoozes, so it needs no button of its own. */}
|
||||
{/* Two controls, and they are opposites: one spends the offer, one acts on it. Closing is
|
||||
neither — it snoozes, so it needs no button of its own. */}
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{/* Quiet, not destructive: this spends an offer, and opening a chat still reattaches it. */}
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void dismissAll()}>
|
||||
|
||||
@@ -4,31 +4,15 @@ import { translate } from '@/i18n/i18n'
|
||||
/**
|
||||
* What Orca tells the user after acting on a restart offer.
|
||||
*
|
||||
* `continue` is the only action a surface takes now — resuming always reattaches AND sends. The
|
||||
* `reconnect` wording belongs to the host's plain-reattach RPC, which is still published on the
|
||||
* wire, so its vocabulary stays here rather than being reinvented if anything calls it again.
|
||||
* Resuming reattaches AND asks each agent to carry on, so every message here has to say a message
|
||||
* went out. An opted-in launch runs with no dialog in front of it, and these toasts are the only
|
||||
* place that user learns it happened.
|
||||
*/
|
||||
|
||||
/** One `continued` row as the host reports it. */
|
||||
export type RestartActionOutcome = {
|
||||
sessionId: string
|
||||
outcome: 'resumed' | 'continued' | 'pending' | 'unknown' | 'refused'
|
||||
}
|
||||
|
||||
function announceResumed(count: number): void {
|
||||
if (count <= 0) {
|
||||
return
|
||||
}
|
||||
toast(
|
||||
count === 1
|
||||
? translate('auto.components.NativeChatResumeOnRestartModal.resumedOne', 'Resumed 1 chat')
|
||||
: translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.resumedMany',
|
||||
'Resumed {{value0}} chats',
|
||||
{
|
||||
value0: count
|
||||
}
|
||||
)
|
||||
)
|
||||
outcome: 'continued' | 'pending' | 'unknown' | 'refused'
|
||||
}
|
||||
|
||||
function announceContinued(count: number): void {
|
||||
@@ -49,22 +33,17 @@ function announceContinued(count: number): void {
|
||||
)
|
||||
}
|
||||
|
||||
export function announceRestartUnconfirmed(count: number, action: 'reconnect' | 'continue'): void {
|
||||
/** Delivery the host never confirmed. Reported, never retried — a second send is the user's call. */
|
||||
export function announceRestartUnconfirmed(count: number): void {
|
||||
if (count <= 0) {
|
||||
return
|
||||
}
|
||||
toast(
|
||||
action === 'continue'
|
||||
? translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.continueUnconfirmed',
|
||||
'Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.',
|
||||
{ value0: count, count }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.reconnectUnconfirmed',
|
||||
'Resuming is unconfirmed for {{value0}} chats. You can still open them normally.',
|
||||
{ value0: count, count }
|
||||
)
|
||||
translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.continueUnconfirmed',
|
||||
'Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.',
|
||||
{ value0: count, count }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,8 +59,7 @@ export function announceRestartDismissUnconfirmed(): void {
|
||||
|
||||
export function announceRestartResults(
|
||||
requested: readonly string[],
|
||||
results: readonly RestartActionOutcome[],
|
||||
action: 'reconnect' | 'continue'
|
||||
results: readonly RestartActionOutcome[]
|
||||
): void {
|
||||
const bySession = new Map(results.map((result) => [result.sessionId, result.outcome]))
|
||||
let succeeded = 0
|
||||
@@ -89,7 +67,7 @@ export function announceRestartResults(
|
||||
let refused = 0
|
||||
for (const sessionId of new Set(requested)) {
|
||||
const outcome = bySession.get(sessionId)
|
||||
if (outcome === (action === 'continue' ? 'continued' : 'resumed')) {
|
||||
if (outcome === 'continued') {
|
||||
succeeded += 1
|
||||
} else if (outcome === 'pending' || outcome === 'unknown') {
|
||||
unconfirmed += 1
|
||||
@@ -98,25 +76,15 @@ export function announceRestartResults(
|
||||
refused += 1
|
||||
}
|
||||
}
|
||||
if (action === 'continue') {
|
||||
announceContinued(succeeded)
|
||||
} else {
|
||||
announceResumed(succeeded)
|
||||
}
|
||||
announceContinued(succeeded)
|
||||
if (refused > 0) {
|
||||
toast(
|
||||
action === 'continue'
|
||||
? translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.continueRefused',
|
||||
'{{value0}} chats could not be continued. Open them to continue manually.',
|
||||
{ value0: refused, count: refused }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.reconnectRefused',
|
||||
'{{value0}} chats could not be resumed. You can still open them normally.',
|
||||
{ value0: refused, count: refused }
|
||||
)
|
||||
translate(
|
||||
'auto.components.NativeChatResumeOnRestartModal.continueRefused',
|
||||
'{{value0}} chats could not be continued. Open them to continue manually.',
|
||||
{ value0: refused, count: refused }
|
||||
)
|
||||
)
|
||||
}
|
||||
announceRestartUnconfirmed(unconfirmed, action)
|
||||
announceRestartUnconfirmed(unconfirmed)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
let pendingOpen = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the launch load and the status-bar entry both open this dialog, and either can fire before
|
||||
// it subscribes. Keeping the request as an external snapshot prevents mount ordering from losing it.
|
||||
export function requestNativeChatResumeOnRestartDialog(): void {
|
||||
pendingOpen = true
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
export function consumeNativeChatResumeOnRestartDialogRequest(): void {
|
||||
@@ -15,9 +19,7 @@ export function consumeNativeChatResumeOnRestartDialogRequest(): void {
|
||||
return
|
||||
}
|
||||
pendingOpen = false
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
export function getNativeChatResumeOnRestartDialogRequest(): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { useAppStore } from '../store'
|
||||
import {
|
||||
announceRestartDismissUnconfirmed,
|
||||
announceRestartResults,
|
||||
announceRestartUnconfirmed,
|
||||
type RestartActionOutcome
|
||||
@@ -10,13 +11,12 @@ import { allResumeSessionIds, type ResumeCandidate } from './native-chat-resume-
|
||||
import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog'
|
||||
|
||||
/**
|
||||
* Which interrupted chats the host is still offering to resume.
|
||||
* Which interrupted chats the host is still offering to resume, and every action that moves that.
|
||||
*
|
||||
* The offer is the HOST's answer, not a list whichever surface rendered first happens to be
|
||||
* holding. It has to be, because the host retires an offer for reasons no renderer can see —
|
||||
* simply reopening a chat re-acquires its provider at the same cursor, which is the reattach half
|
||||
* of a resume. So this fetches the list and both surfaces read it, and anything about to ACT on
|
||||
* the offer asks the host again first.
|
||||
* holding. It has to be, because the host retires an offer for reasons no renderer can see — simply
|
||||
* reopening a chat re-acquires its provider at the same cursor, which is the reattach half of a
|
||||
* resume. So both surfaces read this one answer, and every action asks the host again first.
|
||||
*
|
||||
* What stays on this side is the user's own facts: the snooze, and the preference that decides
|
||||
* whether the launch asks at all.
|
||||
@@ -81,7 +81,7 @@ export async function refreshNativeChatRestartOffer(): Promise<readonly ResumeCa
|
||||
}
|
||||
|
||||
/** Drops the chats the host reports it has settled, leaving the ones it did not. */
|
||||
export function settleNativeChatRestartOffer(sessionIds: readonly string[]): void {
|
||||
function settle(sessionIds: readonly string[]): void {
|
||||
const settled = new Set(sessionIds)
|
||||
const remaining = offer.candidates.filter((candidate) => !settled.has(candidate.sessionId))
|
||||
if (remaining.length === offer.candidates.length) {
|
||||
@@ -91,20 +91,64 @@ export function settleNativeChatRestartOffer(sessionIds: readonly string[]): voi
|
||||
}
|
||||
|
||||
/** The offer was abandoned outright; the host has already spent the markers. */
|
||||
export function clearNativeChatRestartOffer(): void {
|
||||
function clear(): void {
|
||||
if (offer.candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
publish({ ...offer, candidates: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reattach the offered chats, ask each agent to carry on, then shrink the offer by what the host
|
||||
* says it spent — otherwise the status bar keeps counting chats already handed back.
|
||||
*
|
||||
* `sessionIds` is the dialog's selection. An opted-in launch names nothing, so the host acts on
|
||||
* whatever it still offers rather than on a list this side captured a moment earlier, and passes
|
||||
* `reported` instead: the chats the user was shown, which is what the toasts count.
|
||||
*
|
||||
* Never rejects. The payload is unvalidated, and a shape this side did not expect is reported as
|
||||
* an unconfirmed delivery — the message may well have gone out.
|
||||
*/
|
||||
export async function continueNativeChatRestartOffer(
|
||||
sessionIds: readonly string[] | undefined,
|
||||
reported: readonly string[] = sessionIds ?? []
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await callStructuredAgentSession<{
|
||||
/** Which chats the host reattached, and so which claims it spent. */
|
||||
resumed?: { sessionId: string }[]
|
||||
continued: RestartActionOutcome[]
|
||||
}>(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {})
|
||||
announceRestartResults(reported, result.continued)
|
||||
settle((result.resumed ?? []).map((entry) => entry.sessionId))
|
||||
} catch {
|
||||
announceRestartUnconfirmed(reported.length)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning the offer down for good, which is the only path that spends the markers.
|
||||
*
|
||||
* Nothing is lost: opening a chat takes a resume-capable hold, which re-acquires the provider at
|
||||
* the same cursor. A write the host never confirmed leaves the offer standing and says so.
|
||||
*/
|
||||
export async function dismissNativeChatRestartOffer(): Promise<void> {
|
||||
try {
|
||||
await callStructuredAgentSession(LOCAL, 'agentSession.restartResumableDismiss', {})
|
||||
clear()
|
||||
} catch {
|
||||
announceRestartDismissUnconfirmed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This launch's single read of the offer, and the one decision the preference makes: ask, or
|
||||
* resume without asking.
|
||||
*
|
||||
* "Resume automatically" has to mean the same thing the button means, or the preference is a lie:
|
||||
* the identical call, reattaching AND asking each agent to carry on. Reattaching on its own is
|
||||
* what opening the chat already does, so a silent version of that would recover nothing.
|
||||
* what opening the chat already does, so a silent version of that would recover nothing. It is
|
||||
* never silent either — the toasts are where an opted-in user learns a message went out.
|
||||
*
|
||||
* Runs once however many surfaces mount, so the count and the dialog describe the same answer and
|
||||
* an opted-in launch cannot dispatch twice.
|
||||
@@ -120,21 +164,7 @@ async function loadLaunchOffer(): Promise<void> {
|
||||
requestNativeChatResumeOnRestartDialog()
|
||||
return
|
||||
}
|
||||
// Identical call to the dialog's own button; the host re-derives eligibility either way.
|
||||
const result = await callStructuredAgentSession<{
|
||||
/** Which chats the host reattached, and so which claims it spent. Optional because the payload
|
||||
* is unvalidated, exactly as the dialog reads it. */
|
||||
resumed?: RestartActionOutcome[]
|
||||
continued: RestartActionOutcome[]
|
||||
}>(LOCAL, 'agentSession.restartContinue', {}).catch(() => null)
|
||||
if (!result) {
|
||||
announceRestartUnconfirmed(offered.length, 'continue')
|
||||
return
|
||||
}
|
||||
// Automatic must never be silent: someone who ticked the box months ago still sees this, and
|
||||
// this is the only place they learn a message went out on their behalf.
|
||||
announceRestartResults(allResumeSessionIds(offered), result.continued, 'continue')
|
||||
settleNativeChatRestartOffer((result.resumed ?? []).map((entry) => entry.sessionId))
|
||||
await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-8
@@ -76,14 +76,7 @@
|
||||
"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.",
|
||||
"manyAgents": "{{value0}} agents",
|
||||
"oneAgent": "1 agent",
|
||||
"projects": "Folder workspaces",
|
||||
"reconnectRefused_one": "{{value0}} chat could not be resumed. You can still open it normally.",
|
||||
"reconnectRefused_other": "{{value0}} chats could not be resumed. You can still open them normally.",
|
||||
"reconnectUnconfirmed_one": "Resuming is unconfirmed for {{value0}} chat. You can still open it normally.",
|
||||
"reconnectUnconfirmed_other": "Resuming is unconfirmed for {{value0}} chats. You can still open them normally."
|
||||
"continueUnconfirmed_other": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message."
|
||||
},
|
||||
"NewWorkspaceComposerCard": {
|
||||
"0e587e31fb": "yaml",
|
||||
|
||||
@@ -1954,28 +1954,17 @@
|
||||
"listLabel": "Chats that would be resumed",
|
||||
"dontAskAgain": "Don't ask again (resume automatically)",
|
||||
"dontAskAgainHint": "You can turn this off in Settings → Experimental → Chat UI.",
|
||||
"resumedOne": "Resumed 1 chat",
|
||||
"resumedMany": "Resumed {{value0}} chats",
|
||||
"oneAgent": "1 agent",
|
||||
"manyAgents": "{{value0}} agents",
|
||||
"continuedOne": "Resumed 1 chat and asked it to continue",
|
||||
"continuedMany": "Resumed {{value0}} chats and asked them to continue",
|
||||
"selectAgent": "Resume {{value0}} chat \"{{value1}}\" in {{value2}}",
|
||||
"projects": "Folder workspaces",
|
||||
"resumeSelected": "Resume {{value0}} chats",
|
||||
"resumeSelectedOne": "Resume 1 chat",
|
||||
"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.",
|
||||
"reconnectUnconfirmed": "Resuming is unconfirmed for {{value0}} chats. You can still open them normally.",
|
||||
"reconnectUnconfirmed_one": "Resuming is unconfirmed for {{value0}} chat. You can still open it normally.",
|
||||
"reconnectUnconfirmed_other": "Resuming is unconfirmed for {{value0}} chats. You can still open them normally.",
|
||||
"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.",
|
||||
"reconnectRefused": "{{value0}} chats could not be resumed. You can still open them normally.",
|
||||
"reconnectRefused_one": "{{value0}} chat could not be resumed. You can still open it normally.",
|
||||
"reconnectRefused_other": "{{value0}} chats could not be resumed. You can still open them normally.",
|
||||
"dismissAll": "Dismiss all",
|
||||
"dismissUnconfirmed": "Dismissing the resume offer was not confirmed — it may still be in the status bar."
|
||||
},
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
// would make the two lanes behave differently — and the wording is the part that tells an agent to
|
||||
// VERIFY its last action before repeating it. Both reasons point the same way.
|
||||
//
|
||||
// Sending this needs the user's OPT-IN, not their presence. Resuming from the restart prompt sends
|
||||
// it, and so does the launch itself once the user ticked "resume automatically" — an earlier
|
||||
// comment here promised no setting could ever reach this module, and that is no longer true. It is
|
||||
// acceptable because the work being continued is the user's own, the wording above tells the agent
|
||||
// to VERIFY its last action before repeating it, and the launch reports what it did. Reattaching
|
||||
// without a send remains a separate operation that never comes here.
|
||||
// Sending this needs the user's OPT-IN, not their presence: the restart prompt's resume sends it,
|
||||
// and so does a launch the user ticked "resume automatically" for. That is acceptable because the
|
||||
// work being continued is the user's own, the wording above tells the agent to VERIFY its last
|
||||
// action before repeating it, and the launch reports what it did. Reattaching without a send
|
||||
// remains a separate operation that never comes here.
|
||||
|
||||
export const AGENT_SESSION_RESTART_CONTINUATION_MESSAGE =
|
||||
"Orca restarted, so your previous reply was cut off partway through. Before continuing, check whether your most recent action completed — don't repeat it if it did. Then carry on."
|
||||
|
||||
Reference in New Issue
Block a user