diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 388cf0227880..b01a0fa66c18 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' @@ -60,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) } @@ -85,13 +87,63 @@ 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') }) + // 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 && + 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) + await act(async () => toggle().props.onPress()) + 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' }) @@ -422,8 +474,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 3aff7ff1bd80..80476207c04d 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. setClipped(event.nativeEvent.lines.length > 2)} + > + {text} + + ) + if (!clipped && !expanded) { + return ( + + {measure} + + {text} + + + ) + } + const toggle = ( + setExpanded(!expanded)} + > + + {text} + + {expanded ? 'Show less' : 'Show more'} + + ) + // The press sits inside the scroll, so a drag scrolls and only a tap folds it. + return expanded ? ( + + {toggle} + + ) : ( + + {measure} + {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 @@ -269,6 +321,15 @@ const styles = StyleSheet.create({ color: colors.textPrimary, fontSize: typography.bodySize }, + 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/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 0b83b794b055..968421b334bd 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-rejection-words' @@ -28,6 +31,7 @@ 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' import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' @@ -152,120 +156,180 @@ export function NativeChatQueuedMessageCard({ const returned = card.state === 'returned' const sendNow = queuedMessageCardSendNow(card) const isMac = isMacPlatform() + // 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() return (
  • - {returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? ( - - ) : ( - - )} -
    - {card.from ? ( -

    - {queuedCardSenderLine(card.from)} -

    - ) : null} -

    - {card.text} -

    - {caption ? ( -

    - {caption} -

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

    + {queuedCardSenderLine(card.from)} +

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

    + {card.text} +

    + )} + {caption ? ( +

    + {caption} +

    + ) : null} +
    + {expanded || clipped ? ( + setExpanded(!expanded)} + /> ) : null} -
    - {/* Nothing acts on a send still on its way: the host holds no card for it yet. */} - {card.hold === 'sending' ? null : ( - <> - {/* A command never steers: its Send shows only while the agent is idle. */} - {card.waitsForAgent ? null : ( + {/* Nothing acts on a send still on its way: the host holds no card for it yet. */} + {card.hold === 'sending' ? null : ( + <> + {/* A command never steers: its Send shows only while the agent is idle. */} + {card.waitsForAgent ? null : ( + + + + + + + {sendNow.hint} + {showsSteerShortcut ? ( + + ) : null} + + + + )} - - {sendNow.hint} - {showsSteerShortcut ? ( - - ) : null} - + {translate('components.native-chat.queuedMessages.delete', 'Delete')} - )} - - - - - - {translate('components.native-chat.queuedMessages.delete', 'Delete')} - - - - - - - - {/* 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')} - - )} - {onTurnOffQueueing ? ( - - {translate( - 'components.native-chat.queuedMessages.turnOffQueueing', - 'Turn off queueing' + + + + + + {/* 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')} + + )} + {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}
  • ) } + +function QueuedMessageExpandToggle({ + expanded, + controls, + onToggle +}: { + expanded: boolean + /** The opened text element; absent while folded, when no such element exists. */ + controls: string | undefined + 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 99d1e42f007e..28c4d9e2b186 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -524,11 +524,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) }) @@ -666,6 +671,42 @@ 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') + // 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') + 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') + // 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 { + 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) 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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 140dc56c828b..d1d78adb2db3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18627,6 +18627,8 @@ "delete": "Delete", "moreActions": "More actions", "editMessage": "Edit message", + "showFullMessage": "Show full message", + "showLess": "Show less", "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 903faac31db7..1371796c792a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18242,6 +18242,8 @@ "delete": "Eliminar", "moreActions": "Más acciones", "editMessage": "Editar mensaje", + "showFullMessage": "Mostrar el mensaje completo", + "showLess": "Mostrar menos", "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 ad5cfc6b400d..80c4a84d1abc 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18479,6 +18479,8 @@ "delete": "Supprimer", "moreActions": "Plus d'actions", "editMessage": "Modifier le message", + "showFullMessage": "Afficher le message complet", + "showLess": "Afficher moins", "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 3aa840dfaa01..50b3bd7ad2c6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18413,6 +18413,8 @@ "delete": "削除", "moreActions": "その他の操作", "editMessage": "メッセージを編集", + "showFullMessage": "メッセージ全体を表示", + "showLess": "折りたたむ", "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 43a423f16585..ce2ec5a355a9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18413,6 +18413,8 @@ "delete": "삭제", "moreActions": "추가 작업", "editMessage": "메시지 편집", + "showFullMessage": "전체 메시지 보기", + "showLess": "접기", "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9057858199aa..935dcb326a99 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18378,6 +18378,8 @@ "delete": "删除", "moreActions": "更多操作", "editMessage": "编辑消息", + "showFullMessage": "显示完整消息", + "showLess": "收起", "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。",