) : 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({