mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(native-chat): read a queued message in full (#26004)
* 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. * fix(native-chat): keep the full-text hover title on a folded queued card * 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. * fix(mobile): the unseen measuring copy of a queued card takes no touches * 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. * fix(native-chat): the queued card's toggle names the opened text only while it exists * refactor(native-chat): the queue reads pending prompts itself, keeping the session hook under its line cap * refactor(native-chat): keep this PR to reading a queued message in full; Edit moves to in-place editing * test(native-chat): queue list rig passes the pending-prompt flag main's controller takes
This commit is contained in:
@@ -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<void> {
|
||||
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',
|
||||
|
||||
@@ -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}
|
||||
</Text>
|
||||
) : null}
|
||||
{/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */}
|
||||
<Text style={styles.body} numberOfLines={2}>
|
||||
{card.text}
|
||||
</Text>
|
||||
<MobileQueuedCardText text={card.text} />
|
||||
{card.caption ? (
|
||||
// A returned card's reason only reads whole, often at its end; a hold is one line.
|
||||
<Text
|
||||
@@ -227,6 +224,61 @@ export function MobileNativeChatQueuedMessages({
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 = (
|
||||
<Text
|
||||
style={[styles.body, styles.measure]}
|
||||
pointerEvents="none"
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
onTextLayout={(event) => setClipped(event.nativeEvent.lines.length > 2)}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
)
|
||||
if (!clipped && !expanded) {
|
||||
return (
|
||||
<View>
|
||||
{measure}
|
||||
<Text style={styles.body} numberOfLines={2}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const toggle = (
|
||||
<Pressable
|
||||
testID="queued-card-text"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
accessibilityHint={expanded ? 'Shows less of the message' : 'Shows the whole message'}
|
||||
onPress={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Text style={styles.body} numberOfLines={expanded ? undefined : 2}>
|
||||
{text}
|
||||
</Text>
|
||||
<Text style={styles.caption}>{expanded ? 'Show less' : 'Show more'}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
// The press sits inside the scroll, so a drag scrolls and only a tap folds it.
|
||||
return expanded ? (
|
||||
<ScrollView style={styles.expandedBody} nestedScrollEnabled>
|
||||
{toggle}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View>
|
||||
{measure}
|
||||
{toggle}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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<ResizeObserver | null>(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
|
||||
|
||||
@@ -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 (
|
||||
<li
|
||||
data-queued-message-id={card.messageId}
|
||||
data-queued-message-state={card.state}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5"
|
||||
className="px-2.5 py-1.5"
|
||||
>
|
||||
{returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? (
|
||||
<AlertCircle className="size-3.5 shrink-0 text-destructive" aria-hidden />
|
||||
) : (
|
||||
<ListEnd className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{card.from ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{queuedCardSenderLine(card.from)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{returned || card.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED ? (
|
||||
<AlertCircle className="size-3.5 shrink-0 text-destructive" aria-hidden />
|
||||
) : (
|
||||
<ListEnd className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{card.from ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{queuedCardSenderLine(card.from)}
|
||||
</p>
|
||||
) : null}
|
||||
{expanded ? null : (
|
||||
<p ref={measureLine} className="truncate text-sm" title={card.text}>
|
||||
{card.text}
|
||||
</p>
|
||||
)}
|
||||
{caption ? (
|
||||
<p
|
||||
className={
|
||||
returned
|
||||
? 'truncate text-xs text-destructive'
|
||||
: 'truncate text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded || clipped ? (
|
||||
<QueuedMessageExpandToggle
|
||||
expanded={expanded}
|
||||
controls={expanded ? textId : undefined}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
/>
|
||||
) : null}
|
||||
<p className="truncate text-sm" title={card.text}>
|
||||
{card.text}
|
||||
</p>
|
||||
{caption ? (
|
||||
<p
|
||||
className={
|
||||
returned
|
||||
? 'truncate text-xs text-destructive'
|
||||
: 'truncate text-xs text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{/* 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 : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onSteer}
|
||||
disabled={steerHeld}
|
||||
>
|
||||
{sendNow.steers ? (
|
||||
<CornerDownRight className="size-3" />
|
||||
) : (
|
||||
<Send className="size-3" />
|
||||
)}
|
||||
{sendNow.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{sendNow.hint}</span>
|
||||
{showsSteerShortcut ? (
|
||||
<ShortcutKeyCombo keys={[isMac ? '⌘' : 'Ctrl', isMac ? '⏎' : 'Enter']} />
|
||||
) : null}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onSteer}
|
||||
disabled={steerHeld}
|
||||
size="icon-xs"
|
||||
aria-label={translate('components.native-chat.queuedMessages.delete', 'Delete')}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{sendNow.steers ? (
|
||||
<CornerDownRight className="size-3" />
|
||||
) : (
|
||||
<Send className="size-3" />
|
||||
)}
|
||||
{sendNow.label}
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{sendNow.hint}</span>
|
||||
{showsSteerShortcut ? (
|
||||
<ShortcutKeyCombo keys={[isMac ? '⌘' : 'Ctrl', isMac ? '⏎' : 'Enter']} />
|
||||
) : null}
|
||||
</span>
|
||||
{translate('components.native-chat.queuedMessages.delete', 'Delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate('components.native-chat.queuedMessages.delete', 'Delete')}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate('components.native-chat.queuedMessages.delete', 'Delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'components.native-chat.queuedMessages.moreActions',
|
||||
'More actions'
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* A command's text is not a draft: edited, it would become a message. */}
|
||||
{card.command ? null : (
|
||||
<DropdownMenuItem onSelect={onEdit}>
|
||||
<Pencil />
|
||||
{translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onTurnOffQueueing ? (
|
||||
<DropdownMenuItem onSelect={onTurnOffQueueing}>
|
||||
{translate(
|
||||
'components.native-chat.queuedMessages.turnOffQueueing',
|
||||
'Turn off queueing'
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'components.native-chat.queuedMessages.moreActions',
|
||||
'More actions'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* A command's text is not a draft: edited, it would become a message. */}
|
||||
{card.command ? null : (
|
||||
<DropdownMenuItem onSelect={onEdit}>
|
||||
<Pencil />
|
||||
{translate('components.native-chat.queuedMessages.editMessage', 'Edit message')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onTurnOffQueueing ? (
|
||||
<DropdownMenuItem onSelect={onTurnOffQueueing}>
|
||||
{translate(
|
||||
'components.native-chat.queuedMessages.turnOffQueueing',
|
||||
'Turn off queueing'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{expanded ? (
|
||||
// Indented to the text, past the icon slot and its gap.
|
||||
<p
|
||||
id={textId}
|
||||
className="scrollbar-sleek mt-1 max-h-60 overflow-y-auto whitespace-pre-wrap break-words pl-5.5 text-sm"
|
||||
>
|
||||
{card.text}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={controls}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ResizeObserver | null>(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]
|
||||
}
|
||||
@@ -18640,6 +18640,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.",
|
||||
|
||||
@@ -18254,6 +18254,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.",
|
||||
|
||||
@@ -18492,6 +18492,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.",
|
||||
|
||||
@@ -18426,6 +18426,8 @@
|
||||
"delete": "削除",
|
||||
"moreActions": "その他の操作",
|
||||
"editMessage": "メッセージを編集",
|
||||
"showFullMessage": "メッセージ全体を表示",
|
||||
"showLess": "折りたたむ",
|
||||
"turnOffQueueing": "キューへの追加をオフにする",
|
||||
"withdrawnHold": "送信前に停止されました",
|
||||
"pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。",
|
||||
|
||||
@@ -18426,6 +18426,8 @@
|
||||
"delete": "삭제",
|
||||
"moreActions": "추가 작업",
|
||||
"editMessage": "메시지 편집",
|
||||
"showFullMessage": "전체 메시지 보기",
|
||||
"showLess": "접기",
|
||||
"turnOffQueueing": "대기열 사용 안 함",
|
||||
"withdrawnHold": "전송 전에 중지됨",
|
||||
"pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.",
|
||||
|
||||
@@ -18391,6 +18391,8 @@
|
||||
"delete": "删除",
|
||||
"moreActions": "更多操作",
|
||||
"editMessage": "编辑消息",
|
||||
"showFullMessage": "显示完整消息",
|
||||
"showLess": "收起",
|
||||
"turnOffQueueing": "关闭排队",
|
||||
"withdrawnHold": "在发送前已停止",
|
||||
"pausedSendFailed": "无法发送。点按发送以重试。",
|
||||
|
||||
Reference in New Issue
Block a user