From 065a3d3e51686518b89c67289b666df5286a6deb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:00:11 -0700 Subject: [PATCH 1/9] fix(native-chat): read a queued card's whole text, and keep Edit from hiding it behind a prompt A queued card showed one clipped line, so a long message (often another agent's, never typed by the reader) could not be read before Steer or Delete. A clipped card now opens in place to its whole text; the phone's two-line card opens on tap. Edit moved the card's text into the composer and deleted the card even while a question or approval card stood in the composer's slot, so the text vanished from the page. Edit now waits, saying why, until the prompt is answered; the pane and the queue read one check for whether a prompt holds the composer's slot. --- .../MobileNativeChatQueuedMessages.test.tsx | 25 ++++- .../MobileNativeChatQueuedMessages.tsx | 37 ++++++- .../NativeChatAwaitingInputRow.tsx | 20 +--- .../NativeChatQueuedMessageCard.tsx | 99 +++++++++++++++++-- .../NativeChatQueuedMessageList.test.tsx | 50 ++++++++++ .../NativeChatQueuedMessageList.tsx | 1 + .../NativeChatStructuredSession.tsx | 6 +- .../native-chat/native-chat-composer-slot.ts | 14 +++ .../use-native-chat-clipped-line.ts | 26 +++++ ...nt-session-queued-messages.resume.test.tsx | 1 + ...red-agent-session-queued-messages.test.tsx | 16 ++- ...tructured-agent-session-queued-messages.ts | 23 ++++- .../use-structured-agent-session.ts | 2 + src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/fr.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + 19 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-slot.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-clipped-line.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 5af9040f664b..ca24ab027a08 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -8,6 +8,7 @@ import type { MobileQueuedMessageCard } from './mobile-structured-queued-message vi.mock('react-native', () => ({ Pressable: 'Pressable', + ScrollView: 'ScrollView', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, Text: 'Text', View: 'View' @@ -92,6 +93,24 @@ describe('MobileNativeChatQueuedMessages', () => { expect(nodeTypes(rows[0]!)).not.toContain('AlertCircle') }) + it('a tap on the text opens the whole message, line breaks kept, and a second tap folds it', async () => { + const text = 'You have 1 orchestration message.\nRun `orca orchestration check --run run_e99`' + const mounted = await mount({ cards: [card({ messageId: 'mail', text })] }) + const body = () => + mounted.root.find((node) => String(node.type) === 'Text' && node.props.children === text) + const toggle = () => mounted.root.findByProps({ testID: 'queued-card-text' }) + expect(body().props.numberOfLines).toBe(2) + expect(toggle().props.accessibilityState).toEqual({ expanded: false }) + await act(async () => toggle().props.onPress()) + expect(body().props.numberOfLines).toBeUndefined() + expect(toggle().props.accessibilityState).toEqual({ expanded: true }) + // A long message scrolls inside a capped box rather than pushing the composer away. + const scroll = mounted.root.find((node) => String(node.type) === 'ScrollView') + expect(flatStyle(scroll.props.style).maxHeight).toBeGreaterThan(0) + await act(async () => toggle().props.onPress()) + expect(body().props.numberOfLines).toBe(2) + }) + it('divides rows with hairlines inside one box, the first row undivided', async () => { const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] }) const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) @@ -382,8 +401,12 @@ describe('MobileNativeChatQueuedMessages', () => { onDelete: vi.fn(async () => true), onEdit: vi.fn(async () => true) }) + // The card's text is its own tap target, sized by the text it shows. const buttons = mounted.root.findAll( - (node) => typeof node.type === 'string' && node.props.accessibilityRole === 'button' + (node) => + typeof node.type === 'string' && + node.props.accessibilityRole === 'button' && + node.props.testID !== 'queued-card-text' ) expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual([ 'Resume sending the queued messages', diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index f600781dde51..e8d64f9ed161 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { AlertCircle, CornerDownRight, @@ -128,10 +128,7 @@ export function MobileNativeChatQueuedMessages({ {card.attribution} ) : null} - {/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */} - - {card.text} - + {card.caption ? ( // A returned card's reason only reads whole, often at its end; a hold is one line. setExpanded(!expanded)} + > + + {text} + + + ) + // The press sits inside the scroll, so a drag scrolls and only a tap folds it. + return expanded ? ( + + {toggle} + + ) : ( + toggle + ) +} + // Every action touches as a 44pt target (platform floor) inside its row: Android drops touches // outside the parent, so the row is at least that tall and nothing overhangs it. const MIN_TOUCH_TARGET = 44 @@ -263,6 +287,9 @@ const styles = StyleSheet.create({ color: colors.textPrimary, fontSize: typography.bodySize }, + expandedBody: { + maxHeight: 240 + }, caption: { color: colors.textMuted, fontSize: typography.metaSize diff --git a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx index 9549fc933e73..ecf94cba8afa 100644 --- a/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAwaitingInputRow.tsx @@ -1,4 +1,3 @@ -import { useCallback, useRef, useState } from 'react' import { ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -8,6 +7,7 @@ import { } from '../../../../shared/native-chat-ask-row' import { useNativeChatDisclosure } from './native-chat-disclosure-store' import { NativeChatToolRunIcon } from './NativeChatToolIcon' +import { useNativeChatClippedLine } from './use-native-chat-clipped-line' const ROW_CLASS_NAME = 'flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left text-sm leading-relaxed text-muted-foreground' @@ -50,23 +50,7 @@ export function NativeChatAwaitingInputRow({ ) // Seeded from `open`: a row remounted open was clipped when the reader opened // it, and dropping the toggle as it folds would drop keyboard focus with it. - const [clipped, setClipped] = useState(open) - const observerRef = useRef(null) - // Attached to the question only while it sits on the line; an open row keeps its verdict. - const measureLine = useCallback((line: HTMLSpanElement | null) => { - observerRef.current?.disconnect() - observerRef.current = null - if (!line) { - return - } - const measure = (): void => setClipped(line.scrollWidth > line.clientWidth) - measure() - if (typeof ResizeObserver === 'undefined') { - return - } - observerRef.current = new ResizeObserver(measure) - observerRef.current.observe(line) - }, []) + const [clipped, measureLine] = useNativeChatClippedLine(open) const question = subject?.kind === 'question' ? subject.text : null const questions = subject?.kind === 'questions' && listsQuestions ? subject.questions : null diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index b0c2eded879f..e787432d44dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -1,5 +1,7 @@ +import { useId, useState } from 'react' import { AlertCircle, + ChevronDown, CornerDownRight, ListEnd, MoreHorizontal, @@ -15,6 +17,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { translate } from '@/i18n/i18n' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' @@ -28,6 +31,7 @@ import { import { isMacPlatform } from './native-chat-shortcut' import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' +import { useNativeChatClippedLine } from './use-native-chat-clipped-line' /** The visible caption under the text; the default waiting hold needs none. */ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null { @@ -113,6 +117,26 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { } } +/** Why Edit waits: the prompt card standing in the composer's slot must be answered first. */ +function queuedMessageCardEditHold( + editHeldBy: 'question' | 'approval' | null +): string | null { + switch (editHeldBy) { + case 'question': + return translate( + 'components.native-chat.queuedMessages.editHeldByQuestion', + 'Answer the question to edit' + ) + case 'approval': + return translate( + 'components.native-chat.queuedMessages.editHeldByApproval', + 'Answer the request to edit' + ) + case null: + return null + } +} + export function NativeChatQueuedMessageCard({ card, showsSteerShortcut, @@ -120,6 +144,7 @@ export function NativeChatQueuedMessageCard({ onSteer, onDelete, onEdit, + editHeldBy = null, onTurnOffQueueing }: { card: QueuedMessageCard @@ -130,6 +155,8 @@ export function NativeChatQueuedMessageCard({ onSteer: () => void onDelete: () => void onEdit: () => void + /** A prompt card stands where the composer would take Edit's text; Edit waits for the answer. */ + editHeldBy?: 'question' | 'approval' | null /** Absent when the host does not queue sends, so there is nothing to turn off. */ onTurnOffQueueing?: () => void }): React.JSX.Element { @@ -137,11 +164,17 @@ export function NativeChatQueuedMessageCard({ const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() + // A clipped line opens in place: a card can hold text the person never typed (another agent's + // message), and Steer or Delete must not be a blind choice. + const [expanded, setExpanded] = useState(false) + const [clipped, measureLine] = useNativeChatClippedLine(false) + const textId = useId() + const editHold = queuedMessageCardEditHold(editHeldBy) return (
  • {returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? ( @@ -154,9 +187,18 @@ export function NativeChatQueuedMessageCard({ {queuedCardSenderLine(card.from)}

    ) : null} -

    - {card.text} -

    + {expanded ? ( +

    + {card.text} +

    + ) : ( +

    + {card.text} +

    + )} {caption ? (

    ) : null} + {expanded || clipped ? ( + setExpanded(!expanded)} + /> + ) : null} - + - {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} + + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} + {editHold ? {editHold} : null} + {onTurnOffQueueing ? ( @@ -233,3 +285,38 @@ export function NativeChatQueuedMessageCard({

  • ) } + +function QueuedMessageExpandToggle({ + expanded, + controls, + onToggle +}: { + expanded: boolean + /** The text element it opens and folds. */ + controls: string + onToggle: () => void +}): React.JSX.Element { + const label = expanded + ? translate('components.native-chat.queuedMessages.showLess', 'Show less') + : translate('components.native-chat.queuedMessages.showFullMessage', 'Show full message') + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index e9b60f0da27a..6a29d12424f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -63,6 +63,7 @@ function controller( steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), + editHeldBy: null, steerNewest: vi.fn(() => false) } } @@ -494,6 +495,35 @@ describe('NativeChatQueuedMessageList', () => { ) }) + it('a clipped card opens to its whole text, line breaks kept, and folds back', () => { + // happy-dom lays nothing out: a line wider than its box is what a clipped card measures. + const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(900) + const box = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300) + try { + const text = 'You have 1 orchestration message.\nRun `orca orchestration check --run run_e99`' + renderList(controller([card({ messageId: 'mail', text })])) + const open = screen.getByRole('button', { name: 'Show full message' }) + expect(open.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(open) + const fold = screen.getByRole('button', { name: 'Show less' }) + expect(fold.getAttribute('aria-expanded')).toBe('true') + const whole = document.getElementById(fold.getAttribute('aria-controls') ?? '') + expect(whole?.textContent).toBe(text) + expect(whole?.className).toContain('whitespace-pre-wrap') + expect(whole?.className).not.toContain('truncate') + fireEvent.click(fold) + expect(screen.getByRole('button', { name: 'Show full message' })).toBeTruthy() + } finally { + width.mockRestore() + box.mockRestore() + } + }) + + it('a card whose text fits its line offers no toggle', () => { + renderList(controller([card({ messageId: 'short', text: 'ok' })])) + expect(screen.queryByRole('button', { name: 'Show full message' })).toBeNull() + }) + it('the menu offers Edit message and Turn off queueing', async () => { const owner = controller([card({ messageId: 'draft-1' })]) renderList(owner) @@ -505,6 +535,26 @@ describe('NativeChatQueuedMessageList', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) + // The prompt card stands in the composer's slot: Edit would move the text out of sight. + it.each([ + { editHeldBy: 'question' as const, reason: 'Answer the question to edit' }, + { editHeldBy: 'approval' as const, reason: 'Answer the request to edit' } + ])( + 'Edit waits, saying why, while the $editHeldBy card holds the composer slot', + async ({ editHeldBy, reason }) => { + const owner = { ...controller([card({ messageId: 'draft-1' })]), editHeldBy } + renderList(owner) + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + const item = await screen.findByRole('menuitem', { name: /Edit message/ }) + expect(item.getAttribute('aria-disabled')).toBe('true') + expect(item.textContent).toContain(reason) + fireEvent.click(item) + expect(owner.edit).not.toHaveBeenCalled() + // The card itself stays readable and actionable. + expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy() + } + ) + // A kept message shows as a card even where the host does not queue sends; there the setting // and the chord would do nothing, so neither is offered. it.each([ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 4c236d87284e..b7890f9aba0d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -72,6 +72,7 @@ export function NativeChatQueuedMessageList({ onSteer={() => refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} + editHeldBy={controller.editHeldBy} onTurnOffQueueing={turnOffQueueing} /> ))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index dae1e61a27fb..04c904817395 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -40,6 +40,7 @@ import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' +import { promptHoldingComposerSlot } from './native-chat-composer-slot' export function NativeChatStructuredSession( props: Omit @@ -155,10 +156,9 @@ export function NativeChatStructuredSession( { sessionId: props.sessionId, isVisible: props.isVisible } ) const prompt = controller.prompts[0] ?? null - // Prompts this build cannot answer leave the composer open: a send starts a turn, whose card - // cancel then works. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) - const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally + // The queue's Edit reads the same slot (`editHeldBy`): text never moves into a hidden composer. + const composerShown = promptHoldingComposerSlot(controller.prompts) === null && !readFailedFinally const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null const cancelPrompt = () => { if (controller.turnId && prompt) { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-slot.ts b/src/renderer/src/components/native-chat/native-chat-composer-slot.ts new file mode 100644 index 000000000000..a8b9fe5b6777 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-slot.ts @@ -0,0 +1,14 @@ +import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' +import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' + +/** The prompt card standing in the composer's slot, or null when the composer shows. A prompt this + * build cannot answer leaves the composer open: a send starts a turn, whose card cancel works. */ +export function promptHoldingComposerSlot( + prompts: readonly { body: AgentJournalItemBody }[] +): 'question' | 'approval' | null { + const first = prompts[0] + if (!first || pendingPromptsAllUnanswerableHere(prompts)) { + return null + } + return first.body.kind === 'question' ? 'question' : 'approval' +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-clipped-line.ts b/src/renderer/src/components/native-chat/use-native-chat-clipped-line.ts new file mode 100644 index 000000000000..6f21e0c5c12d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-clipped-line.ts @@ -0,0 +1,26 @@ +import { useCallback, useRef, useState } from 'react' + +/** Whether a one-line `truncate` element cuts its text off, kept current as its width changes. + * Attach the returned ref only while the element sits on its line; an opened disclosure keeps + * the last verdict, so its toggle stays put. */ +export function useNativeChatClippedLine( + initiallyClipped: boolean +): [clipped: boolean, measureLine: (line: HTMLElement | null) => void] { + const [clipped, setClipped] = useState(initiallyClipped) + const observerRef = useRef(null) + const measureLine = useCallback((line: HTMLElement | null) => { + observerRef.current?.disconnect() + observerRef.current = null + if (!line) { + return + } + const measure = (): void => setClipped(line.scrollWidth > line.clientWidth) + measure() + if (typeof ResizeObserver === 'undefined') { + return + } + observerRef.current = new ResizeObserver(measure) + observerRef.current.observe(line) + }, []) + return [clipped, measureLine] +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index fa4ae91b1eae..9f224e167233 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -35,6 +35,7 @@ function renderController() { queuePause: { reason: 'stopped' }, submissions: [], hasPendingPrompt: false, + promptInComposerSlot: null, composerScopeKey: undefined, mutate }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 2c5257725a58..7241cae88a5e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -40,6 +40,7 @@ function createHarness( queuedMessages?: AgentSessionQueuedMessage[] enabled?: boolean composerScopeKey?: string | undefined + promptInComposerSlot?: 'question' | 'approval' | null mutateResult?: (call: MutateCall) => unknown } = {} ) { @@ -54,7 +55,8 @@ function createHarness( queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)], queuePause: null, submissions: [], - hasPendingPrompt: false, + hasPendingPrompt: overrides.promptInComposerSlot != null, + promptInComposerSlot: overrides.promptInComposerSlot ?? null, composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. mutate: mutate as StructuredAgentSessionMutate @@ -179,6 +181,18 @@ describe('queued message actions', () => { expect(harness.mutate).not.toHaveBeenCalled() }) + // A prompt card stands where the composer was: text moved there would vanish from the page. + it.each(['question', 'approval'] as const)( + 'Edit while the %s card holds the composer slot keeps the card and moves no text', + async (promptInComposerSlot) => { + const harness = createHarness({ promptInComposerSlot }) + await act(() => harness.result.current.edit('draft-1')) + expect(readNativeChatDraftCache(SCOPE)).toBe('') + expect(harness.mutate).not.toHaveBeenCalled() + expect(harness.result.current.editHeldBy).toBe(promptInComposerSlot) + } + ) + it('a second press on a card while its action is in flight sends nothing more', async () => { const harness = createHarness() await act(async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 748487986a3e..786f738387d4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -41,6 +41,8 @@ export type StructuredAgentSessionQueuedMessagesController = { remove: (messageId: string) => Promise /** Copy the card's shown text into the composer, then delete the draft. */ edit: (messageId: string) => Promise + /** The prompt card standing in the composer's slot; Edit waits until it is answered. */ + editHeldBy: 'question' | 'approval' | null /** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */ steerNewest: () => boolean } @@ -58,10 +60,20 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] hasPendingPrompt: boolean + /** A prompt card stands in the composer's slot, so there is no composer to take Edit's text. */ + promptInComposerSlot: 'question' | 'approval' | null composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { - const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args + const { + composerScopeKey, + enabled, + hasPendingPrompt, + mutate, + promptInComposerSlot, + queuedMessages, + submissions + } = args const pause = args.queuePause const cards = useMemo( @@ -125,10 +137,10 @@ export function useStructuredAgentSessionQueuedMessages(args: { (messageId: string): Promise => actOnce(messageId, async () => { // The text is copied FIRST, from the card this pane already shows — a local move, - // never a wire payload. Without a composer to hold it, deleting would destroy it, - // so the draft then stays a card. + // never a wire payload. Without a composer to hold it, or with a prompt card hiding + // it, deleting would make the text vanish, so the draft then stays a card. const card = cardsRef.current.find((entry) => entry.messageId === messageId) - if (!card || !composerScopeKey) { + if (!card || !composerScopeKey || promptInComposerSlot) { return } appendNativeChatDraftCache(composerScopeKey, card.text) @@ -151,7 +163,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { } // A failed Delete leaves the card: the text shows in both places, visibly, never lost. }), - [actOnce, composerScopeKey, mutate] + [actOnce, composerScopeKey, mutate, promptInComposerSlot] ) const resumingRef = useRef(false) @@ -195,6 +207,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { steer, remove, edit, + editHeldBy: promptInComposerSlot, steerNewest } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 6b9f13c61324..14caf2f4c459 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -38,6 +38,7 @@ import { structuredAgentSessionStartFailureFacts } from './structured-agent-sess import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { structuredAgentSessionNewSendsQueue } from '../../../../shared/structured-agent-session-outbox-delivery' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' +import { promptHoldingComposerSlot } from './native-chat-composer-slot' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { useStructuredAgentSessionRewind } from './use-native-chat-rewind' import type { NativeChatRewindHost } from './use-native-chat-rewind' @@ -217,6 +218,7 @@ export function useStructuredAgentSession(args: { queuePause: transportState.queuePause, submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, + promptInComposerSlot: promptHoldingComposerSlot(prompts), composerScopeKey, mutate }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ab40a3656bd6..fe18e470ccda 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18341,6 +18341,10 @@ "delete": "Delete", "moreActions": "More actions", "editMessage": "Edit message", + "showFullMessage": "Show full message", + "showLess": "Show less", + "editHeldByQuestion": "Answer the question to edit", + "editHeldByApproval": "Answer the request to edit", "turnOffQueueing": "Turn off queueing", "listLabel": "Queued messages", "alreadySent": "This message was already sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5d9fe87229cc..598802978965 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17962,6 +17962,10 @@ "delete": "Eliminar", "moreActions": "Más acciones", "editMessage": "Editar mensaje", + "showFullMessage": "Mostrar el mensaje completo", + "showLess": "Mostrar menos", + "editHeldByQuestion": "Responde la pregunta para editar", + "editHeldByApproval": "Responde la solicitud para editar", "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f5c657e6d253..7b5254af2c67 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18192,6 +18192,10 @@ "delete": "Supprimer", "moreActions": "Plus d'actions", "editMessage": "Modifier le message", + "showFullMessage": "Afficher le message complet", + "showLess": "Afficher moins", + "editHeldByQuestion": "Répondez à la question pour modifier", + "editHeldByApproval": "Répondez à la demande pour modifier", "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e9f9c9e8c173..1d27c041d63f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18126,6 +18126,10 @@ "delete": "削除", "moreActions": "その他の操作", "editMessage": "メッセージを編集", + "showFullMessage": "メッセージ全体を表示", + "showLess": "折りたたむ", + "editHeldByQuestion": "編集するには質問に回答してください", + "editHeldByApproval": "編集するにはリクエストに応答してください", "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a44ed6c6fd24..e9eb3e301825 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18126,6 +18126,10 @@ "delete": "삭제", "moreActions": "추가 작업", "editMessage": "메시지 편집", + "showFullMessage": "전체 메시지 보기", + "showLess": "접기", + "editHeldByQuestion": "편집하려면 질문에 답하세요", + "editHeldByApproval": "편집하려면 요청에 응답하세요", "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5ca74326af4a..d92fee43fa61 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18091,6 +18091,10 @@ "delete": "删除", "moreActions": "更多操作", "editMessage": "编辑消息", + "showFullMessage": "显示完整消息", + "showLess": "收起", + "editHeldByQuestion": "回答问题后即可编辑", + "editHeldByApproval": "回应请求后即可编辑", "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", From 342e2ae4fdff0ad375dfa4a17548c9d4527188a6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:03:09 -0700 Subject: [PATCH 2/9] fix(native-chat): keep the full-text hover title on a folded queued card --- .../components/native-chat/NativeChatQueuedMessageCard.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index e787432d44dc..54b23ee83a08 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -118,9 +118,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { } /** Why Edit waits: the prompt card standing in the composer's slot must be answered first. */ -function queuedMessageCardEditHold( - editHeldBy: 'question' | 'approval' | null -): string | null { +function queuedMessageCardEditHold(editHeldBy: 'question' | 'approval' | null): string | null { switch (editHeldBy) { case 'question': return translate( @@ -195,7 +193,7 @@ export function NativeChatQueuedMessageCard({ {card.text}

    ) : ( -

    +

    {card.text}

    )} From d6bd4b1463e6b3206efad0226042ec63c863af05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:14:16 -0700 Subject: [PATCH 3/9] fix(native-chat): phone card opens only when it clips; test the composer-slot check The phone card measured nothing and made every card a button; it now lays the text out unseen at its width and offers Show more only past two lines. The Edit reason is no longer dimmed twice, and the shared composer-slot check and the desktop toggle's keyboard reach have their own tests. --- .../MobileNativeChatQueuedMessages.test.tsx | 43 ++++++++++++++++--- .../MobileNativeChatQueuedMessages.tsx | 39 +++++++++++++++-- .../NativeChatQueuedMessageCard.tsx | 2 +- .../NativeChatQueuedMessageList.test.tsx | 5 +++ .../native-chat-composer-slot.test.ts | 40 +++++++++++++++++ 5 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index ca24ab027a08..f4541fd77a0d 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -61,9 +61,10 @@ describe('MobileNativeChatQueuedMessages', () => { return mounted } + /** What a reader sees: the unseen copy that measures the card's text is left out. */ function texts(mounted: ReactTestRenderer): unknown[] { return mounted.root - .findAll((node) => String(node.type) === 'Text') + .findAll((node) => String(node.type) === 'Text' && !node.props.accessibilityElementsHidden) .map((node) => node.props.children) } @@ -86,24 +87,49 @@ describe('MobileNativeChatQueuedMessages', () => { expect(texts(mounted)).toEqual(['next', 'Steer']) // At most two lines: the row has no hover title to read the rest from. expect( - mounted.root.find((node) => String(node.type) === 'Text' && node.props.children === 'next') - .props.numberOfLines + mounted.root.find( + (node) => + String(node.type) === 'Text' && + node.props.children === 'next' && + !node.props.accessibilityElementsHidden + ).props.numberOfLines ).toBe(2) expect(nodeTypes(rows[0]!)).toContain('ListEnd') expect(nodeTypes(rows[0]!)).not.toContain('AlertCircle') }) - it('a tap on the text opens the whole message, line breaks kept, and a second tap folds it', async () => { + // The hidden copy lays the whole text out at the card's width; its line count says if it clips. + async function layOut(mounted: ReactTestRenderer, text: string, lines: number): Promise { + const measure = mounted.root.find( + (node) => + String(node.type) === 'Text' && + node.props.children === text && + typeof node.props.onTextLayout === 'function' + ) + await act(async () => + measure.props.onTextLayout({ nativeEvent: { lines: Array.from({ length: lines }) } }) + ) + } + + it('a clipped card opens to the whole message on tap, line breaks kept, and a second tap folds it', async () => { const text = 'You have 1 orchestration message.\nRun `orca orchestration check --run run_e99`' const mounted = await mount({ cards: [card({ messageId: 'mail', text })] }) + await layOut(mounted, text, 5) const body = () => - mounted.root.find((node) => String(node.type) === 'Text' && node.props.children === text) + mounted.root.find( + (node) => + String(node.type) === 'Text' && + node.props.children === text && + node.props.onTextLayout === undefined + ) const toggle = () => mounted.root.findByProps({ testID: 'queued-card-text' }) expect(body().props.numberOfLines).toBe(2) expect(toggle().props.accessibilityState).toEqual({ expanded: false }) + expect(texts(mounted)).toContain('Show more') await act(async () => toggle().props.onPress()) expect(body().props.numberOfLines).toBeUndefined() expect(toggle().props.accessibilityState).toEqual({ expanded: true }) + expect(texts(mounted)).toContain('Show less') // A long message scrolls inside a capped box rather than pushing the composer away. const scroll = mounted.root.find((node) => String(node.type) === 'ScrollView') expect(flatStyle(scroll.props.style).maxHeight).toBeGreaterThan(0) @@ -111,6 +137,13 @@ describe('MobileNativeChatQueuedMessages', () => { expect(body().props.numberOfLines).toBe(2) }) + it('a card whose text fits its two lines is plain text, not a button', async () => { + const mounted = await mount({ cards: [card({ messageId: 'short', text: 'ok' })] }) + await layOut(mounted, 'ok', 1) + expect(mounted.root.findAllByProps({ testID: 'queued-card-text' })).toHaveLength(0) + expect(texts(mounted)).not.toContain('Show more') + }) + it('divides rows with hairlines inside one box, the first row undivided', async () => { const mounted = await mount({ cards: [card({ messageId: 'a' }), card({ messageId: 'b' })] }) const rows = mounted.root.findAllByProps({ testID: 'queued-card-row' }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index e8d64f9ed161..413208af64d9 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -218,10 +218,33 @@ export function MobileNativeChatQueuedMessages({ ) } -/** Two lines until tapped: a card can hold another agent's message the person never read, and - * Steer or Delete must not be a blind choice. Opened, a long one scrolls in a capped box. */ +/** Two lines, opening on tap when it clips: a card can hold another agent's message the person + * never read, and Steer or Delete must not be a blind choice. Opened, a long one scrolls in a + * capped box. */ function MobileQueuedCardText({ text }: { text: string }): React.JSX.Element { const [expanded, setExpanded] = useState(false) + const [clipped, setClipped] = useState(false) + // The whole text laid out unseen at the card's width: more than two lines is what clips. + const measure = ( + setClipped(event.nativeEvent.lines.length > 2)} + > + {text} + + ) + if (!clipped && !expanded) { + return ( + + {measure} + + {text} + + + ) + } const toggle = ( {text}
    + {expanded ? 'Show less' : 'Show more'} ) // The press sits inside the scroll, so a drag scrolls and only a tap folds it. @@ -241,7 +265,10 @@ function MobileQueuedCardText({ text }: { text: string }): React.JSX.Element { {toggle} ) : ( - toggle + + {measure} + {toggle} + ) } @@ -290,6 +317,12 @@ const styles = StyleSheet.create({ expandedBody: { maxHeight: 240 }, + measure: { + position: 'absolute', + left: 0, + right: 0, + opacity: 0 + }, caption: { color: colors.textMuted, fontSize: typography.metaSize diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 54b23ee83a08..b3588cece6ca 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -267,7 +267,7 @@ export function NativeChatQueuedMessageCard({ {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} - {editHold ? {editHold} : null} + {editHold ? {editHold} : null} {onTurnOffQueueing ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 6a29d12424f3..088100c6bc97 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -504,6 +504,11 @@ describe('NativeChatQueuedMessageList', () => { renderList(controller([card({ messageId: 'mail', text })])) const open = screen.getByRole('button', { name: 'Show full message' }) expect(open.getAttribute('aria-expanded')).toBe('false') + // A native button in the tab order: Enter or Space opens it from the keyboard. + expect(open.tagName).toBe('BUTTON') + expect(open.getAttribute('tabindex')).not.toBe('-1') + open.focus() + expect(document.activeElement).toBe(open) fireEvent.click(open) const fold = screen.getByRole('button', { name: 'Show less' }) expect(fold.getAttribute('aria-expanded')).toBe('true') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts new file mode 100644 index 000000000000..b2dd2573b24b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' +import { promptHoldingComposerSlot } from './native-chat-composer-slot' + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +const QUESTION: { body: AgentJournalItemBody } = { + body: { kind: 'question', question: 'Which branch?', options: [], resolution: PENDING } +} +const APPROVAL: { body: AgentJournalItemBody } = { + body: { kind: 'approval', title: 'Run it?', detail: null, options: [], resolution: PENDING } +} +// A subject kind a newer Orca wrote: this build's types cannot name it, so it arrives as JSON does. +const NEWER_APPROVAL: { body: AgentJournalItemBody } = JSON.parse( + '{"body":{"kind":"approval","title":"Review change","detail":null,"subject":{"kind":"diff","path":"a.ts"},"options":[],"resolution":{"state":"pending","selectedOptionId":null,"resolvedBy":null,"resolvedAt":null}}}' +) + +describe('promptHoldingComposerSlot', () => { + it('leaves the composer shown with no prompt', () => { + expect(promptHoldingComposerSlot([])).toBeNull() + }) + + it('names the first prompt this build can answer, which stands in the composer slot', () => { + expect(promptHoldingComposerSlot([QUESTION])).toBe('question') + expect(promptHoldingComposerSlot([APPROVAL, QUESTION])).toBe('approval') + }) + + it('leaves the composer shown when no prompt can be answered here', () => { + expect(promptHoldingComposerSlot([NEWER_APPROVAL])).toBeNull() + }) + + it('holds the slot once any prompt can be answered here', () => { + expect(promptHoldingComposerSlot([NEWER_APPROVAL, QUESTION])).not.toBeNull() + }) +}) From 3d48be93736185a18a2cf0aa6aaa955e14268764 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:19:17 -0700 Subject: [PATCH 4/9] fix(mobile): the unseen measuring copy of a queued card takes no touches --- mobile/src/session/MobileNativeChatQueuedMessages.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 413208af64d9..e231ae723eab 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -228,6 +228,7 @@ function MobileQueuedCardText({ text }: { text: string }): React.JSX.Element { const measure = ( setClipped(event.nativeEvent.lines.length > 2)} From cf2272398e469461724c57eebb725a5c7c42fdb4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:44:38 -0700 Subject: [PATCH 5/9] fix(native-chat): open a queued card's full text below its row, at the card's width In a narrow chat the opened text sat beside the card's actions and wrapped a few characters per line. It now opens under the row, indented to the text, the way the transcript's awaiting-input row opens its question. --- .../NativeChatQueuedMessageCard.tsx | 214 +++++++++--------- .../NativeChatQueuedMessageList.test.tsx | 11 +- 2 files changed, 120 insertions(+), 105 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index b3588cece6ca..9268425aa7ae 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -162,8 +162,8 @@ export function NativeChatQueuedMessageCard({ const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() - // A clipped line opens in place: a card can hold text the person never typed (another agent's - // message), and Steer or Delete must not be a blind choice. + // A clipped line opens below the row, at the card's full width: a card can hold text the person + // never typed (another agent's message), and Steer or Delete must not be a blind choice. const [expanded, setExpanded] = useState(false) const [clipped, measureLine] = useNativeChatClippedLine(false) const textId = useId() @@ -172,114 +172,122 @@ export function NativeChatQueuedMessageCard({
  • - {returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? ( - - ) : ( - - )} -
    - {card.from ? ( -

    - {queuedCardSenderLine(card.from)} -

    - ) : null} - {expanded ? ( -

    - {card.text} -

    +
    + {returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? ( + ) : ( -

    - {card.text} -

    + )} - {caption ? ( -

    - {caption} -

    +
    + {card.from ? ( +

    + {queuedCardSenderLine(card.from)} +

    + ) : null} + {expanded ? null : ( +

    + {card.text} +

    + )} + {caption ? ( +

    + {caption} +

    + ) : null} +
    + {expanded || clipped ? ( + setExpanded(!expanded)} + /> ) : null} -
    - {expanded || clipped ? ( - setExpanded(!expanded)} - /> - ) : null} - - - - - - - {sendNow.hint} - {showsSteerShortcut ? ( - - ) : null} - - - - - - - - - {translate('components.native-chat.queuedMessages.delete', 'Delete')} - - - - - - - - - - - {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} - {editHold ? {editHold} : null} + + + + + + + {sendNow.hint} + {showsSteerShortcut ? ( + + ) : null} - - {onTurnOffQueueing ? ( - - {translate( - 'components.native-chat.queuedMessages.turnOffQueueing', - 'Turn off queueing' + + + + + + + + {translate('components.native-chat.queuedMessages.delete', 'Delete')} + + + + + + + + + + + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} + {editHold ? {editHold} : null} + - ) : null} - - + {onTurnOffQueueing ? ( + + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + ) : null} + + +
    + {expanded ? ( + // Indented to the text, past the icon slot and its gap. +

    + {card.text} +

    + ) : null}
  • ) } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 088100c6bc97..f06cb32e86cf 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -336,11 +336,16 @@ describe('NativeChatQueuedMessageList', () => { .getByRole('button', { name: 'Steer' }) .querySelector('.lucide-corner-down-right') ).not.toBeNull() - expect(waiting!.firstElementChild?.classList.contains('lucide-list-end')).toBe(true) + // The card's row (its first child) leads with the glyph. + expect( + waiting!.firstElementChild?.firstElementChild?.classList.contains('lucide-list-end') + ).toBe(true) expect( within(failed!).getByRole('button', { name: 'Send' }).querySelector('.lucide-send') ).not.toBeNull() - expect(failed!.firstElementChild?.classList.contains('lucide-circle-alert')).toBe(true) + expect( + failed!.firstElementChild?.firstElementChild?.classList.contains('lucide-circle-alert') + ).toBe(true) expect(container.querySelectorAll('.lucide-list-end')).toHaveLength(1) }) @@ -516,6 +521,8 @@ describe('NativeChatQueuedMessageList', () => { expect(whole?.textContent).toBe(text) expect(whole?.className).toContain('whitespace-pre-wrap') expect(whole?.className).not.toContain('truncate') + // Opened below the row, at the card's width, not squeezed beside its actions. + expect(whole?.parentElement?.tagName).toBe('LI') fireEvent.click(fold) expect(screen.getByRole('button', { name: 'Show full message' })).toBeTruthy() } finally { From 774f6699324e367b1c7cf172fb91f846f02612ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:04:19 -0700 Subject: [PATCH 6/9] fix(native-chat): the queued card's toggle names the opened text only while it exists --- .../components/native-chat/NativeChatQueuedMessageCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 9268425aa7ae..8a33661741e9 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -206,7 +206,7 @@ export function NativeChatQueuedMessageCard({ {expanded || clipped ? ( setExpanded(!expanded)} /> ) : null} @@ -298,8 +298,8 @@ function QueuedMessageExpandToggle({ onToggle }: { expanded: boolean - /** The text element it opens and folds. */ - controls: string + /** The opened text element; absent while folded, when no such element exists. */ + controls: string | undefined onToggle: () => void }): React.JSX.Element { const label = expanded From ead988026a28aac992df8d58f3d52eda05e6b4c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:07:37 -0700 Subject: [PATCH 7/9] refactor(native-chat): the queue reads pending prompts itself, keeping the session hook under its line cap --- ...nt-session-queued-messages.resume.test.tsx | 3 +-- ...red-agent-session-queued-messages.test.tsx | 19 +++++++++++++-- ...tructured-agent-session-queued-messages.ts | 24 +++++++++---------- .../use-structured-agent-session.ts | 4 +--- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 9f224e167233..3d199f9d033c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -34,8 +34,7 @@ function renderController() { queuedMessages: [], queuePause: { reason: 'stopped' }, submissions: [], - hasPendingPrompt: false, - promptInComposerSlot: null, + prompts: [], composerScopeKey: undefined, mutate }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 7241cae88a5e..eb59d9396c78 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -6,6 +6,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -35,6 +36,21 @@ function draft(id: string, position: number): AgentSessionQueuedMessage { const SCOPE = 'tab-1:pane-scope' +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +/** A prompt of each kind this build answers, as the journal holds it while it waits. */ +const PROMPTS: Record<'question' | 'approval', { body: AgentJournalItemBody }> = { + question: { body: { kind: 'question', question: 'Which?', options: [], resolution: PENDING } }, + approval: { + body: { kind: 'approval', title: 'Run it?', detail: null, options: [], resolution: PENDING } + } +} + function createHarness( overrides: { queuedMessages?: AgentSessionQueuedMessage[] @@ -55,8 +71,7 @@ function createHarness( queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)], queuePause: null, submissions: [], - hasPendingPrompt: overrides.promptInComposerSlot != null, - promptInComposerSlot: overrides.promptInComposerSlot ?? null, + prompts: overrides.promptInComposerSlot ? [PROMPTS[overrides.promptInComposerSlot]] : [], composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. mutate: mutate as StructuredAgentSessionMutate diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 786f738387d4..89873557b305 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -7,7 +7,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, @@ -16,6 +19,7 @@ import type { AgentSessionSendResult } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' +import { promptHoldingComposerSlot } from './native-chat-composer-slot' import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store' import { newestSteerableQueuedMessageCard, @@ -59,21 +63,15 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuedMessages: readonly AgentSessionQueuedMessage[] | null queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] - hasPendingPrompt: boolean - /** A prompt card stands in the composer's slot, so there is no composer to take Edit's text. */ - promptInComposerSlot: 'question' | 'approval' | null + /** Pending prompts: any holds the cards; one this build can answer also hides the composer. */ + prompts: readonly { body: AgentJournalItemBody }[] composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { - const { - composerScopeKey, - enabled, - hasPendingPrompt, - mutate, - promptInComposerSlot, - queuedMessages, - submissions - } = args + const { composerScopeKey, enabled, mutate, prompts, queuedMessages, submissions } = args + const hasPendingPrompt = prompts.length > 0 + // A prompt card standing in the composer's slot leaves no composer to take Edit's text. + const promptInComposerSlot = promptHoldingComposerSlot(prompts) const pause = args.queuePause const cards = useMemo( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 14caf2f4c459..9071ffb63ca2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -38,7 +38,6 @@ import { structuredAgentSessionStartFailureFacts } from './structured-agent-sess import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { structuredAgentSessionNewSendsQueue } from '../../../../shared/structured-agent-session-outbox-delivery' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' -import { promptHoldingComposerSlot } from './native-chat-composer-slot' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { useStructuredAgentSessionRewind } from './use-native-chat-rewind' import type { NativeChatRewindHost } from './use-native-chat-rewind' @@ -217,8 +216,7 @@ export function useStructuredAgentSession(args: { queuedMessages: transportState.queuedMessages, queuePause: transportState.queuePause, submissions: transportState.submissions, - hasPendingPrompt: prompts.length > 0, - promptInComposerSlot: promptHoldingComposerSlot(prompts), + prompts, composerScopeKey, mutate }) From b64e7d648e06d1fd86aea6190cc3d5a343963c8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:51:35 -0700 Subject: [PATCH 8/9] refactor(native-chat): keep this PR to reading a queued message in full; Edit moves to in-place editing --- .../NativeChatQueuedMessageCard.tsx | 32 +-------------- .../NativeChatQueuedMessageList.test.tsx | 21 ---------- .../NativeChatQueuedMessageList.tsx | 8 +++- .../NativeChatStructuredSession.tsx | 27 ++++++++++--- .../native-chat-composer-slot.test.ts | 40 ------------------- .../native-chat/native-chat-composer-slot.ts | 14 ------- ...native-chat-submission-navigation.test.tsx | 1 - ...ent-session-queued-messages.clear.test.tsx | 2 +- ...nt-session-queued-messages.resume.test.tsx | 15 +------ ...red-agent-session-queued-messages.test.tsx | 31 +------------- ...tructured-agent-session-queued-messages.ts | 25 ++++-------- .../use-structured-agent-session.ts | 2 +- ...ructured-native-chat-submit-reveal.test.ts | 1 - src/renderer/src/i18n/locales/en.json | 2 - src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/fr.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - 19 files changed, 42 insertions(+), 189 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-composer-slot.ts diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index a33a097a6dc9..278921e6bd0d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -118,24 +118,6 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { } } -/** Why Edit waits: the prompt card standing in the composer's slot must be answered first. */ -function queuedMessageCardEditHold(editHeldBy: 'question' | 'approval' | null): string | null { - switch (editHeldBy) { - case 'question': - return translate( - 'components.native-chat.queuedMessages.editHeldByQuestion', - 'Answer the question to edit' - ) - case 'approval': - return translate( - 'components.native-chat.queuedMessages.editHeldByApproval', - 'Answer the request to edit' - ) - case null: - return null - } -} - export function NativeChatQueuedMessageCard({ card, showsSteerShortcut, @@ -143,7 +125,6 @@ export function NativeChatQueuedMessageCard({ onSteer, onDelete, onEdit, - editHeldBy = null, onTurnOffQueueing }: { card: QueuedMessageCard @@ -154,8 +135,6 @@ export function NativeChatQueuedMessageCard({ onSteer: () => void onDelete: () => void onEdit: () => void - /** A prompt card stands where the composer would take Edit's text; Edit waits for the answer. */ - editHeldBy?: 'question' | 'approval' | null /** Absent when the host does not queue sends, so there is nothing to turn off. */ onTurnOffQueueing?: () => void }): React.JSX.Element { @@ -168,7 +147,6 @@ export function NativeChatQueuedMessageCard({ const [expanded, setExpanded] = useState(false) const [clipped, measureLine] = useNativeChatClippedLine(false) const textId = useId() - const editHold = queuedMessageCardEditHold(editHeldBy) return (
  • {/* A command's text is not a draft: edited, it would become a message. */} {card.command ? null : ( - + - - {translate( - 'components.native-chat.queuedMessages.editMessage', - 'Edit message' - )} - {editHold ? {editHold} : null} - + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} )} {onTurnOffQueueing ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 15fe7f2a742d..d1c9d96183a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -70,7 +70,6 @@ function controller( steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - editHeldBy: null, steerNewest: vi.fn(() => false), queueResume: undefined, queueHold: undefined @@ -657,26 +656,6 @@ describe('NativeChatQueuedMessageList', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) - // The prompt card stands in the composer's slot: Edit would move the text out of sight. - it.each([ - { editHeldBy: 'question' as const, reason: 'Answer the question to edit' }, - { editHeldBy: 'approval' as const, reason: 'Answer the request to edit' } - ])( - 'Edit waits, saying why, while the $editHeldBy card holds the composer slot', - async ({ editHeldBy, reason }) => { - const owner = { ...controller([card({ messageId: 'draft-1' })]), editHeldBy } - renderList(owner) - fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) - const item = await screen.findByRole('menuitem', { name: /Edit message/ }) - expect(item.getAttribute('aria-disabled')).toBe('true') - expect(item.textContent).toContain(reason) - fireEvent.click(item) - expect(owner.edit).not.toHaveBeenCalled() - // The card itself stays readable and actionable. - expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy() - } - ) - // A kept message shows as a card even where the host does not queue sends; there the setting // and the chord would do nothing, so neither is offered. it.each([ diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index d5f9068d7b90..f1e5a0a3130d 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -5,6 +5,7 @@ import { useAppStore } from '../../store' import { translate } from '@/i18n/i18n' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' import type { StructuredAgentSessionQueuedMessagesController } from './use-structured-agent-session-queued-messages' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' /** * Host-held drafts stacked between the transcript and the composer — never in @@ -13,10 +14,14 @@ import type { StructuredAgentSessionQueuedMessagesController } from './use-struc */ export function NativeChatQueuedMessageList({ controller, + agentName, + statedFailures, steerHeld = false, focusComposer }: { controller: StructuredAgentSessionQueuedMessagesController + agentName?: string + statedFailures?: readonly AgentSessionFailureFact[] /** The chat reads Stopping: no card steers into the turn a Stop is ending. */ steerHeld?: boolean /** Where focus goes once Steer, Edit or Delete takes the focused card away. */ @@ -80,12 +85,13 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} - editHeldBy={controller.editHeldBy} onTurnOffQueueing={turnOffQueueing} /> ))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 702f4ee53844..89eddaa8e0c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -49,10 +49,12 @@ import { useNativeChatStructuredComposerTransport } from './use-native-chat-stru import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' -import { promptHoldingComposerSlot } from './native-chat-composer-slot' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import { useNativeChatHostOutage } from './use-native-chat-host-outage' import { useNativeChatHostOutageNotice } from './use-native-chat-host-outage-notice' import { useNativeChatAvailabilityNotice } from './use-native-chat-availability-notice' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' export function NativeChatStructuredSession( props: Omit @@ -132,10 +134,20 @@ export function NativeChatStructuredSession( isWorking: controller.isWorking, composer: { clearError: () => reportComposerError(null) } }) + const needsFailureFacts = + submits.queuedMessages.cards.some((card) => card.state === 'returned') || + controller.submissions.some( + (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) + ) + const startFailures = useStructuredAgentSessionStartFailureFacts( + controller.journalItems, + needsFailureFacts + ) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ pending: controller.pending, submissions: controller.submissions, journalItems: controller.journalItems, + startFailures, agentName: agentLabel }) // Nothing reads an unread history, so its pane stays blank beside the Retry line. @@ -161,9 +173,10 @@ export function NativeChatStructuredSession( { sessionId: props.sessionId, isVisible: props.isVisible } ) const prompt = controller.prompts[0] ?? null - // A prompt this build cannot answer holds no slot: the composer stays, and its card takes no focus. - const promptInSlot = promptHoldingComposerSlot(controller.prompts) - const composerShown = promptInSlot === null && !readFailedFinally + // Prompts this build cannot answer leave the composer open: a send starts a turn, whose card + // cancel then works. + const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) + const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null const cancelPrompt = () => { if (prompt && (controller.turnId || props.agent === 'pi')) { @@ -279,6 +292,8 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} @@ -314,7 +329,7 @@ export function NativeChatStructuredSession( } isSubmitting={promptResponse.holds(prompt)} onCancel={cancelPrompt} - shouldFocus={promptInSlot !== null && props.isVisible && props.isFocusedGroup} + shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} /> @@ -326,7 +341,7 @@ export function NativeChatStructuredSession( onAnswer={(response) => void promptResponse.respond(prompt, response)} isSubmitting={promptResponse.holds(prompt)} onCancel={cancelPrompt} - shouldFocus={promptInSlot !== null && props.isVisible && props.isFocusedGroup} + shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} answerInputRef={questionAnswerInputRef} /> ) : null} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts deleted file mode 100644 index b2dd2573b24b..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-composer-slot.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' -import { promptHoldingComposerSlot } from './native-chat-composer-slot' - -const PENDING = { - state: 'pending', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null -} as const - -const QUESTION: { body: AgentJournalItemBody } = { - body: { kind: 'question', question: 'Which branch?', options: [], resolution: PENDING } -} -const APPROVAL: { body: AgentJournalItemBody } = { - body: { kind: 'approval', title: 'Run it?', detail: null, options: [], resolution: PENDING } -} -// A subject kind a newer Orca wrote: this build's types cannot name it, so it arrives as JSON does. -const NEWER_APPROVAL: { body: AgentJournalItemBody } = JSON.parse( - '{"body":{"kind":"approval","title":"Review change","detail":null,"subject":{"kind":"diff","path":"a.ts"},"options":[],"resolution":{"state":"pending","selectedOptionId":null,"resolvedBy":null,"resolvedAt":null}}}' -) - -describe('promptHoldingComposerSlot', () => { - it('leaves the composer shown with no prompt', () => { - expect(promptHoldingComposerSlot([])).toBeNull() - }) - - it('names the first prompt this build can answer, which stands in the composer slot', () => { - expect(promptHoldingComposerSlot([QUESTION])).toBe('question') - expect(promptHoldingComposerSlot([APPROVAL, QUESTION])).toBe('approval') - }) - - it('leaves the composer shown when no prompt can be answered here', () => { - expect(promptHoldingComposerSlot([NEWER_APPROVAL])).toBeNull() - }) - - it('holds the slot once any prompt can be answered here', () => { - expect(promptHoldingComposerSlot([NEWER_APPROVAL, QUESTION])).not.toBeNull() - }) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-slot.ts b/src/renderer/src/components/native-chat/native-chat-composer-slot.ts deleted file mode 100644 index a8b9fe5b6777..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-composer-slot.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' -import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' - -/** The prompt card standing in the composer's slot, or null when the composer shows. A prompt this - * build cannot answer leaves the composer open: a send starts a turn, whose card cancel works. */ -export function promptHoldingComposerSlot( - prompts: readonly { body: AgentJournalItemBody }[] -): 'question' | 'approval' | null { - const first = prompts[0] - if (!first || pendingPromptsAllUnanswerableHere(prompts)) { - return null - } - return first.body.kind === 'question' ? 'question' : 'approval' -} diff --git a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx index 1bc21097ad62..15f16c1be603 100644 --- a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx @@ -404,7 +404,6 @@ it('reveals a structured prompt answer at the press, before the host accepts it' steer: async () => {}, remove: async () => {}, edit: async () => {}, - editHeldBy: null, steerNewest: () => false, queueResume: undefined, queueHold: undefined diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx index 425cea6517dd..ab6d7fd7263c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.clear.test.tsx @@ -47,7 +47,7 @@ function renderController( queuedMessages, queuePause: options.queuePause === undefined ? STOPPED : options.queuePause, submissions: [], - prompts: [], + hasPendingPrompt: false, isWorking: options.isWorking === true, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the controller only awaits mutate; the stub answers Delete's shape. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 6e92bd24540d..37d4ead942e9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -8,7 +8,6 @@ import { useCallback, useRef } from 'react' import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuePause @@ -40,16 +39,6 @@ const RESUMED = { value: { resumed: true } } -/** A question waiting on its answer, as the journal holds it. */ -const PENDING_QUESTION: { body: AgentJournalItemBody } = { - body: { - kind: 'question', - question: 'Which?', - options: [], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } -} - function card( messageId: string, fields: Partial = {} @@ -80,7 +69,7 @@ function renderController(initialProps: ControllerInput = {}) { queuedMessages: input.queuedMessages ?? [card('held')], queuePause: input.queuePause === undefined ? { reason: 'stopped' } : input.queuePause, submissions: [], - prompts: input.hasPendingPrompt ? [PENDING_QUESTION] : [], + hasPendingPrompt: input.hasPendingPrompt ?? false, isWorking: input.isWorking ?? false, composerScopeKey: undefined, mutate @@ -294,7 +283,7 @@ function useQueue(sessionId = 'session-1', fence: number | null = 1) { queuedMessages: [card('held')], queuePause: { reason: 'stopped' }, submissions: [], - prompts: [], + hasPendingPrompt: false, isWorking: false, composerScopeKey: undefined, mutate diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index e58802104700..5d1f642071fc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -6,7 +6,6 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalItemBody } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -36,27 +35,11 @@ function draft(id: string, position: number): AgentSessionQueuedMessage { const SCOPE = 'tab-1:pane-scope' -const PENDING = { - state: 'pending', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null -} as const - -/** A prompt of each kind this build answers, as the journal holds it while it waits. */ -const PROMPTS: Record<'question' | 'approval', { body: AgentJournalItemBody }> = { - question: { body: { kind: 'question', question: 'Which?', options: [], resolution: PENDING } }, - approval: { - body: { kind: 'approval', title: 'Run it?', detail: null, options: [], resolution: PENDING } - } -} - function createHarness( overrides: { queuedMessages?: AgentSessionQueuedMessage[] enabled?: boolean composerScopeKey?: string | undefined - promptInComposerSlot?: 'question' | 'approval' | null mutateResult?: (call: MutateCall) => unknown } = {} ) { @@ -71,7 +54,7 @@ function createHarness( queuedMessages: overrides.queuedMessages ?? [draft('draft-1', 1), draft('draft-2', 2)], queuePause: null, submissions: [], - prompts: overrides.promptInComposerSlot ? [PROMPTS[overrides.promptInComposerSlot]] : [], + hasPendingPrompt: false, isWorking: false, composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. @@ -197,18 +180,6 @@ describe('queued message actions', () => { expect(harness.mutate).not.toHaveBeenCalled() }) - // A prompt card stands where the composer was: text moved there would vanish from the page. - it.each(['question', 'approval'] as const)( - 'Edit while the %s card holds the composer slot keeps the card and moves no text', - async (promptInComposerSlot) => { - const harness = createHarness({ promptInComposerSlot }) - await act(() => harness.result.current.edit('draft-1')) - expect(readNativeChatDraftCache(SCOPE)).toBe('') - expect(harness.mutate).not.toHaveBeenCalled() - expect(harness.result.current.editHeldBy).toBe(promptInComposerSlot) - } - ) - it('a second press on a card while its action is in flight sends nothing more', async () => { const harness = createHarness() await act(async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 0e199d5c01a1..b56d5b0eb916 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -8,10 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import type { - AgentJournalItemBody, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage, AgentSessionQueuedMessageDeleteResult, @@ -20,7 +17,6 @@ import type { AgentSessionSendResult } from '../../../../shared/agent-session-wire' import { appendNativeChatDraftCache } from './native-chat-draft-cache' -import { promptHoldingComposerSlot } from './native-chat-composer-slot' import type { StructuredAgentSessionPendingSend } from './structured-agent-session-pending-sends' import { nativeChatComposerDraftWriteSettled } from './native-chat-composer-draft-store' import { @@ -52,8 +48,6 @@ export type StructuredAgentSessionQueuedMessagesController = { remove: (messageId: string) => Promise /** Copy the card's shown text into the composer, then delete the draft. */ edit: (messageId: string) => Promise - /** The prompt card standing in the composer's slot; Edit waits until it is answered. */ - editHeldBy: 'question' | 'approval' | null /** Cmd/Ctrl+Enter: Send-now the newest card. False when there is none to steer. */ steerNewest: () => boolean /** Present while the header row shows and the queue could send now: the composer offers @@ -84,8 +78,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuedMessages: readonly AgentSessionQueuedMessage[] | null queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] - /** Pending prompts: any holds the cards; one this build can answer also hides the composer. */ - prompts: readonly { body: AgentJournalItemBody }[] + hasPendingPrompt: boolean /** A turn is running, whoever started it, or the queue is about to send its next card. */ isWorking: boolean /** This pane's sends without a host record yet show as sending cards. */ @@ -93,12 +86,9 @@ export function useStructuredAgentSessionQueuedMessages(args: { composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { - const { composerScopeKey, enabled, mutate, prompts, queuedMessages, submissions } = args + const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args const { isWorking, queuePause } = args const sending = args.sending ?? NO_SENDS - const hasPendingPrompt = prompts.length > 0 - // A prompt card standing in the composer's slot leaves no composer to take Edit's text. - const promptInComposerSlot = promptHoldingComposerSlot(prompts) const cards = useMemo( () => [ @@ -183,10 +173,10 @@ export function useStructuredAgentSessionQueuedMessages(args: { (messageId: string): Promise => actOnce(messageId, async () => { // The text is copied FIRST, from the card this pane already shows — a local move, - // never a wire payload. Without a composer to hold it, or with a prompt card hiding - // it, deleting would make the text vanish, so the draft then stays a card. + // never a wire payload. Without a composer to hold it, deleting would destroy it, + // so the draft then stays a card. const card = cardsRef.current.find((entry) => entry.messageId === messageId) - if (!card || card.command || !composerScopeKey || promptInComposerSlot) { + if (!card || card.command || !composerScopeKey) { return } appendNativeChatDraftCache(composerScopeKey, card.text) @@ -209,7 +199,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { } // A failed Delete leaves the card: the text shows in both places, visibly, never lost. }), - [actOnce, composerScopeKey, mutate, promptInComposerSlot] + [actOnce, composerScopeKey, mutate] ) const steerNewest = useCallback((): boolean => { @@ -276,7 +266,6 @@ export function useStructuredAgentSessionQueuedMessages(args: { steer, remove, edit, - editHeldBy: promptInComposerSlot, steerNewest, queueResume, queueHold diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 19b5fa593c30..d21cb5b29a97 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -212,7 +212,7 @@ export function useStructuredAgentSession(args: { // Its published list, pause and submissions; the rest is named below. ...transportState, enabled: queueCapability === 'supported' && transportState.fence !== null, - prompts, + hasPendingPrompt: prompts.length > 0, isWorking, // Hidden from the transcript, a queue send on its way reads as sending among the cards. sending: pending, diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index 556fad4403bb..69767fb79282 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -19,7 +19,6 @@ it('reveals before launch Retry, but waits for a successful queue Resume', async steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), - editHeldBy: null, steerNewest: vi.fn(() => false), queueResume: undefined, queueHold: undefined diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d55a6396addc..fe0727c46eec 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18615,8 +18615,6 @@ "editMessage": "Edit message", "showFullMessage": "Show full message", "showLess": "Show less", - "editHeldByQuestion": "Answer the question to edit", - "editHeldByApproval": "Answer the request to edit", "turnOffQueueing": "Turn off queueing", "listLabel": "Queued messages", "alreadySent": "This message was already sent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2a2f80b4a875..fbc042b21104 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18229,8 +18229,6 @@ "editMessage": "Editar mensaje", "showFullMessage": "Mostrar el mensaje completo", "showLess": "Mostrar menos", - "editHeldByQuestion": "Responde la pregunta para editar", - "editHeldByApproval": "Responde la solicitud para editar", "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d2c09b30d487..465eda79a42e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18466,8 +18466,6 @@ "editMessage": "Modifier le message", "showFullMessage": "Afficher le message complet", "showLess": "Afficher moins", - "editHeldByQuestion": "Répondez à la question pour modifier", - "editHeldByApproval": "Répondez à la demande pour modifier", "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 02f0d91f18c5..aad78226bcd1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18400,8 +18400,6 @@ "editMessage": "メッセージを編集", "showFullMessage": "メッセージ全体を表示", "showLess": "折りたたむ", - "editHeldByQuestion": "編集するには質問に回答してください", - "editHeldByApproval": "編集するにはリクエストに応答してください", "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 65efc3aaea0d..380d6436aab2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18400,8 +18400,6 @@ "editMessage": "메시지 편집", "showFullMessage": "전체 메시지 보기", "showLess": "접기", - "editHeldByQuestion": "편집하려면 질문에 답하세요", - "editHeldByApproval": "편집하려면 요청에 응답하세요", "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e2576d04fd93..9cd9add35725 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18365,8 +18365,6 @@ "editMessage": "编辑消息", "showFullMessage": "显示完整消息", "showLess": "收起", - "editHeldByQuestion": "回答问题后即可编辑", - "editHeldByApproval": "回应请求后即可编辑", "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", From 13181256b9d7a97ef7bd77a21f79e67249c93067 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:13:05 -0700 Subject: [PATCH 9/9] test(native-chat): queue list rig passes the pending-prompt flag main's controller takes --- .../components/native-chat/NativeChatQueuedMessageList.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index c084576fc653..28c4d9e2b186 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -111,7 +111,7 @@ function renderHeldQueue( queuedMessages, queuePause, submissions: [], - prompts: [], + hasPendingPrompt: false, isWorking: false, composerScopeKey: undefined, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list only awaits mutate; its answer is never read.