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:
Brennan Benson
2026-10-08 20:54:59 -07:00
committed by GitHub
parent 1cda323b6a
commit 56cdd678fc
12 changed files with 369 additions and 125 deletions
@@ -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]
}
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -18426,6 +18426,8 @@
"delete": "削除",
"moreActions": "その他の操作",
"editMessage": "メッセージを編集",
"showFullMessage": "メッセージ全体を表示",
"showLess": "折りたたむ",
"turnOffQueueing": "キューへの追加をオフにする",
"withdrawnHold": "送信前に停止されました",
"pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。",
+2
View File
@@ -18426,6 +18426,8 @@
"delete": "삭제",
"moreActions": "추가 작업",
"editMessage": "메시지 편집",
"showFullMessage": "전체 메시지 보기",
"showLess": "접기",
"turnOffQueueing": "대기열 사용 안 함",
"withdrawnHold": "전송 전에 중지됨",
"pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.",
+2
View File
@@ -18391,6 +18391,8 @@
"delete": "删除",
"moreActions": "更多操作",
"editMessage": "编辑消息",
"showFullMessage": "显示完整消息",
"showLess": "收起",
"turnOffQueueing": "关闭排队",
"withdrawnHold": "在发送前已停止",
"pausedSendFailed": "无法发送。点按发送以重试。",