From fd483bb68b40339149528bd9ebac5085fd0f3af4 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 10 Sep 2026 11:56:35 +0000
Subject: [PATCH 01/12] An action that succeeds with nothing is not a failure
useAction reported both a refusal and a success as `undefined`, so a caller
could not tell them apart. Three call sites worked around it by mapping their
void success to a stand-in `true`, and the confirm dialog, which had no such
wrapper, would have sat open with no message after a successful confirm that
resolved to nothing.
`run` now answers whether the action succeeded and carries its value when it
did. The three stand-ins go, the delete confirmation stops rejecting its own
refusal to be heard, and the value keeps its type: the refusal branch is
excluded from it, so a caller reads the success branch's fields directly.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01YcSwKgtQE6ndJYjdDitVFB
---
.../dashboard/components/AddProjectPanel.tsx | 6 ++--
.../dashboard/components/AgentActionsMenu.tsx | 14 ++++-----
.../dashboard/components/AgentComposer.tsx | 8 ++---
.../dashboard/components/ChoicePanel.tsx | 16 ++++------
.../dashboard/components/TicketDetailPage.tsx | 8 ++---
.../dashboard/components/TicketsPage.tsx | 16 +++++-----
.../dashboard/components/TicketsPanel.tsx | 4 +--
.../components/ui/confirm-dialog.LOGIC.md | 2 +-
.../components/ui/confirm-dialog.tsx | 6 ++--
.../dashboard/lib/use-action.test.ts | 31 +++++++++++++------
.../framework/dashboard/lib/use-action.ts | 29 ++++++++++++-----
.../dashboard/lib/use-agent-handoff.ts | 4 +--
.../dashboard/lib/use-start-agent.ts | 10 +++---
13 files changed, 87 insertions(+), 67 deletions(-)
diff --git a/packages/framework/dashboard/components/AddProjectPanel.tsx b/packages/framework/dashboard/components/AddProjectPanel.tsx
index 6443e4e89..f3548e938 100644
--- a/packages/framework/dashboard/components/AddProjectPanel.tsx
+++ b/packages/framework/dashboard/components/AddProjectPanel.tsx
@@ -58,9 +58,9 @@ export function AddProjectPanel({ onAdded, onClose }: { onAdded: () => void; onC
// Trust confirmed (#439) -> install + register.
const confirmAdd = async () => {
if (busy || !path) return
- const result = await run(() => sendAddProject(path), 'Failed to add the project.')
- if (result?.ok) {
- setAdded({ alreadyActivated: result.alreadyActivated })
+ const outcome = await run(() => sendAddProject(path), 'Failed to add the project.')
+ if (outcome.ok) {
+ setAdded({ alreadyActivated: outcome.value.alreadyActivated })
onAdded()
}
}
diff --git a/packages/framework/dashboard/components/AgentActionsMenu.tsx b/packages/framework/dashboard/components/AgentActionsMenu.tsx
index 429d9667c..212e6bb8e 100644
--- a/packages/framework/dashboard/components/AgentActionsMenu.tsx
+++ b/packages/framework/dashboard/components/AgentActionsMenu.tsx
@@ -102,19 +102,19 @@ export function AgentActionsMenu({
const openApp = (target: 'files' | 'editor') => run(() => sendOpenInApp(projectId, target, agentId ?? undefined), 'Failed to open.')
const stopSession = () =>
- void run(() => sendStop(projectId, agentId ?? undefined).then(() => true), 'Could not stop the session.').then(result => {
- if (result) setStopRequested(true)
+ void run(() => sendStop(projectId, agentId ?? undefined), 'Could not stop the session.').then(outcome => {
+ if (outcome.ok) setStopRequested(true)
})
const mergeAgent = () => {
if (!agentId) return
- void run(() => sendMerge(projectId, agentId), 'Could not arm the merge.').then(result => {
- if (result?.ok) setMergeRequested(true)
+ void run(() => sendMerge(projectId, agentId), 'Could not arm the merge.').then(outcome => {
+ if (outcome.ok) setMergeRequested(true)
})
}
const removeWorktree = () => {
if (!agentId) return
- void run(() => sendRemoveWorktree(projectId, agentId), 'Could not remove the worktree.').then(result => {
- if (result !== undefined) onWorktreeRemoved?.()
+ void run(() => sendRemoveWorktree(projectId, agentId), 'Could not remove the worktree.').then(outcome => {
+ if (outcome.ok) onWorktreeRemoved?.()
})
}
@@ -237,7 +237,7 @@ export function AgentActionsMenu({
confirmLabel="Delete"
confirmBusyLabel="Deleting…"
fallbackError="Could not delete the agent."
- onConfirm={() => sendDeleteAgent(projectId, agentId).then(result => (result.ok ? result : Promise.reject(new Error(result.error))))}
+ onConfirm={() => sendDeleteAgent(projectId, agentId)}
onSuccess={onDeleted}
/>
)}
diff --git a/packages/framework/dashboard/components/AgentComposer.tsx b/packages/framework/dashboard/components/AgentComposer.tsx
index 53de12b4c..b9658ab16 100644
--- a/packages/framework/dashboard/components/AgentComposer.tsx
+++ b/packages/framework/dashboard/components/AgentComposer.tsx
@@ -118,13 +118,11 @@ export function AgentComposer({
return
}
if (live) {
- // sendMessage resolves void; map success to `true` so it is tellable from useAction's
- // failure `undefined`.
- const result = await run(
- () => sendMessage(projectId, text, agentId ?? undefined).then(() => true),
+ const outcome = await run(
+ () => sendMessage(projectId, text, agentId ?? undefined),
'Could not send — the agent may have just ended. Your text is kept, try again.',
)
- if (result) {
+ if (outcome.ok) {
setQueued(text)
composerRef.current?.clear()
}
diff --git a/packages/framework/dashboard/components/ChoicePanel.tsx b/packages/framework/dashboard/components/ChoicePanel.tsx
index a8e61c341..3ccc9a955 100644
--- a/packages/framework/dashboard/components/ChoicePanel.tsx
+++ b/packages/framework/dashboard/components/ChoicePanel.tsx
@@ -75,16 +75,12 @@ export function ChoicePanel({
const post = (pick: string | string[], by: 'user' | 'autopilot' = 'user') => {
const deliver = send ?? ((p: string | string[], b: 'user' | 'autopilot') => sendChoice(projectId, choice.id, p, b, agentId ?? undefined))
- // Both deliverers resolve void and run() reports failure as undefined, so map a delivered
- // pick to a value — otherwise success is indistinguishable from a failed post.
- void run(() => deliver(pick, by).then(() => true as const), 'Could not send your choice — try again.').then(
- delivered => {
- if (delivered) {
- setSent(true)
- onAnswered?.(pick)
- }
- },
- )
+ void run(() => deliver(pick, by), 'Could not send your choice — try again.').then(outcome => {
+ if (outcome.ok) {
+ setSent(true)
+ onAnswered?.(pick)
+ }
+ })
}
const toggle = (id: string) =>
diff --git a/packages/framework/dashboard/components/TicketDetailPage.tsx b/packages/framework/dashboard/components/TicketDetailPage.tsx
index ebd5d1bc7..1acfcc143 100644
--- a/packages/framework/dashboard/components/TicketDetailPage.tsx
+++ b/packages/framework/dashboard/components/TicketDetailPage.tsx
@@ -37,11 +37,11 @@ export function TicketDetailPage({
const queue = async () => {
if (!ticket) return
- const result = await run(
+ const outcome = await run(
() => sendQueueTicket(projectId, ticket.title, { file: ticket.file, ...(ticket.priority ? { priority: ticket.priority } : {}) }),
'The ticket could not be queued.',
)
- if (result?.ok) setQueued(true)
+ if (outcome.ok) setQueued(true)
}
// The manual lock release (#1420): nothing times a `.lock.md` out anymore, so a dead agent's
@@ -51,8 +51,8 @@ export function TicketDetailPage({
const holder = ticket?.lockedByAgent?.name ?? ticket?.lockedBy
const release = async () => {
if (!ticket) return
- const result = await run(() => sendReleaseTicketLock(projectId, ticket.file), 'The lock could not be released.')
- if (result?.ok) setReleased(true)
+ const outcome = await run(() => sendReleaseTicketLock(projectId, ticket.file), 'The lock could not be released.')
+ if (outcome.ok) setReleased(true)
}
return (
diff --git a/packages/framework/dashboard/components/TicketsPage.tsx b/packages/framework/dashboard/components/TicketsPage.tsx
index d60d0f4b2..fa0398f3d 100644
--- a/packages/framework/dashboard/components/TicketsPage.tsx
+++ b/packages/framework/dashboard/components/TicketsPage.tsx
@@ -97,17 +97,17 @@ export function TicketsPage({
const { busy, error, run } = useAction()
const startPlan = async (projectId: string, file: string) => {
const prompt = planTicketPrompt(file)
- const result = await run(() => sendStart(projectId, prompt, 'prompt'), 'The planning agent could not be started.')
- if (result?.ok) onAgentStarted?.(projectId, prompt, result.agentId)
+ const outcome = await run(() => sendStart(projectId, prompt, 'prompt'), 'The planning agent could not be started.')
+ if (outcome.ok) onAgentStarted?.(projectId, prompt, outcome.value.agentId)
}
// Unattended with the ticket named on the options, exactly as the panel's own start column does.
const startWork = async (projectId: string, file: string) => {
const prompt = workOnTicketPrompt(file)
- const result = await run(
+ const outcome = await run(
() => sendStart(projectId, prompt, 'prompt', { unattended: true, ticket: `tickets/${file}` }),
'The work agent could not be started.',
)
- if (result?.ok) onAgentStarted?.(projectId, prompt, result.agentId)
+ if (outcome.ok) onAgentStarted?.(projectId, prompt, outcome.value.agentId)
}
// The page-wide queue-adds: every unclaimed shown ticket joins the AI queue — the work the
// framework picks up on its own — as an implementation entry, or as the [Plan tickets]
@@ -138,7 +138,7 @@ export function TicketsPage({
// title as the entry, linked back to the ticket, its priority picking the section.
const [queuedKey, setQueuedKey] = useState(null)
const queueShownTickets = async (targets: { projectId: string; ticket: WorkspaceTicket }[], key: string) => {
- const done = await run(async () => {
+ const outcome = await run(async () => {
const open = await readOpenQueue()
for (const { projectId, ticket } of targets) {
if (open.tickets.has(`${projectId}\n${ticket.file}`)) continue
@@ -150,7 +150,7 @@ export function TicketsPage({
}
return { ok: true as const }
}, 'The tickets could not be queued.')
- if (done?.ok) setQueuedKey(key)
+ if (outcome.ok) setQueuedKey(key)
}
// Queue the tickets' PLANS: one `Create tickets/.plan.md` entry each — the [Plan
@@ -160,7 +160,7 @@ export function TicketsPage({
// plan could matter.
const [plansQueuedKey, setPlansQueuedKey] = useState(null)
const queueShownPlans = async (targets: { projectId: string; ticket: WorkspaceTicket }[], key: string) => {
- const done = await run(async () => {
+ const outcome = await run(async () => {
const open = await readOpenQueue()
for (const { projectId, ticket } of targets) {
if (open.texts.has(`${projectId}\n${planTicketPrompt(ticket.file)}`)) continue
@@ -173,7 +173,7 @@ export function TicketsPage({
}
return { ok: true as const }
}, 'The plans could not be queued.')
- if (done?.ok) setPlansQueuedKey(key)
+ if (outcome.ok) setPlansQueuedKey(key)
}
// A project deselected in the Project facet disappears entirely — its section would otherwise
diff --git a/packages/framework/dashboard/components/TicketsPanel.tsx b/packages/framework/dashboard/components/TicketsPanel.tsx
index 2d4f9a342..90307ff05 100644
--- a/packages/framework/dashboard/components/TicketsPanel.tsx
+++ b/packages/framework/dashboard/components/TicketsPanel.tsx
@@ -341,10 +341,10 @@ export function TicketsPanel({
const configure = () => onSelectProject(projectId)
const startSession = async (prompt: string, failure: string, options: { unattended?: boolean; ticket?: string } = {}) => {
- const result = await run(() => sendStart(projectId, prompt, 'prompt', options), failure)
+ const outcome = await run(() => sendStart(projectId, prompt, 'prompt', options), failure)
// Jump to the session doing the work, so its progress is watchable instead of the panel
// sitting on stale rows until files land.
- if (result?.ok) onAgentStarted?.(prompt, result.agentId)
+ if (outcome.ok) onAgentStarted?.(prompt, outcome.value.agentId)
}
// Unattended (#1279): an update fired by a button is routine work, not a conversation — it
diff --git a/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md b/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md
index 4e21650e1..016a6f38d 100644
--- a/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md
+++ b/packages/framework/dashboard/components/ui/confirm-dialog.LOGIC.md
@@ -67,7 +67,7 @@ See `## Context`.
#### Business logic
-An action fails by throwing or by answering with a refused result; either way the dialog stays open and shows the failure's message in red under the body, or the host's fallback text, "Something went wrong." by default, when the failure carries no message. The user may confirm again or cancel. When the dialog is opened again later, any earlier message is cleared. The dialog treats an action as succeeded only when it resolves to a result; one that resolves to nothing keeps the dialog open without a message.
+An action fails by throwing or by answering with a refused result; either way the dialog stays open and shows the failure's message in red under the body, or the host's fallback text, "Something went wrong." by default, when the failure carries no message. The user may confirm again or cancel. When the dialog is opened again later, any earlier message is cleared. An action that succeeds with nothing to report closes the dialog like any other success: only a refusal or a throw keeps it open.
### Success closes first, then the host continues
diff --git a/packages/framework/dashboard/components/ui/confirm-dialog.tsx b/packages/framework/dashboard/components/ui/confirm-dialog.tsx
index 8bf7d7633..3a0f004b5 100644
--- a/packages/framework/dashboard/components/ui/confirm-dialog.tsx
+++ b/packages/framework/dashboard/components/ui/confirm-dialog.tsx
@@ -36,7 +36,7 @@ export function ConfirmDialog({
body: ReactNode
confirmLabel: string
confirmBusyLabel?: string
- /** Runs on confirm; returning a falsy/thrown result keeps the dialog open with the error. */
+ /** Runs on confirm; a refused or thrown result keeps the dialog open with the error. */
onConfirm: () => Promise
/** Fires once, after the dialog has closed on a successful confirm — safe to navigate away in. */
onSuccess?: () => void
@@ -49,8 +49,8 @@ export function ConfirmDialog({
const { busy, error, run, reset } = useAction()
const confirm = (): void => {
- void run(onConfirm, fallbackError).then(result => {
- if (result === undefined) return
+ void run(onConfirm, fallbackError).then(outcome => {
+ if (!outcome.ok) return
setOpen(false)
// After the close, so a caller that unmounts this (navigating off the deleted session) does
// not tear the dialog down mid-transition.
diff --git a/packages/framework/dashboard/lib/use-action.test.ts b/packages/framework/dashboard/lib/use-action.test.ts
index 8d25cfa77..bd7e8833b 100644
--- a/packages/framework/dashboard/lib/use-action.test.ts
+++ b/packages/framework/dashboard/lib/use-action.test.ts
@@ -3,34 +3,36 @@ import { act, renderHook } from '@testing-library/react'
import { useAction } from './use-action.js'
describe('useAction', () => {
- test('a successful action returns the result, sets no error, and settles busy', async () => {
+ test('a successful action carries its value, sets no error, and settles busy', async () => {
const { result } = renderHook(() => useAction())
let out: unknown
await act(async () => {
out = await result.current.run(async () => ({ ok: true, url: 'x' }))
})
- expect(out).toEqual({ ok: true, url: 'x' })
+ expect(out).toEqual({ ok: true, value: { ok: true, url: 'x' } })
expect(result.current.busy).toBe(false)
expect(result.current.error).toBe(null)
})
- test('a { ok: false } result routes into error and returns undefined', async () => {
+ test('a { ok: false } result routes into error and reports the action as not ok', async () => {
const { result } = renderHook(() => useAction())
let out: unknown = 'sentinel'
await act(async () => {
out = await result.current.run(async () => ({ ok: false, error: 'nope' }))
})
- expect(out).toBe(undefined)
+ expect(out).toEqual({ ok: false })
expect(result.current.error).toBe('nope')
})
test('a thrown error routes into error, falling back when it carries no message', async () => {
const { result } = renderHook(() => useAction())
+ let out: unknown = 'sentinel'
await act(async () => {
- await result.current.run(async () => {
+ out = await result.current.run(async () => {
throw new Error('boom')
})
})
+ expect(out).toEqual({ ok: false })
expect(result.current.error).toBe('boom')
await act(async () => {
await result.current.run(async () => {
@@ -40,14 +42,25 @@ describe('useAction', () => {
expect(result.current.error).toBe('fallback msg')
})
- test('a void action returns undefined and sets no error on success', async () => {
+ // The distinction the outcome exists for: an action that succeeds with nothing to report is
+ // not a failure, and a caller must be able to tell the two apart without a stand-in value.
+ test('an action that succeeds with nothing is ok, and tellable from one that failed', async () => {
const { result } = renderHook(() => useAction())
- let out: unknown = 'sentinel'
+ let succeeded: unknown = 'sentinel'
await act(async () => {
- out = await result.current.run(async () => {})
+ succeeded = await result.current.run(async () => {})
})
- expect(out).toBe(undefined)
+ expect(succeeded).toEqual({ ok: true, value: undefined })
expect(result.current.error).toBe(null)
+
+ let failed: unknown = 'sentinel'
+ await act(async () => {
+ failed = await result.current.run(async () => {
+ throw new Error('boom')
+ })
+ })
+ expect(failed).toEqual({ ok: false })
+ expect(result.current.error).toBe('boom')
})
test('reset clears the error', async () => {
diff --git a/packages/framework/dashboard/lib/use-action.ts b/packages/framework/dashboard/lib/use-action.ts
index fff1d52d5..a5584c0a6 100644
--- a/packages/framework/dashboard/lib/use-action.ts
+++ b/packages/framework/dashboard/lib/use-action.ts
@@ -1,34 +1,47 @@
import { useCallback, useState } from 'react'
+/**
+ * What an action did: `ok` says whether it succeeded, and only then is there a `value`.
+ *
+ * Said as a pair rather than as "the value, or `undefined` when it did not succeed", because an
+ * action that succeeds with nothing to report is not a failure: with the value alone, a `sendStop`
+ * that resolves void was indistinguishable from one that threw, and every such caller had to map
+ * its success to a stand-in value to tell the two apart.
+ */
+export type ActionOutcome = { ok: true; value: Succeeded } | { ok: false }
+
+/** What is left of an action's result once its own refusal branch is ruled out. */
+type Succeeded = Exclude
+
// The write-side twin of use-async's read hooks. Every mutation panel hand-rolled the same
// shape: flip a busy flag, clear the error, await the RPC, route a `{ ok: false, error }`
// result or a thrown error into an error string, and reset busy in a finally. That is this
-// hook, once. `run` returns the RPC result on success (so the caller does only its success
-// side) and `undefined` when the action did not succeed. `fallback` names the error for a
-// thrown failure that carries no message of its own.
+// hook, once. `run` reports whether the action succeeded and carries its value when it did, so
+// the caller does only its success side. `fallback` names the error for a thrown failure that
+// carries no message of its own.
export function useAction(): {
busy: boolean
error: string | null
reset: () => void
- run: (fn: () => Promise, fallback?: string) => Promise
+ run: (fn: () => Promise, fallback?: string) => Promise>
} {
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const reset = useCallback(() => setError(null), [])
- const run = useCallback(async (fn: () => Promise, fallback = 'Something went wrong.'): Promise => {
+ const run = useCallback(async (fn: () => Promise, fallback = 'Something went wrong.'): Promise> => {
setBusy(true)
setError(null)
try {
const result = await fn()
if (isFailure(result)) {
setError(result.error ?? fallback)
- return undefined
+ return { ok: false }
}
- return result
+ return { ok: true, value: result as Succeeded }
} catch (err) {
setError(err instanceof Error ? err.message : fallback)
- return undefined
+ return { ok: false }
} finally {
setBusy(false)
}
diff --git a/packages/framework/dashboard/lib/use-agent-handoff.ts b/packages/framework/dashboard/lib/use-agent-handoff.ts
index 9273a5789..d7764eac7 100644
--- a/packages/framework/dashboard/lib/use-agent-handoff.ts
+++ b/packages/framework/dashboard/lib/use-agent-handoff.ts
@@ -41,9 +41,9 @@ export function useAgentHandoff(projectId: string, agentId: string | null | unde
const act = (which: 'push' | 'pr' | 'merge', fn: () => Promise, fallback: string): void => {
setPending(which)
- void run(fn, fallback).then(result => {
+ void run(fn, fallback).then(outcome => {
setPending(null)
- if (result !== undefined) reload()
+ if (outcome.ok) reload()
})
}
diff --git a/packages/framework/dashboard/lib/use-start-agent.ts b/packages/framework/dashboard/lib/use-start-agent.ts
index d5dc5f86b..c65a30253 100644
--- a/packages/framework/dashboard/lib/use-start-agent.ts
+++ b/packages/framework/dashboard/lib/use-start-agent.ts
@@ -28,13 +28,13 @@ export function useStartAgent(): {
options: StartArgs[3],
fallback = 'Failed to start the agent.',
) => {
- const result = await run(async () => {
- const outcome = await sendStart(projectId, text, kind, options)
+ const outcome = await run(async () => {
+ const started = await sendStart(projectId, text, kind, options)
// The daemon's refusal is phrased for its own log; give the dashboard its words.
- if (!outcome.ok && outcome.busy) return { ...outcome, error: 'An agent is already active for this project.' }
- return outcome
+ if (!started.ok && started.busy) return { ...started, error: 'An agent is already active for this project.' }
+ return started
}, fallback)
- return result?.ok ? result : undefined
+ return outcome.ok ? outcome.value : undefined
}
return { busy, error, reset, start }
}
From 53d6333821bce62ebe8f4b35c7074611d9c2726c Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 10 Sep 2026 11:58:32 +0000
Subject: [PATCH 02/12] Escape closes the suggestion menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Escape over an open `/`, `<`, `@` or `#` menu was passed through untouched, so
the menu stayed on screen after the keystroke that means "dismiss this" — and
since an open menu is what makes Enter pick instead of send, the next Enter
picked a suggestion the user thought they had dismissed.
The first Escape now closes the menu and stops there, leaving the trigger armed
but silent until a fresh one opens. With no menu showing, Escape is left alone
and reaches the surface around the editor as before.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01YcSwKgtQE6ndJYjdDitVFB
---
.../prompt-editor/suggestion.LOGIC.md | 13 +++++++++++-
.../components/prompt-editor/suggestion.ts | 20 +++++++++++++++++--
2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md b/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md
index 8b8ce5989..1c433426b 100644
--- a/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md
+++ b/packages/framework/dashboard/components/prompt-editor/suggestion.LOGIC.md
@@ -14,7 +14,8 @@ Wires a trigger character to a floating suggestion menu in the composer [1]'s pr
- **The query is matched ignoring letter case** - what the user typed after the trigger character is lower-cased before the trigger's own item source filters on it.
- **The menu floats at the caret** - it opens just below the caret, flips above it when the caret is near the bottom of the window, and follows the caret through scrolling and window resizing.
- **An empty source shows a note, a mistyped query hides the menu** - a fresh trigger over an empty source shows the trigger's note, while a query that matches nothing hides the menu but keeps the trigger armed, so the menu is back the moment a later key matches.
-- **Keys reach the menu while a trigger is open** - key presses go to the menu list first, which steers and picks as described in `SuggestionList.tsx`; Escape is passed through untouched.
+- **Keys reach the menu while a trigger is open** - key presses go to the menu list first, which steers and picks as described in `SuggestionList.tsx`.
+- **Escape dismisses the menu** - the first Escape closes an open menu and goes no further; the trigger stays armed but silent until a fresh one opens, and a second Escape reaches the surface around the editor.
- **A pick replaces the trigger text** - the picked item and the span from the trigger character to the caret go to the trigger's own insertion, which replaces that span.
- **The open menu is announced** - while the menu is visible the editor reads as an expanded combobox pointing at the highlighted entry, and the same signal is what makes Enter pick from the menu instead of sending the prompt.
@@ -70,6 +71,16 @@ See `## Context`.
While a trigger is open, every key press is offered to the menu list before the editor sees it; the list decides which keys it consumes (arrows, Enter and Tab, per `SuggestionList.tsx`) and the rest reach the editor as normal typing. Escape is the one key never offered to the list: it passes through to the editor and the page, and does not close the trigger by itself.
+### Escape dismisses the menu
+
+#### Context
+
+**Problem**: Escape over an open menu means "close this". Letting it through untouched left the menu on screen after the keystroke that dismisses it, and because an open menu is what makes Enter pick instead of send, the next Enter picked a suggestion the user thought they had dismissed.
+
+#### Business logic
+
+The first Escape while a menu is showing closes it and stops there: the menu is hidden, the editor stops reading as expanded, and nothing else on the page acts on that keystroke. The trigger itself stays armed but silent — typing on does not bring the menu back, and only a fresh trigger opens one again. With no menu showing, Escape is not taken: it reaches the surface around the editor as it always did.
+
### A pick replaces the trigger text
#### Context
diff --git a/packages/framework/dashboard/components/prompt-editor/suggestion.ts b/packages/framework/dashboard/components/prompt-editor/suggestion.ts
index 6ec0c6830..a784ec401 100644
--- a/packages/framework/dashboard/components/prompt-editor/suggestion.ts
+++ b/packages/framework/dashboard/components/prompt-editor/suggestion.ts
@@ -55,6 +55,9 @@ function makeRender(config: TriggerConfig) {
let getRect: (() => DOMRect | null) | null = null
// The editor's contenteditable, for the aria combobox wiring while the menu is open.
let editorDom: HTMLElement | null = null
+ // Escape dismisses this menu for as long as the trigger stays armed: typing on does not bring
+ // it back, and only a fresh trigger opens one again.
+ let dismissed = false
const setActive = (id: string | null): void => {
if (!editorDom) return
@@ -68,7 +71,7 @@ function makeRender(config: TriggerConfig) {
// stray `<`/`@` in prose is not a trap. The plugin stays active — the menu reappears if
// a later key matches.
const note = items.length === 0 && !props.query ? config.emptyNote : undefined
- const visible = items.length > 0 || !!note
+ const visible = !dismissed && (items.length > 0 || !!note)
if (el) el.style.display = visible ? '' : 'none'
// aria-expanded tracks what the user actually sees, not the plugin's active range: a
// mistyped query hides the menu while the plugin stays armed, and both the a11y tree and
@@ -94,6 +97,7 @@ function makeRender(config: TriggerConfig) {
return {
onStart(props: RenderProps) {
+ dismissed = false
el = document.createElement('div')
el.style.position = 'fixed'
el.style.zIndex = '50'
@@ -113,10 +117,22 @@ function makeRender(config: TriggerConfig) {
draw(props)
},
onKeyDown(props: { event: KeyboardEvent }) {
- if (props.event.key === 'Escape') return false
+ // Escape closes the menu and stops there. Leaving it to the editor kept the menu on screen
+ // after the keystroke that means "dismiss this", and the editor's Enter-to-send guard reads
+ // an open menu — so the next Enter picked a suggestion instead of sending. A second Escape,
+ // with no menu open, reaches the surface around the editor as it always did.
+ if (props.event.key === 'Escape') {
+ if (!el || el.style.display === 'none') return false
+ dismissed = true
+ el.style.display = 'none'
+ editorDom?.setAttribute('aria-expanded', 'false')
+ setActive(null)
+ return true
+ }
return listRef?.onKeyDown(props.event) ?? false
},
onExit() {
+ dismissed = false
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
editorDom?.setAttribute('aria-expanded', 'false')
From bc78a042efd30f7b4868fe2bc9059af1dc219671 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 10 Sep 2026 11:59:38 +0000
Subject: [PATCH 03/12] A tag is a tag whether it was typed or loaded
Typing `` into the composer produced the `` the agent expects,
but loading a preset carrying the same text left it as prose, so it reached the
agent lower-case. The two paths recognized different sets: typing accepted a tag
in any letter case, loading accepted upper-case only.
Loading now recognizes what typing recognizes, and a catalogued tag normalizes
on both paths. Tests pin the rule, which the token catalogue had none of.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01YcSwKgtQE6ndJYjdDitVFB
---
.../components/prompt-editor/tokens.LOGIC.md | 4 +-
.../prompt-editor/tokens.test.LOGIC.md | 5 +++
.../components/prompt-editor/tokens.test.ts | 39 +++++++++++++++++++
.../components/prompt-editor/tokens.ts | 10 ++++-
4 files changed, 54 insertions(+), 4 deletions(-)
create mode 100644 packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md
create mode 100644 packages/framework/dashboard/components/prompt-editor/tokens.test.ts
diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md b/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md
index 1bce58f9d..f2de46bc9 100644
--- a/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md
+++ b/packages/framework/dashboard/components/prompt-editor/tokens.LOGIC.md
@@ -19,7 +19,7 @@ Defines the tokens of the composer [1]'s prompt editor: inline chips that stand
- **Five kinds of token** - macro, action, reference, project and file; the kind decides the chip's color and which trigger menu inserts it.
- **The macro catalog** - the six angle-bracket tags the preset prompts repeat, each with a one-line hint for the menu.
- **The action catalog** - the three call-shaped tokens that make the agent stop at a gate [3] or push a view [4], each with a hint.
-- **Recognizing a token in text** - an upper-case angle-bracket tag or a `show…()` call anywhere in free text is a token; this is how a loaded preset is turned into chips.
+- **Recognizing a token in text** - an angle-bracket tag or a `show…()` call anywhere in free text is a token, whatever its letter case; this is how a loaded preset is turned into chips, and it recognizes exactly what typing the same text recognizes.
- **Normalizing a known token** - a token matching a catalogued one in any letter case becomes that catalogued token; an unknown one keeps its exact spelling.
- **A chip is one unit and serializes verbatim** - a chip is edited as a whole, shows its label, and is written to the prompt as its exact text with no markdown escaping.
- **Chips form while typing** - a tag becomes a chip the moment its closing `>` is typed, a call the moment its closing `)` is typed.
@@ -64,7 +64,7 @@ Three actions are catalogued: `showChoices()` "Single-select gate", `showMultiSe
#### Business logic
-Anywhere in free text, a token is either a tag written as `<`, an upper-case letter, any run of upper-case letters, digits and underscores, and `>`, or a call written as `show`, one or more letters, and `()`. A lower-case tag in loaded text is not a token. Turning loaded text into chips is done by `tokenize.ts` with this rule.
+Anywhere in free text, a token is either a tag written as `<`, a letter, any run of letters, digits and underscores, and `>`, or a call written as `show`, one or more letters, and `()`. Letter case does not decide what counts: the rule recognizes exactly what typing the same text recognizes, so a loaded `` becomes the canonical `` chip just as a typed one does. Turning loaded text into chips is done by `tokenize.ts` with this rule.
### Normalizing a known token
diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md b/packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md
new file mode 100644
index 000000000..da2296baf
--- /dev/null
+++ b/packages/framework/dashboard/components/prompt-editor/tokens.test.LOGIC.md
@@ -0,0 +1,5 @@
+What the tests cover:
+
+- **Recognizing a token in text** - a tag and a call are found anywhere in free text, and letter case does not decide what counts, so what a loaded preset offers up as chips is what typing the same text offers.
+- **What is not a token** - a stray `<` or `>` in prose, an empty tag, a tag starting with a digit, and a bare `show()` are all left alone.
+- **Normalizing a recognized token** - a catalogued tag or call takes its canonical spelling however it was written, so a loaded or typed `` becomes ``; an unknown one keeps exactly what was written, as a tag or, when it ends in a call's brackets, as a call.
diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.test.ts b/packages/framework/dashboard/components/prompt-editor/tokens.test.ts
new file mode 100644
index 000000000..419dd542d
--- /dev/null
+++ b/packages/framework/dashboard/components/prompt-editor/tokens.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from 'vitest'
+import { TOKEN_PATTERN, specForText } from './tokens.js'
+
+/** Every token the pattern finds in `text`, as the tokenizer walks it. */
+function found(text: string): string[] {
+ return [...text.matchAll(TOKEN_PATTERN)].map(m => m[0])
+}
+
+describe('recognizing a token in text', () => {
+ test('tags and calls are found anywhere in free text', () => {
+ expect(found('Do the work, then and call showChoices() when unsure.')).toEqual(['', 'showChoices()'])
+ })
+
+ // What typing recognizes and what loading recognizes have to be the same set, or a preset
+ // carrying `` reaches the agent as typed while typing it produces ``.
+ test('letter case does not decide what counts as a tag', () => {
+ expect(found(' and and ')).toEqual(['', '', ''])
+ })
+
+ test('what is not a token', () => {
+ expect(found('2 < 3 and x > y')).toEqual([])
+ expect(found('<>')).toEqual([])
+ expect(found('<9lives>')).toEqual([])
+ expect(found('show()')).toEqual([])
+ })
+})
+
+describe('normalizing a recognized token', () => {
+ test('a catalogued token takes its canonical spelling, whatever the case it was written in', () => {
+ expect(specForText('')).toMatchObject({ kind: 'macro', label: 'AWAIT', text: '' })
+ expect(specForText('')).toMatchObject({ text: '' })
+ expect(specForText('showchoices()')).toMatchObject({ kind: 'action', text: 'showChoices()' })
+ })
+
+ test('an unknown token keeps exactly what was written', () => {
+ expect(specForText('')).toMatchObject({ kind: 'macro', label: 'MY_TAG', text: '' })
+ expect(specForText('showSomething()')).toMatchObject({ kind: 'action', label: 'showSomething()', text: 'showSomething()' })
+ })
+})
diff --git a/packages/framework/dashboard/components/prompt-editor/tokens.ts b/packages/framework/dashboard/components/prompt-editor/tokens.ts
index 2fda2d6ef..1f312e6ca 100644
--- a/packages/framework/dashboard/components/prompt-editor/tokens.ts
+++ b/packages/framework/dashboard/components/prompt-editor/tokens.ts
@@ -37,8 +37,14 @@ export const ACTION_TOKENS: TokenSpec[] = [
{ kind: 'action', label: 'showMarkdown()', text: 'showMarkdown()', hint: 'Push a markdown view' },
]
-/** Match any insertable token in free text, so a loaded preset can be chip-ified (tokenize.ts). */
-export const TOKEN_PATTERN = /<[A-Z][A-Z0-9_]*>|show[A-Za-z]+\(\)/g
+/**
+ * Match any insertable token in free text, so a loaded preset can be chip-ified (tokenize.ts).
+ * Letter case does not decide what counts as a tag — `specForText` normalizes a catalogued one —
+ * so this matches exactly what typing the same text matches (the input rules below). Taking
+ * uppercase alone made the two disagree: a preset carrying `` stayed plain text and
+ * reached the agent as typed, while typing it into the same box produced ``.
+ */
+export const TOKEN_PATTERN = /<[A-Za-z][A-Za-z0-9_]*>|show[A-Za-z]+\(\)/g
/**
* The token spec for a matched string. A catalogued macro/action matches case-insensitively
From 31f8f49fc4b9f400d5d163ec020feab102d1abbb Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 10 Sep 2026 12:00:49 +0000
Subject: [PATCH 04/12] Drop the preview event nothing can emit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The event vocabulary declared a generated app booted and serving, the terminal
rendered it, and a test pinned that rendering — but nothing in the agent's
lifecycle ever emitted one, and the serve config its doc hung on does not
exist. A word no writer can say is not vocabulary.
The dashboard's own project preview is a different thing and stays.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01YcSwKgtQE6ndJYjdDitVFB
---
packages/framework/dashboard/components/EventList.tsx | 4 ++--
packages/framework/src/events.LOGIC.md | 1 -
packages/framework/src/events.test.LOGIC.md | 1 -
packages/framework/src/events.test.ts | 7 -------
packages/framework/src/events.ts | 6 ------
packages/framework/src/terminal.LOGIC.md | 2 +-
packages/framework/src/terminal.ts | 2 --
7 files changed, 3 insertions(+), 20 deletions(-)
diff --git a/packages/framework/dashboard/components/EventList.tsx b/packages/framework/dashboard/components/EventList.tsx
index de6aca3ba..8556e3633 100644
--- a/packages/framework/dashboard/components/EventList.tsx
+++ b/packages/framework/dashboard/components/EventList.tsx
@@ -110,14 +110,14 @@ function rowTone(e: FrameworkEvent): string {
* - milestones (a CLEAN `end`, `ready-for-merge`) — green, how far the agent got; a stopped or
* failed end is not a milestone (failure is already red, stopped stays neutral), and
* `handoff` stays muted because its body reports per-rung outcomes that may be mixed
- * - pushed surfaces (`view`, `browser-stream`, `preview`) — primary, the agent showing you something
+ * - pushed surfaces (`view`, `browser-stream`, `browser`) — primary, the agent showing you something
*/
function badgeTone(e: FrameworkEvent): string {
const semantic = rowTone(e)
if (semantic) return semantic
if (e.kind === 'choice' || e.kind === 'choice-resolved') return 'text-warning'
if ((e.kind === 'end' && e.ok) || e.kind === 'ready-for-merge') return 'text-success'
- if (e.kind === 'view' || e.kind === 'browser-stream' || e.kind === 'browser' || e.kind === 'preview') return 'text-primary'
+ if (e.kind === 'view' || e.kind === 'browser-stream' || e.kind === 'browser') return 'text-primary'
return ''
}
diff --git a/packages/framework/src/events.LOGIC.md b/packages/framework/src/events.LOGIC.md
index 01d658a0d..007e1cf78 100644
--- a/packages/framework/src/events.LOGIC.md
+++ b/packages/framework/src/events.LOGIC.md
@@ -93,7 +93,6 @@ Every progress event the driver [13] reports is wrapped and forwarded verbatim o
- A log line: one line of The Framework's own narration ("Finishing the session (await limit reached).", "Handed off: …").
- The browser's page: the URL the agent's browser is showing, emitted for the first real `http` or `https` page and again on every change of page, so the agent view can host the live preview at the point in the stream where it was used. Only the URL travels. It is emitted again after each session opening, because the dashboard shows only the events since the latest session opening; readers fold repeats of the same URL in place, like a re-shown view.
- The browser stream: the agent's browser preview is up and listening on a loopback port. Only the port travels: the dashboard reaches the stream through the daemon, which proxies to that port, so the agent's own browser endpoint is never reachable from the web. Frames never enter the log, because someone will type a password into that pane.
-- A preview: a generated app booted and serving at a URL, with the command that serves it, so the user can open it. The vocabulary defines it and the terminal renders it; no part of the agent's lifecycle emits it today.
### A gate and its pick
diff --git a/packages/framework/src/events.test.LOGIC.md b/packages/framework/src/events.test.LOGIC.md
index aa842ae43..cee672b71 100644
--- a/packages/framework/src/events.test.LOGIC.md
+++ b/packages/framework/src/events.test.LOGIC.md
@@ -8,7 +8,6 @@ What the tests cover, across the pick normalization in `events.ts`, the session
- **The session id line** - "session abc123", with " — " appended once a session link is known.
- **The system prompt line** - only the length is shown: "system prompt sent (5 chars)".
- **The forwarded prompt line** - the coding agent's turn start shows the prompt text itself ("› prompt: Build this app end to end"), not just "prompt sent"; a long prompt is cut to well under 160 characters and ends in an ellipsis.
-- **The preview line** - "▶ your app is running at ".
- **The end line** - "✓ finished", "■ stopped", or "✗ failed: ", so a stop never reads as a failure.
- **The usage line** - "spend: $0.0400 over 2 turns", with "turn" in the singular for one; when the coding agent reported tokens but no price the line reads "tokens: 12,224 (6 out) over 1 turn — no price reported" and carries no dollar sign, because a "$0.0000" would read as free rather than unknown; the token total is the input, cache-read and output tokens together.
- **The quota line** - "· quota allowed (five_hour)", "! quota running low (five_hour)" and "✗ quota exhausted (five_hour)" by the reported status, each followed by the reset time as an ISO timestamp; a status never seen before still renders as "quota " rather than vanishing or crashing.
diff --git a/packages/framework/src/events.test.ts b/packages/framework/src/events.test.ts
index f3fc3cd8e..cd64dde0f 100644
--- a/packages/framework/src/events.test.ts
+++ b/packages/framework/src/events.test.ts
@@ -114,13 +114,6 @@ test('formatFrameworkEvent shows a preview of the driver prompt, not just "promp
assert.ok(line.length < 160 && line.endsWith('…'))
})
-test('formatFrameworkEvent renders a preview line', () => {
- assert.equal(
- formatFrameworkEvent({ kind: 'preview', url: 'http://localhost:3000', command: 'npm run dev' }),
- '▶ your app is running at http://localhost:3000',
- )
-})
-
test('formatFrameworkEvent distinguishes finished / stopped / failed (#218)', () => {
assert.equal(formatFrameworkEvent({ kind: 'end', ok: true }), '✓ finished')
assert.equal(formatFrameworkEvent({ kind: 'end', ok: false, stopped: true }), '■ stopped')
diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts
index a55e06153..a95156159 100644
--- a/packages/framework/src/events.ts
+++ b/packages/framework/src/events.ts
@@ -170,12 +170,6 @@ export type FrameworkEvent =
| { kind: 'intent'; text: string }
/** The wrapped agent's own progress, forwarded verbatim (never gated on). */
| { kind: 'driver'; event: DriverEvent }
- /**
- * The generated app is booted and serving. Emitted after a successful agent when
- * a serve config is set: the app is kept running so the user can open it, and
- * the dashboard shows a live preview link (torn down on Ctrl+C).
- */
- | { kind: 'preview'; url: string; command: string }
/**
* The agent's browser preview is up and listening on this loopback port (#813).
*
diff --git a/packages/framework/src/terminal.LOGIC.md b/packages/framework/src/terminal.LOGIC.md
index 16013e11d..7f3359d99 100644
--- a/packages/framework/src/terminal.LOGIC.md
+++ b/packages/framework/src/terminal.LOGIC.md
@@ -27,7 +27,7 @@ Renders an agent's [1] event stream [2] in a terminal, one human-readable line p
## Business logic — TL;DR
-- **The agent's setup** - the driver and model in the checkout with the session link, the prompt, the branch, the ticket, the cloud anchor, the preview and browser addresses, the pull request number.
+- **The agent's setup** - the driver and model in the checkout with the session link, the prompt, the branch, the ticket, the cloud anchor, the browser addresses, the pull request number.
- **What the agent signals** - its log lines, errors with their detail indented, views by title, "✓ ready for merge", the pull request it wrote, and "done for now" when it is settled.
- **Gates and picks** - the question with one option per line, the recommended one marked, and the pick with who made it.
- **The handoff, announced then reported** - one line saying what will happen when the agent ends, then what happened to the push and the pull request, and always a line for the merge.
diff --git a/packages/framework/src/terminal.ts b/packages/framework/src/terminal.ts
index f67362d52..aa4307780 100644
--- a/packages/framework/src/terminal.ts
+++ b/packages/framework/src/terminal.ts
@@ -17,8 +17,6 @@ export function formatFrameworkEvent(event: FrameworkEvent): string {
return ` session ${event.sessionId}${event.sessionLink ? ` — ${event.sessionLink}` : ''}`
case 'system-prompt':
return ` system prompt sent (${event.text.length} chars)`
- case 'preview':
- return `▶ your app is running at ${event.url}`
case 'browser-stream':
return `◆ browser preview: http://127.0.0.1:${event.port}/stream`
case 'browser':
From cf912e15eee462f709d41623335d4cd82a61801e Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 10 Sep 2026 12:05:45 +0000
Subject: [PATCH 05/12] Remove the gate countdown the dashboard does not run
A gate carried a delay after which the dashboard was to accept the recommended
option, the panel took a prop saying whether that countdown may run here, two
call sites turned it off, and a mouse-move listener stood by to cancel it. None
of it did anything: nothing ever started a countdown, so the panel always asked,
and the delay was read by no one.
The scaffolding goes, and with it the pick author no surface could produce: a
pick is now the user's or nobody's, the latter being an agent nobody is
watching, which is the daemon's own auto-accept and still very much alive.
Whether the dashboard should answer a gate for the user at all is a product
question, and this leaves it open rather than half-built.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01YcSwKgtQE6ndJYjdDitVFB
---
.../dashboard/components/ChoicePanel.tsx | 32 ++-------
.../dashboard/components/CloudAgentNotice.tsx | 2 +-
.../components/OpenQuestions.LOGIC.md | 4 +-
.../components/OpenQuestions.test.tsx | 8 +--
.../dashboard/components/OpenQuestions.tsx | 5 +-
packages/framework/src/control.test.ts | 4 +-
.../src/dashboard-rpc/control.LOGIC.md | 2 +-
packages/framework/src/events.LOGIC.md | 54 +++++++-------
packages/framework/src/events.ts | 10 ++-
packages/framework/src/todo-loop.LOGIC.md | 71 +++++++++----------
10 files changed, 84 insertions(+), 108 deletions(-)
diff --git a/packages/framework/dashboard/components/ChoicePanel.tsx b/packages/framework/dashboard/components/ChoicePanel.tsx
index 3ccc9a955..30210deb8 100644
--- a/packages/framework/dashboard/components/ChoicePanel.tsx
+++ b/packages/framework/dashboard/components/ChoicePanel.tsx
@@ -22,7 +22,6 @@ export function ChoicePanel({
agentId: agentId,
choice,
active = false,
- countdown = true,
inline = false,
onAnswered,
send,
@@ -35,13 +34,6 @@ export function ChoicePanel({
/** Inline in the transcript (#1455 item 6): a rounded card in the flow rather than a
* full-bleed rail section. Behaviour is identical — only the container changes. */
inline?: boolean
- /**
- * Whether autopilot's auto-accept countdown may run here (#1455). The launcher's questions hub
- * turns it off: it renders every parked session's gate at once, and a page that answers all of
- * them ten seconds after being opened is not a hub, it is a mass auto-accept. The session's own
- * rail keeps the countdown — there the user chose to look at that one agent.
- */
- countdown?: boolean
/**
* Told once the pick is posted and accepted, with what was picked. The launcher's hub uses it
* to collapse the answered card to a single line (#1455 bonus 2); the rail passes nothing —
@@ -54,7 +46,7 @@ export function ChoicePanel({
* panel is the same either way, which is the point: a cloud session's question is answered
* exactly like a local one.
*/
- send?: ((pick: string | string[], by: 'user' | 'autopilot') => Promise) | undefined
+ send?: ((pick: string | string[]) => Promise) | undefined
}) {
const { busy, error, run } = useAction()
// Posted and accepted by the daemon; the panel stays parked (buttons off, status shown)
@@ -64,18 +56,15 @@ export function ChoicePanel({
const [checked, setChecked] = useState>(
() => new Set(choice.multi ? choice.options.filter(o => o.default).map(o => o.id) : []),
)
- // The countdown's auto-accept fires from a closure captured when the countdown started;
- // the ref keeps it reading the boxes as they are at fire time (#948).
+ // Accept reads the boxes as they are when it fires, not as they were when it was wired up.
const checkedRef = useRef(checked)
checkedRef.current = checked
- const [secondsLeft, setSecondsLeft] = useState(null)
- const [cancelled, setCancelled] = useState(false)
const parked = busy || sent
- const post = (pick: string | string[], by: 'user' | 'autopilot' = 'user') => {
- const deliver = send ?? ((p: string | string[], b: 'user' | 'autopilot') => sendChoice(projectId, choice.id, p, b, agentId ?? undefined))
- void run(() => deliver(pick, by), 'Could not send your choice — try again.').then(outcome => {
+ const post = (pick: string | string[]) => {
+ const deliver = send ?? ((p: string | string[]) => sendChoice(projectId, choice.id, p, 'user', agentId ?? undefined))
+ void run(() => deliver(pick), 'Could not send your choice — try again.').then(outcome => {
if (outcome.ok) {
setSent(true)
onAnswered?.(pick)
@@ -91,17 +80,10 @@ export function ChoicePanel({
})
// What Accept picks: the checked subset for a multi-select, else the recommended option (the
- // first when the agent named none). Shared by the button, the countdown, and Ctrl+Enter.
+ // first when the agent named none). Shared by the button and Ctrl+Enter.
const recommendedId = choice.recommended ?? choice.options[0]?.id
const autoPick = (): string | string[] => (choice.multi ? [...checkedRef.current] : (recommendedId ?? ''))
- const accept = (by: 'user' | 'autopilot' = 'user') => post(autoPick(), by)
-
- // Any mouse movement cancels the auto-accept — the human is here, so let them pick.
- useEffect(() => {
- const cancel = () => setCancelled(true)
- window.addEventListener('mousemove', cancel, { once: true })
- return () => window.removeEventListener('mousemove', cancel)
- }, [])
+ const accept = () => post(autoPick())
// Ctrl+Enter accepts the recommended pick (page.ts parity, #440). Only the active gate
// (the first in the rail) binds it, so the shortcut is unambiguous with several gates open.
diff --git a/packages/framework/dashboard/components/CloudAgentNotice.tsx b/packages/framework/dashboard/components/CloudAgentNotice.tsx
index e5752c07b..fbb45b6aa 100644
--- a/packages/framework/dashboard/components/CloudAgentNotice.tsx
+++ b/packages/framework/dashboard/components/CloudAgentNotice.tsx
@@ -116,7 +116,7 @@ function ParkedQuestion({
Sending “{failure.labels.join(', ')}” failed{failure.note ? `: ${failure.note}` : ''}. Pick again, or answer in the session.