Improve notice details

This commit is contained in:
Jinjing
2026-11-12 22:45:48 -08:00
parent b0c0377664
commit bcf8a5cef8
46 changed files with 1291 additions and 87 deletions
+7
View File
@@ -303,6 +303,13 @@
"safetyReviewApproved": "{{agent}} hat diese Aktion genehmigt. Details anzeigen.",
"safetyReview": "{{agent}} hat eine Warnung zur Sicherheitsprüfung gemeldet. Details anzeigen.",
"truncatedResponse": "Anbieterantwort gekürzt (ursprüngliche Größe: {{bytes}} Bytes).",
"retrying": "Neuer Versuch. Details anzeigen.",
"retryingCount": "Neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
"rateLimitedRetrying": "Ratenlimit erreicht, neuer Versuch. Details anzeigen.",
"rateLimitedRetryingCount": "Ratenlimit erreicht, neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
"codexConfigurationWarning": "Codex-Konfigurationswarnung. Details anzeigen.",
"codexDeprecationNotice": "Codex-Hinweis auf veraltete Funktion. Details anzeigen.",
"providerResponse": "Anbieterantwort",
"modelFallback": "Claude hat das Modell gewechselt: {{from}} → {{to}}.",
"signIn": "Anmelden",
"signInHint": "Öffnet ein Terminal auf dem Computer, auf dem dieser Chat läuft",
+7
View File
@@ -3158,6 +3158,13 @@
"safetyReviewApproved": "{{agent}} approved this action. View details.",
"safetyReview": "{{agent}} reported a safety review warning. View details.",
"truncatedResponse": "Provider response truncated (original size: {{bytes}} bytes).",
"retrying": "Retrying. View details.",
"retryingCount": "Retrying ({{attempt}} of {{max}}). View details.",
"rateLimitedRetrying": "Rate limited, retrying. View details.",
"rateLimitedRetryingCount": "Rate limited, retrying ({{attempt}} of {{max}}). View details.",
"codexConfigurationWarning": "Codex configuration warning. View details.",
"codexDeprecationNotice": "Codex deprecation notice. View details.",
"providerResponse": "Provider response",
"modelFallback": "Claude switched models: {{from}} → {{to}}.",
"forkedFrom": "Forked from",
"signIn": "Sign in",
+7
View File
@@ -307,6 +307,13 @@
"safetyReviewApproved": "{{agent}} aprobó esta acción. Ver detalles.",
"safetyReview": "{{agent}} notificó una advertencia de revisión de seguridad. Ver detalles.",
"truncatedResponse": "Respuesta del proveedor truncada (tamaño original: {{bytes}} bytes).",
"retrying": "Reintentando. Ver detalles.",
"retryingCount": "Reintentando ({{attempt}} de {{max}}). Ver detalles.",
"rateLimitedRetrying": "Límite de solicitudes alcanzado, reintentando. Ver detalles.",
"rateLimitedRetryingCount": "Límite de solicitudes alcanzado, reintentando ({{attempt}} de {{max}}). Ver detalles.",
"codexConfigurationWarning": "Advertencia de configuración de Codex. Ver detalles.",
"codexDeprecationNotice": "Aviso de obsolescencia de Codex. Ver detalles.",
"providerResponse": "Respuesta del proveedor",
"modelFallback": "Claude cambió de modelo: {{from}} → {{to}}.",
"signIn": "Iniciar sesión",
"signInHint": "Abre una terminal en el ordenador que ejecuta este chat",
+7
View File
@@ -307,6 +307,13 @@
"safetyReviewApproved": "{{agent}} a approuvé cette action. Voir les détails.",
"safetyReview": "{{agent}} a signalé un avertissement de sécurité. Voir les détails.",
"truncatedResponse": "Réponse du fournisseur tronquée (taille d’origine : {{bytes}} octets).",
"retrying": "Nouvelle tentative. Voir les détails.",
"retryingCount": "Nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
"rateLimitedRetrying": "Limite de débit atteinte, nouvelle tentative. Voir les détails.",
"rateLimitedRetryingCount": "Limite de débit atteinte, nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
"codexConfigurationWarning": "Avertissement de configuration Codex. Voir les détails.",
"codexDeprecationNotice": "Avis d’obsolescence Codex. Voir les détails.",
"providerResponse": "Réponse du fournisseur",
"modelFallback": "Claude a changé de modèle : {{from}} → {{to}}.",
"signIn": "Se connecter",
"signInHint": "Ouvre un terminal sur l’ordinateur qui exécute ce chat",
+7
View File
@@ -303,6 +303,13 @@
"safetyReviewApproved": "{{agent}} がこの操作を承認しました。詳細を表示。",
"safetyReview": "{{agent}} が安全性レビューの警告を報告しました。詳細を表示。",
"truncatedResponse": "プロバイダーの応答は切り詰められています(元のサイズ:{{bytes}} バイト)。",
"retrying": "再試行中。詳細を表示。",
"retryingCount": "再試行中({{attempt}}/{{max}})。詳細を表示。",
"rateLimitedRetrying": "レート制限のため再試行中。詳細を表示。",
"rateLimitedRetryingCount": "レート制限のため再試行中({{attempt}}/{{max}})。詳細を表示。",
"codexConfigurationWarning": "Codex の設定に関する警告。詳細を表示。",
"codexDeprecationNotice": "Codex の非推奨に関する通知。詳細を表示。",
"providerResponse": "プロバイダーの応答",
"modelFallback": "Claudeがモデルを切り替えました: {{from}} → {{to}}。",
"signIn": "サインイン",
"signInHint": "このチャットを実行しているコンピューターでターミナルを開きます",
+7
View File
@@ -303,6 +303,13 @@
"safetyReviewApproved": "{{agent}}이(가) 이 작업을 승인했습니다. 세부 정보 보기.",
"safetyReview": "{{agent}}이(가) 안전 검토 경고를 보고했습니다. 세부 정보 보기.",
"truncatedResponse": "공급자 응답이 잘렸습니다(원래 크기: {{bytes}}바이트).",
"retrying": "다시 시도 중. 세부 정보 보기.",
"retryingCount": "다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
"rateLimitedRetrying": "요청 한도 도달, 다시 시도 중. 세부 정보 보기.",
"rateLimitedRetryingCount": "요청 한도 도달, 다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
"codexConfigurationWarning": "Codex 구성 경고. 세부 정보 보기.",
"codexDeprecationNotice": "Codex 지원 중단 알림. 세부 정보 보기.",
"providerResponse": "공급자 응답",
"modelFallback": "Claude가 모델을 변경했습니다: {{from}} → {{to}}.",
"signIn": "로그인",
"signInHint": "이 채팅을 실행 중인 컴퓨터에서 터미널을 엽니다",
+7
View File
@@ -325,6 +325,13 @@
"safetyReviewApproved": "{{agent}} aprovou esta ação. Ver detalhes.",
"safetyReview": "{{agent}} relatou um aviso de revisão de segurança. Ver detalhes.",
"truncatedResponse": "Resposta do provedor truncada (tamanho original: {{bytes}} bytes).",
"retrying": "Tentando novamente. Ver detalhes.",
"retryingCount": "Tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
"rateLimitedRetrying": "Limite de requisições atingido, tentando novamente. Ver detalhes.",
"rateLimitedRetryingCount": "Limite de requisições atingido, tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
"codexConfigurationWarning": "Aviso de configuração do Codex. Ver detalhes.",
"codexDeprecationNotice": "Aviso de descontinuação do Codex. Ver detalhes.",
"providerResponse": "Resposta do provedor",
"modelFallback": "Claude mudou de modelo: {{from}} → {{to}}.",
"signIn": "Entrar",
"signInHint": "Abre um terminal no computador que executa este chat",
+7
View File
@@ -311,6 +311,13 @@
"safetyReviewApproved": "{{agent}} одобрил это действие. Подробнее.",
"safetyReview": "{{agent}} сообщил о предупреждении проверки безопасности. Подробнее.",
"truncatedResponse": "Ответ поставщика обрезан (исходный размер: {{bytes}} байт).",
"retrying": "Повторная попытка. Подробнее.",
"retryingCount": "Повторная попытка ({{attempt}} из {{max}}). Подробнее.",
"rateLimitedRetrying": "Превышен лимит запросов, повторная попытка. Подробнее.",
"rateLimitedRetryingCount": "Превышен лимит запросов, повторная попытка ({{attempt}} из {{max}}). Подробнее.",
"codexConfigurationWarning": "Предупреждение о конфигурации Codex. Подробнее.",
"codexDeprecationNotice": "Уведомление Codex об устаревшей функции. Подробнее.",
"providerResponse": "Ответ поставщика",
"modelFallback": "Claude переключил модель: {{from}} → {{to}}.",
"signIn": "Войти",
"signInHint": "Открывает терминал на компьютере с этим чатом",
+7
View File
@@ -303,6 +303,13 @@
"safetyReviewApproved": "{{agent}} bu işleme onay verdi. Ayrıntıları görüntüleyin.",
"safetyReview": "{{agent}} bir güvenlik incelemesi uyarısı bildirdi. Ayrıntıları görüntüleyin.",
"truncatedResponse": "Sağlayıcı yanıtı kısaltıldı (özgün boyut: {{bytes}} bayt).",
"retrying": "Yeniden deneniyor. Ayrıntıları görüntüleyin.",
"retryingCount": "Yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
"rateLimitedRetrying": "İstek sınırına ulaşıldı, yeniden deneniyor. Ayrıntıları görüntüleyin.",
"rateLimitedRetryingCount": "İstek sınırına ulaşıldı, yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
"codexConfigurationWarning": "Codex yapılandırma uyarısı. Ayrıntıları görüntüleyin.",
"codexDeprecationNotice": "Codex kullanımdan kaldırma bildirimi. Ayrıntıları görüntüleyin.",
"providerResponse": "Sağlayıcı yanıtı",
"modelFallback": "Claude model değiştirdi: {{from}} → {{to}}.",
"signIn": "Oturum aç",
"signInHint": "Bu sohbeti çalıştıran bilgisayarda bir terminal açar",
+7
View File
@@ -303,6 +303,13 @@
"safetyReviewApproved": "{{agent}} 已核准此操作。查看詳細資訊。",
"safetyReview": "{{agent}} 回報了安全審查警告。查看詳細資訊。",
"truncatedResponse": "供應商回應已截斷(原始大小:{{bytes}} 位元組)。",
"retrying": "正在重試。查看詳細資訊。",
"retryingCount": "正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
"rateLimitedRetrying": "已達速率限制,正在重試。查看詳細資訊。",
"rateLimitedRetryingCount": "已達速率限制,正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
"codexConfigurationWarning": "Codex 設定警告。查看詳細資訊。",
"codexDeprecationNotice": "Codex 淘汰通知。查看詳細資訊。",
"providerResponse": "供應商回應",
"modelFallback": "Claude 已切換模型:{{from}} → {{to}}。",
"signIn": "登入",
"signInHint": "在執行此聊天的電腦上開啟終端機",
+7
View File
@@ -303,6 +303,13 @@
"safetyReviewApproved": "{{agent}} 已批准此操作。查看详情。",
"safetyReview": "{{agent}} 报告了安全审查警告。查看详情。",
"truncatedResponse": "提供方响应已截断(原始大小:{{bytes}} 字节)。",
"retrying": "正在重试。查看详情。",
"retryingCount": "正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
"rateLimitedRetrying": "已达速率限制,正在重试。查看详情。",
"rateLimitedRetryingCount": "已达速率限制,正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
"codexConfigurationWarning": "Codex 配置警告。查看详情。",
"codexDeprecationNotice": "Codex 弃用通知。查看详情。",
"providerResponse": "提供方响应",
"modelFallback": "Claude 已切换模型:{{from}} → {{to}}。",
"signIn": "登录",
"signInHint": "在运行此聊天的电脑上打开终端",
@@ -0,0 +1,141 @@
import { useId } from 'react'
import { Text, View } from 'react-native'
import { agentSessionFailureDetailText } from '../../../../../src/shared/agent-session/failures/agent-session-failure-quoted-detail'
import { readWholeAgentSessionFailureFact } from '../../../../../src/shared/agent-session/failures/agent-session-failure'
import type { NativeChatNoticeLook } from '../../../../../src/shared/native-chat/native-chat-notice'
import type { NativeChatTextBlock } from '../../../../../src/shared/native-chat/native-chat-types'
import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection'
import type { Translate } from '../../../i18n/mobile-i18n'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { colors, spacing } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store'
import { MONO_SIZE, TEXT_SIZE, scaledType } from './mobile-native-chat-message-styles'
import { toolStyles } from './mobile-native-chat-tool-styles'
import { MobileNativeChatNoticeDisclosure } from './MobileNativeChatNoticeDisclosure'
import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView'
type CollapsedNoticeLook = Extract<NativeChatNoticeLook, { kind: 'collapsed-notice' }>
function collapsedNoticeSummary(look: CollapsedNoticeLook, t: Translate): string {
const counts =
look.attempt && look.maxRetries ? { attempt: look.attempt, max: look.maxRetries } : undefined
switch (look.label) {
case 'retrying':
return counts
? t('chat.notice.retryingCount', 'Retrying ({{attempt}} of {{max}}). View details.', counts)
: t('chat.notice.retrying', 'Retrying. View details.')
case 'rate-limited':
return counts
? t(
'chat.notice.rateLimitedRetryingCount',
'Rate limited, retrying ({{attempt}} of {{max}}). View details.',
counts
)
: t('chat.notice.rateLimitedRetrying', 'Rate limited, retrying. View details.')
case 'configuration':
return t(
'chat.notice.codexConfigurationWarning',
'Codex configuration warning. View details.'
)
case 'deprecation':
return t('chat.notice.codexDeprecationNotice', 'Codex deprecation notice. View details.')
}
}
/** A retry or Codex configuration/deprecation notice: one summary line that opens the host's own text. */
export function MobileNativeChatCollapsedNotice({
notice,
look,
fontScale,
onLongPress,
disclosureKeys
}: {
notice: NativeChatTextBlock
look: CollapsedNoticeLook
fontScale: number
onLongPress?: () => void
/** Where the open choice is kept, newest first. */
disclosureKeys?: readonly string[]
}): React.JSX.Element {
useMobileColorScheme()
const t = useMobileTranslation()
const [open, setOpen] = useMobileNativeChatDisclosure(disclosureKeys?.[0], false, disclosureKeys)
const detailId = useId()
const failureDetail = agentSessionFailureDetailText(
readWholeAgentSessionFailureFact(notice.failure)
)
const detail =
failureDetail && !notice.text.trim().includes(failureDetail.trim()) ? failureDetail : undefined
const payload = notice.providerFrame?.payload
return (
<View>
<MobileNativeChatNoticeDisclosure
summary={collapsedNoticeSummary(look, t)}
expanded={open}
fontScale={fontScale}
onPress={() => setOpen(!open)}
onLongPress={onLongPress}
style={[styles.summary, look.label === 'configuration' && styles.configuration]}
/>
{open ? (
<MobileNativeChatOwnedScrollView
positionKey={detailId}
style={[toolStyles.codeBox, toolStyles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.prose, scaledType(TEXT_SIZE, fontScale)]}
>
{notice.text}
{detail ? `\n\n${detail}` : ''}
</Text>
{payload ? (
<>
<Text style={[styles.caption, scaledType(TEXT_SIZE, fontScale)]}>
{t('chat.notice.providerResponse', 'Provider response')}
</Text>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[toolStyles.codeText, scaledType(MONO_SIZE, fontScale)]}
>
{payload.truncated
? `${t(
'chat.notice.truncatedResponse',
'Provider response truncated (original size: {{bytes}} bytes).',
{ bytes: payload.byteLength }
)}\n`
: ''}
{payload.head}
</Text>
</>
) : null}
</MobileNativeChatOwnedScrollView>
) : null}
</View>
)
}
const styles = themedStyleSheet(() => ({
summary: {
minHeight: 44,
paddingVertical: 0
},
// The rule sits in the gutter so the summary stays aligned with its neighbours.
configuration: {
marginLeft: -(spacing.sm + 2),
borderLeftWidth: 2,
borderLeftColor: colors.statusAmber,
paddingLeft: spacing.sm
},
prose: {
color: colors.textPrimary
},
caption: {
marginTop: spacing.sm,
color: colors.textMuted
}
}))
@@ -0,0 +1,238 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, expect, it, vi } from 'vitest'
import { agentSessionFailureWords } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words'
import type { AgentJournalRenderItem } from '../../../../../src/shared/agent-session/journal/agent-session-journal-types'
import { createStructuredAgentSessionMessageProjection } from '../../../../../src/shared/agent-session/view/structured-agent-session-message-stream'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
React.createElement('Text', props, children)
return {
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
ActivityIndicator: 'ActivityIndicator',
Animated: {
Text,
Value: class {
setValue(): void {}
},
loop: (animation: unknown) => animation,
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
timing: () => ({ start: vi.fn(), stop: vi.fn() })
},
Image: 'Image',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('ScrollView', props, children),
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
useWindowDimensions: () => ({ width: 400, height: 800 })
}
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
Bot: 'Bot',
Brain: 'Brain',
Check: 'Check',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
Copy: 'Copy',
Eye: 'Eye',
FileMinus2: 'FileMinus2',
FilePen: 'FilePen',
FilePlus2: 'FilePlus2',
Folder: 'Folder',
Globe: 'Globe',
ListChecks: 'ListChecks',
MessageSquareMore: 'MessageSquareMore',
Pencil: 'Pencil',
Plug: 'Plug',
Search: 'Search',
SquareChevronRight: 'SquareChevronRight',
SquareTerminal: 'SquareTerminal',
Wrench: 'Wrench'
}))
vi.mock('../../../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
vi.mock('../../../components/MobileSourceCopyButton', () => ({
MobileSourceCopyButton: 'CopyButton'
}))
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
}))
import { MobileNativeChatCollapsedNotice } from './MobileNativeChatCollapsedNotice'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import {
MobileNativeChatDisclosureContext,
useMobileNativeChatDisclosures
} from './mobile-native-chat-disclosure-store'
function item(itemId: string, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
const sequence = Number(itemId.slice(1))
return { itemId, revision: 1, sequence, observedAt: sequence, body }
}
function codexRetry(itemId: string): AgentJournalRenderItem {
const head = `{"error":{"message":"stream disconnected ${itemId}"},"willRetry":true}`
return item(itemId, {
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(
{
kind: 'providerRetrying',
detail: { text: `stream disconnected ${itemId}`, audience: 'person' }
},
{ surface: 'row', agentName: 'Codex' }
),
providerFrame: {
provider: 'codex',
kind: 'notification:error',
payload: { head, byteLength: head.length, digest: 'synthetic', truncated: false }
}
})
}
const claudeRetry = item('c1', {
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(
{
kind: 'providerRetrying',
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
},
{ surface: 'row', agentName: 'Claude' }
)
})
const reply = (itemId: string): AgentJournalRenderItem =>
item(itemId, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Partial answer' }]
})
const project = createStructuredAgentSessionMessageProjection()
function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
const disclosures = useMobileNativeChatDisclosures('chat')
return createElement(
MobileNativeChatDisclosureContext.Provider,
{ value: disclosures },
project(items, [], [])
.filter((message) => message.role === 'system')
.map((message) =>
createElement(
'Row',
{ key: message.id, id: message.id },
createElement(MobileNativeChatMessage, { message, rowKey: message.id })
)
)
)
}
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function draw(items: AgentJournalRenderItem[]): void {
act(() => {
const element = createElement(Transcript, { items })
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
function summary(id: string): ReactTestInstance {
return renderer!.root
.find((node) => String(node.type) === 'Row' && node.props.id === id)
.find((node) => String(node.type) === 'Pressable')
}
function bodyText(id: string): string {
return renderer!.root
.find((node) => String(node.type) === 'Row' && node.props.id === id)
.findAll((node) => String(node.type) === 'ScrollView')
.flatMap((box) => box.findAll((node) => String(node.type) === 'Text'))
.flatMap((node) => node.children.filter((part) => typeof part === 'string'))
.join('')
}
it('keeps an opened retry row open through new attempts, older pages and trims that keep its attempt', () => {
const [r1, r2, r3, r4, r6] = ['r1', 'r2', 'r3', 'r4', 'r6'].map(codexRetry)
draw([r2!, r3!])
expect(summary('r3').props['aria-expanded']).toBe(false)
act(() => summary('r3').props.onPress())
expect(summary('r3').props['aria-expanded']).toBe(true)
draw([r1!, r2!, r3!])
expect(summary('r3').props['aria-expanded']).toBe(true)
draw([r1!, r2!, r3!, r4!])
expect(summary('r4').props['aria-expanded']).toBe(true)
expect(bodyText('r4')).toContain('Codex is retrying: stream disconnected r4.')
draw([r3!, r4!])
expect(summary('r4').props['aria-expanded']).toBe(true)
draw([r3!, r4!, reply('r5'), r6!])
expect(summary('r4').props['aria-expanded']).toBe(true)
expect(summary('r6').props['aria-expanded']).toBe(false)
// The attempt that held the choice is no longer loaded: the documented reset.
draw([r4!])
expect(summary('r4').props['aria-expanded']).toBe(false)
})
it('shows a retry with no provider frame as its own text once opened', () => {
draw([claudeRetry])
act(() => summary('c1').props.onPress())
expect(bodyText('c1')).toBe(
'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 529 overloaded.'
)
})
it.each([
[
' Codex is retrying: connection interrupted. ',
'connection interrupted',
'Codex is retrying: connection interrupted.'
],
['Retry scheduled.', 'connection interrupted', 'Retry scheduled.\n\nconnection interrupted']
])(
'shows distinct person-facing retry detail without repeating embedded detail',
(text, detail, expected) => {
act(() => {
renderer = create(
createElement(MobileNativeChatCollapsedNotice, {
notice: {
type: 'text',
tone: 'warning',
text,
failure: { kind: 'providerRetrying', detail: { text: detail, audience: 'person' } }
},
look: { kind: 'collapsed-notice', label: 'retrying' },
fontScale: 1
})
)
})
act(() => renderer!.root.find((node) => String(node.type) === 'Pressable').props.onPress())
const body = renderer!.root
.find((node) => String(node.type) === 'ScrollView')
.findAll((node) => String(node.type) === 'Text')
.flatMap((node) => node.children.filter((part) => typeof part === 'string'))
.join('')
expect(body.trim()).toBe(expected)
}
)
@@ -6,6 +6,11 @@ import { splitNativeChatBlocks } from '../../../../../src/shared/native-chat/too
import { nativeChatToolRunViewedImages } from '../../../../../src/shared/native-chat/tools/native-chat-tool-viewed-images'
import { selectActiveToolCall } from '../../../../../src/shared/native-chat/tools/native-chat-tool-activity'
import * as Chat from '../../../../../src/shared/native-chat/native-chat-types'
import {
nativeChatNoticeBlock,
nativeChatNoticeLook,
nativeChatNoticeDisclosureKeys
} from '../../../../../src/shared/native-chat/native-chat-notice'
import {
isRenderableSubagentGroup,
withoutSubagentGroupTwins
@@ -287,13 +292,15 @@ function MobileNativeChatMessageImpl({
/>
)
}
const notice = isSystem ? nativeChatNoticeBlock(drawnProse) : undefined
const collapsedNotice = notice && nativeChatNoticeLook(notice).kind === 'collapsed-notice'
const proseRows = (blocks: Chat.NativeChatBlock[]) =>
blocks.map((block, index) => proseRow(block, index, Chat.isTextBlock(block) ? block.text : ''))
return (
<>
{turnStatusAbove ? statusRow : null}
<View
style={[styles.row, isUser && styles.rowUser]}
style={[styles.row, collapsedNotice && styles.collapsedNoticeRow, isUser && styles.rowUser]}
testID={mobileNativeChatRowId(rowKey ?? message.id)}
>
<Content onLongPress={onLongPress} style={[styles.content, isUser && styles.userBubble]}>
@@ -320,6 +327,7 @@ function MobileNativeChatMessageImpl({
<MobileNativeChatNoticeRow
blocks={drawnProse}
fontScale={fontScale}
disclosureKeys={nativeChatNoticeDisclosureKeys(message)}
onLongPress={onLongPress}
/>
) : null}
@@ -1,4 +1,4 @@
import { Pressable, Text, View } from 'react-native'
import { Pressable, Text, View, type StyleProp, type ViewStyle } from 'react-native'
import { ChevronRight } from 'lucide-react-native'
import { colors, pressedStyle, spacing, typography } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
@@ -12,13 +12,15 @@ export function MobileNativeChatNoticeDisclosure({
expanded,
fontScale,
onPress,
onLongPress
onLongPress,
style
}: {
summary: string
expanded: boolean
fontScale: number
onPress: () => void
onLongPress?: () => void
style?: StyleProp<ViewStyle>
}): React.JSX.Element {
return (
<Pressable
@@ -27,7 +29,7 @@ export function MobileNativeChatNoticeDisclosure({
aria-expanded={expanded}
onPress={onPress}
onLongPress={onLongPress}
style={({ pressed }) => [styles.disclosure, pressed && pressedStyle]}
style={({ pressed }) => [styles.disclosure, style, pressed && pressedStyle]}
>
<View style={expanded ? toolStyles.caretOpen : undefined}>
<ChevronRight size={16 * fontScale} color={colors.textMuted} />
@@ -13,31 +13,24 @@ import {
} from '../../../../../src/shared/native-chat/native-chat-notice'
import {
isTextBlock,
type NativeChatBlock,
type NativeChatTextBlock
type NativeChatBlock
} from '../../../../../src/shared/native-chat/native-chat-types'
import {
agentSessionFailureDetailText,
readWholeAgentSessionFailureFact
} from '../../../../../src/shared/agent-session/failures/agent-session-failure'
import { agentSessionFailureSentence } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words'
import { agentSessionSignInCommand } from '../../../../../src/shared/agent-session/lifecycle/agent-session-setup-commands'
import { nativeChatOrcaStopRowText } from '../../../../../src/shared/native-chat/native-chat-orca-stop-words'
import {
MobileNativeChatForkedFromContext,
MobileNativeChatNoticeContext,
type MobileNativeChatNoticeView
MobileNativeChatNoticeContext
} from './mobile-native-chat-notice-context'
import { mobileNativeChatNoticeLineText } from './mobile-native-chat-notice-line-text'
import { MobileNativeChatMarkdown } from './MobileNativeChatMarkdown'
import { INLINE_TEXT_SELECTION } from '../../../components/inline-text-selection'
import { colors, radii, spacing, typography } from '../../../theme/mobile-theme'
import { themedStyleSheet } from '../../../theme/themed-styles'
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
import { nativeChatHostStatusCopy, nativeChatNoticeCopy } from './native-chat-notice-copy'
import { nativeChatOrcaStopSay } from './native-chat-orca-stop-copy'
import { nativeChatUsageLimitText } from './native-chat-usage-limit-copy'
import { sayMobileAgentSessionFailure } from '../agent-session/agent-session-failure-copy'
import type { Translate } from '../../../i18n/mobile-i18n'
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from '../../../../../src/shared/native-chat/native-chat-stopped-before-start'
import { MONO_SIZE, TEXT_SIZE, scaledType } from './mobile-native-chat-message-styles'
@@ -45,61 +38,25 @@ import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollVi
import { toolStyles } from './mobile-native-chat-tool-styles'
import { MobileNativeChatUsageLimitStatus } from './MobileNativeChatUsageLimitStatus'
import { MobileNativeChatNoticeDisclosure } from './MobileNativeChatNoticeDisclosure'
import { MobileNativeChatCollapsedNotice } from './MobileNativeChatCollapsedNotice'
import { sheetPrimaryButtonStyles } from '../../../components/SheetPrimaryButton'
function toneColor(tone: NativeChatNoticeTone): string {
return { warning: colors.statusAmber, error: colors.statusRed, notice: colors.textMuted }[tone]
}
// The phone always reads its Orca host as remote.
function noticeLineText(
notice: NativeChatTextBlock,
names: MobileNativeChatNoticeView,
t: Translate
): string {
if (notice.orcaStop) {
const say = nativeChatOrcaStopSay(t)
return nativeChatOrcaStopRowText(
notice.orcaStop.cause,
names.hostName ?? say('unnamedServer'),
{ continueAvailable: names.continuation?.continueAvailable === true, remoteHost: true },
say
)
}
const failure = readWholeAgentSessionFailureFact(notice.failure)
if (failure?.kind === 'usageLimitReached') {
return nativeChatUsageLimitText(failure.usageLimit, names.agentName, t)
}
// Only reword text fully described by its fact; other host text stays as written.
const context = { agentName: names.agentName }
const surface =
failure &&
[
'savedConversationMissing',
'pluginInputRequired',
'modelUnavailable',
'providerUnavailable',
'providerSafetyStopped',
'providerResponseFailed'
].includes(failure.kind)
? (['row', 'rejection'] as const).find(
(candidate) => notice.text === agentSessionFailureSentence(failure, candidate, context)
)
: undefined
return failure && surface
? agentSessionFailureSentence(failure, surface, context, sayMobileAgentSessionFailure)
: notice.text
}
// Render system notices separately so they cannot read as the agent’s own prose.
export function MobileNativeChatNoticeRow({
blocks,
fontScale,
onLongPress
onLongPress,
disclosureKeys
}: {
blocks: readonly NativeChatBlock[]
fontScale: number
onLongPress?: () => void
/** Where a collapsed notice's open choice is kept, newest first. */
disclosureKeys?: readonly string[]
}): React.JSX.Element | null {
useMobileColorScheme()
const t = useMobileTranslation()
@@ -122,7 +79,7 @@ export function MobileNativeChatNoticeRow({
) : null
}
const look = nativeChatNoticeLook(notice)
const lineText = noticeLineText(notice, names, t)
const lineText = mobileNativeChatNoticeLineText(notice, names, t)
const failure = readWholeAgentSessionFailureFact(notice.failure)
const failureDetail = agentSessionFailureDetailText(failure)
const signInCommand =
@@ -193,6 +150,16 @@ export function MobileNativeChatNoticeRow({
</View>
)
}
case 'collapsed-notice':
return (
<MobileNativeChatCollapsedNotice
notice={notice}
look={look}
fontScale={fontScale}
onLongPress={onLongPress}
disclosureKeys={disclosureKeys}
/>
)
case 'divider': {
const label = copy[look.label]
return (
@@ -77,13 +77,20 @@ export function useMobileNativeChatDisclosures(scopeKey: string): MobileNativeCh
export function useMobileNativeChatDisclosure(
key: string | undefined,
initialOpen: boolean
initialOpen: boolean,
/** Read-only, in order, when `key` holds no choice: a replaced row's keys. */
inheritedKeys?: readonly string[]
): [boolean, (next: boolean) => void] {
const store = useContext(MobileNativeChatDisclosureContext)
const [local, setLocal] = useState({ key, initialOpen, open: initialOpen })
const localOpen =
local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen
const open = key !== undefined && store ? (store.read(key) ?? localOpen) : localOpen
const open =
key !== undefined && store
? (store.read(key) ??
inheritedKeys?.map(store.read).find((choice) => choice !== undefined) ??
localOpen)
: localOpen
const write = store?.write
const setOpen = useCallback(
(next: boolean) => {
@@ -16,6 +16,9 @@ export const styles = themedStyleSheet(() => ({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm
},
collapsedNoticeRow: {
paddingVertical: 0
},
rowUser: {
alignItems: 'flex-end'
},
@@ -0,0 +1,49 @@
import { readWholeAgentSessionFailureFact } from '../../../../../src/shared/agent-session/failures/agent-session-failure'
import { agentSessionFailureSentence } from '../../../../../src/shared/agent-session/failures/agent-session-failure-words'
import { nativeChatOrcaStopRowText } from '../../../../../src/shared/native-chat/native-chat-orca-stop-words'
import type { NativeChatTextBlock } from '../../../../../src/shared/native-chat/native-chat-types'
import type { Translate } from '../../../i18n/mobile-i18n'
import { sayMobileAgentSessionFailure } from '../agent-session/agent-session-failure-copy'
import type { MobileNativeChatNoticeView } from './mobile-native-chat-notice-context'
import { nativeChatOrcaStopSay } from './native-chat-orca-stop-copy'
import { nativeChatUsageLimitText } from './native-chat-usage-limit-copy'
// The phone always reads its Orca host as remote.
export function mobileNativeChatNoticeLineText(
notice: NativeChatTextBlock,
names: MobileNativeChatNoticeView,
t: Translate
): string {
if (notice.orcaStop) {
const say = nativeChatOrcaStopSay(t)
return nativeChatOrcaStopRowText(
notice.orcaStop.cause,
names.hostName ?? say('unnamedServer'),
{ continueAvailable: names.continuation?.continueAvailable === true, remoteHost: true },
say
)
}
const failure = readWholeAgentSessionFailureFact(notice.failure)
if (failure?.kind === 'usageLimitReached') {
return nativeChatUsageLimitText(failure.usageLimit, names.agentName, t)
}
// Only reword text fully described by its fact; other host text stays as written.
const context = { agentName: names.agentName }
const surface =
failure &&
[
'savedConversationMissing',
'pluginInputRequired',
'modelUnavailable',
'providerUnavailable',
'providerSafetyStopped',
'providerResponseFailed'
].includes(failure.kind)
? (['row', 'rejection'] as const).find(
(candidate) => notice.text === agentSessionFailureSentence(failure, candidate, context)
)
: undefined
return failure && surface
? agentSessionFailureSentence(failure, surface, context, sayMobileAgentSessionFailure)
: notice.text
}
@@ -50,18 +50,46 @@ export function NativeChatDetailsDisclosure({
summary,
lead,
trail,
className,
summaryClassName,
bodyClassName,
bodyAs = 'pre',
open,
onOpenChange,
children
}: {
summary: string
lead?: React.ReactNode
trail?: React.ReactNode
className?: string
summaryClassName?: string
bodyClassName?: string
bodyAs?: 'pre' | 'div'
/** Given with `onOpenChange` when the reader's choice is kept outside the row. */
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}): React.JSX.Element {
const Body = bodyAs
return (
<details className="group text-xs text-chat-foreground-faint">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<details
className={cn('group text-xs text-chat-foreground-faint', className)}
open={open}
onToggle={(event) => {
// Mounting already open also fires; only a reader's toggle is a choice.
if (event.currentTarget.open !== open) {
onOpenChange?.(event.currentTarget.open)
}
}}
>
<summary
aria-expanded={open}
title={summary}
className={cn(
'flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
summaryClassName
)}
>
<span className="transition-transform motion-reduce:transition-none group-open:rotate-90">
›
</span>
@@ -69,7 +97,7 @@ export function NativeChatDetailsDisclosure({
<span className="min-w-0 truncate">{summary}</span>
{trail}
</summary>
<pre
<Body
data-native-chat-code-content
className={cn(
'scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-chat-code text-foreground',
@@ -77,7 +105,7 @@ export function NativeChatDetailsDisclosure({
)}
>
{children}
</pre>
</Body>
</details>
)
}
@@ -54,7 +54,9 @@ export type NativeChatDisclosure = {
export function useNativeChatDisclosure(
key: string | undefined,
initialOpen: boolean
initialOpen: boolean,
/** Read-only, in order, when `key` holds no choice: a replaced row's keys. */
inheritedKeys?: readonly string[]
): NativeChatDisclosure {
const store = useContext(NativeChatDisclosureContext)
const [local, setLocal] = useState({ key, initialOpen, open: initialOpen })
@@ -62,7 +64,11 @@ export function useNativeChatDisclosure(
const isStored = key !== undefined && store !== null
const localOpen =
local.key === key && local.initialOpen === initialOpen ? local.open : initialOpen
const open = isStored ? (store.read(key) ?? localOpen) : localOpen
const open = isStored
? (store.read(key) ??
inheritedKeys?.map(store.read).find((choice) => choice !== undefined) ??
localOpen)
: localOpen
const setOpen = useCallback(
(next: boolean) => {
setLocal({ key, initialOpen, open: next })
@@ -0,0 +1,103 @@
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import { agentSessionFailureDetailText } from '../../../../../../shared/agent-session/failures/agent-session-failure-quoted-detail'
import { readWholeAgentSessionFailureFact } from '../../../../../../shared/agent-session/failures/agent-session-failure'
import type { NativeChatNoticeLook } from '../../../../../../shared/native-chat/native-chat-notice'
import type { NativeChatTextBlock } from '../../../../../../shared/native-chat/native-chat-types'
import { NativeChatDetailsDisclosure } from '../diagnostics/NativeChatProviderDetails'
import { useNativeChatDisclosure } from '../disclosure/native-chat-disclosure-store'
type CollapsedNoticeLook = Extract<NativeChatNoticeLook, { kind: 'collapsed-notice' }>
function collapsedNoticeSummary(look: CollapsedNoticeLook, t: Translate): string {
const counts =
look.attempt && look.maxRetries ? { attempt: look.attempt, max: look.maxRetries } : undefined
switch (look.label) {
case 'retrying':
return counts
? t(
'components.native-chat.notices.retryingCount',
'Retrying ({{attempt}} of {{max}}). View details.',
counts
)
: t('components.native-chat.notices.retrying', 'Retrying. View details.')
case 'rate-limited':
return counts
? t(
'components.native-chat.notices.rateLimitedRetryingCount',
'Rate limited, retrying ({{attempt}} of {{max}}). View details.',
counts
)
: t(
'components.native-chat.notices.rateLimitedRetrying',
'Rate limited, retrying. View details.'
)
case 'configuration':
return t(
'components.native-chat.notices.codexConfigurationWarning',
'Codex configuration warning. View details.'
)
case 'deprecation':
return t(
'components.native-chat.notices.codexDeprecationNotice',
'Codex deprecation notice. View details.'
)
}
}
/** A retry or Codex configuration/deprecation notice: one summary line that opens the host's own text. */
export function NativeChatCollapsedNotice({
block,
look,
disclosureKeys
}: {
block: NativeChatTextBlock
look: CollapsedNoticeLook
/** Where the open choice is kept, newest first. */
disclosureKeys?: readonly string[]
}): React.JSX.Element {
const t = useTranslate()
const disclosure = useNativeChatDisclosure(disclosureKeys?.[0], false, disclosureKeys)
const payload = block.providerFrame?.payload
const failureDetail = agentSessionFailureDetailText(
readWholeAgentSessionFailureFact(block.failure)
)
const detail =
failureDetail && !block.text.trim().includes(failureDetail.trim()) ? failureDetail : undefined
return (
<NativeChatDetailsDisclosure
summary={collapsedNoticeSummary(look, t)}
bodyAs="div"
summaryClassName={
look.label === 'configuration' ? '-ml-0.5 border-l-2 border-chat-warning' : undefined
}
// The payload scrolls on its own, so the box around it does not.
bodyClassName={cn(
'font-sans text-sm [overflow-wrap:anywhere]',
payload && 'max-h-none overflow-visible'
)}
open={disclosure.open}
onOpenChange={disclosure.setOpen}
>
{block.text}
{detail ? `\n\n${detail}` : ''}
{payload ? (
<>
<span className="mt-2 block text-xs text-muted-foreground">
{t('components.native-chat.notices.providerResponse', 'Provider response')}
</span>
<span className="scrollbar-sleek mt-1 block max-h-60 overflow-y-auto font-mono text-chat-code">
{payload.truncated
? `${t(
'components.native-chat.notices.truncatedResponse',
'Provider response truncated (original size: {{bytes}} bytes).',
{ bytes: payload.byteLength }
)}\n`
: ''}
{payload.head}
</span>
</>
) : null}
</NativeChatDetailsDisclosure>
)
}
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { agentSessionFailureWords } from '../../../../../../shared/agent-session/failures/agent-session-failure-words'
import type { AgentJournalRenderItem } from '../../../../../../shared/agent-session/journal/agent-session-journal-types'
import { createStructuredAgentSessionMessageProjection } from '../../../../../../shared/agent-session/view/structured-agent-session-message-stream'
import {
nativeChatNoticeBlock,
nativeChatNoticeDisclosureKeys
} from '../../../../../../shared/native-chat/native-chat-notice'
import {
NativeChatDisclosureContext,
useNativeChatDisclosures
} from '../disclosure/native-chat-disclosure-store'
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
import { NativeChatCollapsedNotice } from './NativeChatCollapsedNotice'
afterEach(cleanup)
function item(itemId: string, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
const sequence = Number(itemId.slice(1))
return { itemId, revision: 1, sequence, observedAt: sequence, body }
}
function codexRetry(itemId: string): AgentJournalRenderItem {
const head = `{"error":{"message":"stream disconnected ${itemId}"},"willRetry":true}`
return item(itemId, {
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(
{
kind: 'providerRetrying',
detail: { text: `stream disconnected ${itemId}`, audience: 'person' }
},
{ surface: 'row', agentName: 'Codex' }
),
providerFrame: {
provider: 'codex',
kind: 'notification:error',
payload: { head, byteLength: head.length, digest: 'synthetic', truncated: false }
}
})
}
const reply = (itemId: string): AgentJournalRenderItem =>
item(itemId, {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'Partial answer' }]
})
const project = createStructuredAgentSessionMessageProjection()
function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
const disclosures = useNativeChatDisclosures()
return (
<NativeChatDisclosureContext.Provider value={disclosures}>
{project(items, [], []).map((message) => {
const notice = nativeChatNoticeBlock(message.blocks)
return notice ? (
<div key={message.id} data-row={message.id}>
<NativeChatNoticeRow
block={notice}
messageId={message.id}
disclosureKeys={nativeChatNoticeDisclosureKeys(message)}
/>
</div>
) : null
})}
</NativeChatDisclosureContext.Provider>
)
}
it('keeps an opened retry row open through new attempts, older pages and trims that keep its attempt', () => {
const [r1, r2, r3, r4, r6] = ['r1', 'r2', 'r3', 'r4', 'r6'].map(codexRetry)
const { container, rerender } = render(<Transcript items={[r2, r3]} />)
const row = (id: string) =>
container.querySelector<HTMLDetailsElement>(`[data-row="${id}"] details`)!
expect(row('r3').open).toBe(false)
fireEvent.click(row('r3').querySelector('summary')!)
expect(row('r3').open).toBe(true)
rerender(<Transcript items={[r1!, r2!, r3!]} />)
expect(row('r3').open).toBe(true)
rerender(<Transcript items={[r1!, r2!, r3!, r4!]} />)
expect(row('r4').open).toBe(true)
expect(row('r4')).toHaveTextContent('Codex is retrying: stream disconnected r4.')
rerender(<Transcript items={[r3!, r4!]} />)
expect(row('r4').open).toBe(true)
rerender(<Transcript items={[r3!, r4!, reply('r5'), r6!]} />)
expect(row('r4').open).toBe(true)
expect(row('r6').open).toBe(false)
// The attempt that held the choice is no longer loaded: the documented reset.
rerender(<Transcript items={[r4!]} />)
expect(row('r4').open).toBe(false)
})
it('shows a retry with no provider frame as its own text once opened', () => {
const { container } = render(
<NativeChatNoticeRow
block={{
type: 'text',
tone: 'warning',
text: 'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 529 overloaded.',
failure: {
kind: 'providerRetrying',
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
}
}}
/>
)
const details = container.querySelector('details')!
expect(details.open).toBe(false)
expect(details.querySelector('[data-native-chat-code-content]')).toHaveTextContent(
'Claude hit a temporary problem and is retrying. Retry 2 of 10. Last error: HTTP 529 overloaded.'
)
expect(details.querySelector('[data-native-chat-code-content]')).not.toHaveTextContent(
'api_retry'
)
})
it.each([
[
' Codex is retrying: connection interrupted. ',
'connection interrupted',
'Codex is retrying: connection interrupted.'
],
['Retry scheduled.', 'connection interrupted', 'Retry scheduled.\n\nconnection interrupted']
])(
'shows distinct person-facing retry detail without repeating embedded detail',
(text, detail, expected) => {
const { container } = render(
<NativeChatCollapsedNotice
look={{ kind: 'collapsed-notice', label: 'retrying' }}
block={{
type: 'text',
tone: 'warning',
text,
failure: { kind: 'providerRetrying', detail: { text: detail, audience: 'person' } }
}}
/>
)
expect(container.querySelector('[data-native-chat-code-content]')?.textContent?.trim()).toBe(
expected
)
}
)
@@ -1,3 +1,8 @@
import '../../appearance/native-chat-appearance.css'
import {
NativeChatDisclosureContext,
useNativeChatDisclosures
} from '../disclosure/native-chat-disclosure-store'
import { defineFixture } from '../../../../component-fixtures/component-fixture'
import { i18n } from '../../../../i18n/i18n'
import { agentSessionFailureFact } from '../../../../../../shared/agent-session/failures/agent-session-failure'
@@ -92,3 +97,103 @@ export const guardianReviewsExpanded = defineFixture({
frame.querySelectorAll('summary').forEach((summary) => summary.click())
}
})
function codexFrame(kind: string, head: string, byteLength = head.length) {
return {
provider: 'codex',
kind,
payload: { head, byteLength, digest: 'synthetic', truncated: byteLength > head.length }
}
}
const collapsedNotices: NativeChatTextBlock[] = [
{
type: 'text',
tone: 'warning',
text: 'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 529 overloaded.',
failure: {
kind: 'providerRetrying',
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
}
},
{
type: 'text',
tone: 'warning',
text: 'Codex is retrying: stream disconnected before completion.',
failure: {
kind: 'providerRetrying',
detail: { text: 'stream disconnected before completion', audience: 'person' }
},
providerFrame: codexFrame(
'notification:error',
JSON.stringify({
error: { message: 'stream disconnected before completion', codexErrorInfo: 'other' },
willRetry: true,
threadId: 'synthetic-thread-0000000000000000000000000000000000000000000000000000'
}).repeat(12),
48_213
)
},
{
type: 'text',
tone: 'warning',
text: 'Unknown key `experimental_resume` in config.toml; it will be ignored.',
providerFrame: codexFrame(
'notification:configWarning',
'{"summary":"Unknown key `experimental_resume` in config.toml; it will be ignored."}'
)
},
{
type: 'text',
tone: 'notice',
text: '`approval_policy = "on-failure"` is deprecated and will be removed.',
providerFrame: codexFrame(
'notification:deprecationNotice',
'{"summary":"`approval_policy = \\"on-failure\\"` is deprecated and will be removed."}'
)
}
]
function CollapsedNotices() {
return (
<div className="native-chat-appearance space-y-2 p-4">
{collapsedNotices.map((block) => (
<NativeChatNoticeRow key={block.text} block={block} />
))}
</div>
)
}
export const collapsedNoticesClosed = defineFixture({
width: 720,
render: () => <CollapsedNotices />
})
export const collapsedNoticesOpened = defineFixture({
width: 720,
render: () => <CollapsedNotices />,
interact: (frame) => {
frame.querySelectorAll('summary').forEach((summary) => summary.click())
}
})
function StoredRetryNotice() {
const store = useNativeChatDisclosures()
return (
<NativeChatDisclosureContext.Provider value={store}>
<div className="native-chat-appearance p-4">
<NativeChatNoticeRow
block={collapsedNotices[1]!}
disclosureKeys={['synthetic-retry:notice']}
/>
<output data-disclosure-choice={String(store.read('synthetic-retry:notice'))} />
</div>
</NativeChatDisclosureContext.Provider>
)
}
export const storedRetryNotice = defineFixture({
width: 720,
render: () => <StoredRetryNotice />
})
@@ -41,6 +41,7 @@ import { readWholeAgentSessionFailureFact } from '../../../../../../shared/agent
import { agentSessionFailureSentence } from '../../../../../../shared/agent-session/failures/agent-session-failure-words'
import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text'
import { nativeChatAntigravitySignInContent } from '../../host-access/native-chat-antigravity-settings'
import { NativeChatCollapsedNotice } from './NativeChatCollapsedNotice'
import { formatUsageLimitResetClock } from '../../../../../../shared/agent-session/failures/agent-session-usage-limit'
const HOST_STATUS_WORDS: Record<AgentSessionHostStatusPresentation, () => string> = {
@@ -77,12 +78,15 @@ function toneClass(tone: NativeChatNoticeTone): string {
export function NativeChatNoticeRow({
block,
messageId,
agentName
agentName,
disclosureKeys
}: {
block: NativeChatTextBlock
/** The transcript row's message, so a plan's code blocks keep their wrap past the row's mount. */
messageId?: string
agentName?: string
/** Where a collapsed notice's open choice is kept, newest first. */
disclosureKeys?: readonly string[]
}): React.JSX.Element {
const t = useTranslate()
useTranslation()
@@ -218,6 +222,9 @@ export function NativeChatNoticeRow({
</NativeChatDetailsDisclosure>
)
}
if (look.kind === 'collapsed-notice') {
return <NativeChatCollapsedNotice block={block} look={look} disclosureKeys={disclosureKeys} />
}
// Name Orca and its host as the stop cause; without host context, preserve the original words.
const { orcaStop } = block
const { hostLabel, remoteHost, continueAvailable, wslDistro } = orcaStopView
@@ -11,7 +11,10 @@ import type {
import { deriveNativeChatRowContent } from '../../../../../../shared/native-chat/transcript/native-chat-row-content'
import { NativeChatReasoningRow } from '../reasoning/NativeChatReasoningRow'
import { NativeChatNoticeRow } from '../notices/NativeChatNoticeRow'
import { nativeChatNoticeBlock } from '../../../../../../shared/native-chat/native-chat-notice'
import {
nativeChatNoticeBlock,
nativeChatNoticeDisclosureKeys
} from '../../../../../../shared/native-chat/native-chat-notice'
import { nativeChatMessageRowBlocks } from './native-chat-message-row-words'
import { ProviderFrameRow } from '../diagnostics/NativeChatProviderDetails'
import type { NativeChatDiffReveal } from '../../../../../../shared/native-chat/tools/native-chat-turn-diffs'
@@ -143,7 +146,12 @@ export const MessageRow = memo(function MessageRow(
if (notice) {
return (
<div ref={rowRef}>
<NativeChatNoticeRow block={notice} messageId={message.id} agentName={agentName} />
<NativeChatNoticeRow
block={notice}
messageId={message.id}
agentName={agentName}
disclosureKeys={nativeChatNoticeDisclosureKeys(message)}
/>
</div>
)
}
+7
View File
@@ -19516,6 +19516,13 @@
"safetyReviewApproved": "{{agent}} hat diese Aktion genehmigt. Details anzeigen.",
"safetyReview": "{{agent}} hat eine Warnung zur Sicherheitsprüfung gemeldet. Details anzeigen.",
"truncatedResponse": "Anbieterantwort gekürzt (ursprüngliche Größe: {{bytes}} Bytes).",
"retrying": "Neuer Versuch. Details anzeigen.",
"retryingCount": "Neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
"rateLimitedRetrying": "Ratenlimit erreicht, neuer Versuch. Details anzeigen.",
"rateLimitedRetryingCount": "Ratenlimit erreicht, neuer Versuch ({{attempt}} von {{max}}). Details anzeigen.",
"codexConfigurationWarning": "Codex-Konfigurationswarnung. Details anzeigen.",
"codexDeprecationNotice": "Codex-Hinweis auf veraltete Funktion. Details anzeigen.",
"providerResponse": "Anbieterantwort",
"forkedFrom": "Abgezweigt von"
},
"composer": {
+7
View File
@@ -20649,6 +20649,13 @@
"safetyReviewApproved": "{{agent}} approved this action. View details.",
"safetyReview": "{{agent}} reported a safety review warning. View details.",
"truncatedResponse": "Provider response truncated (original size: {{bytes}} bytes).",
"retrying": "Retrying. View details.",
"retryingCount": "Retrying ({{attempt}} of {{max}}). View details.",
"rateLimitedRetrying": "Rate limited, retrying. View details.",
"rateLimitedRetryingCount": "Rate limited, retrying ({{attempt}} of {{max}}). View details.",
"codexConfigurationWarning": "Codex configuration warning. View details.",
"codexDeprecationNotice": "Codex deprecation notice. View details.",
"providerResponse": "Provider response",
"modelFallback": "Claude switched models: {{from}} → {{to}}.",
"forkedFrom": "Forked from"
},
+7
View File
@@ -19757,6 +19757,13 @@
"safetyReviewApproved": "{{agent}} aprobó esta acción. Ver detalles.",
"safetyReview": "{{agent}} notificó una advertencia de revisión de seguridad. Ver detalles.",
"truncatedResponse": "Respuesta del proveedor truncada (tamaño original: {{bytes}} bytes).",
"retrying": "Reintentando. Ver detalles.",
"retryingCount": "Reintentando ({{attempt}} de {{max}}). Ver detalles.",
"rateLimitedRetrying": "Límite de solicitudes alcanzado, reintentando. Ver detalles.",
"rateLimitedRetryingCount": "Límite de solicitudes alcanzado, reintentando ({{attempt}} de {{max}}). Ver detalles.",
"codexConfigurationWarning": "Advertencia de configuración de Codex. Ver detalles.",
"codexDeprecationNotice": "Aviso de obsolescencia de Codex. Ver detalles.",
"providerResponse": "Respuesta del proveedor",
"forkedFrom": "Rama creada desde"
},
"providerFrame": {
+7
View File
@@ -19857,6 +19857,13 @@
"safetyReviewApproved": "{{agent}} a approuvé cette action. Voir les détails.",
"safetyReview": "{{agent}} a signalé un avertissement de sécurité. Voir les détails.",
"truncatedResponse": "Réponse du fournisseur tronquée (taille d’origine : {{bytes}} octets).",
"retrying": "Nouvelle tentative. Voir les détails.",
"retryingCount": "Nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
"rateLimitedRetrying": "Limite de débit atteinte, nouvelle tentative. Voir les détails.",
"rateLimitedRetryingCount": "Limite de débit atteinte, nouvelle tentative ({{attempt}} sur {{max}}). Voir les détails.",
"codexConfigurationWarning": "Avertissement de configuration Codex. Voir les détails.",
"codexDeprecationNotice": "Avis d’obsolescence Codex. Voir les détails.",
"providerResponse": "Réponse du fournisseur",
"forkedFrom": "Branche créée à partir de"
},
"providerFrame": {
+7
View File
@@ -19117,6 +19117,13 @@
"safetyReviewApproved": "{{agent}} がこの操作を承認しました。詳細を表示。",
"safetyReview": "{{agent}} が安全性レビューの警告を報告しました。詳細を表示。",
"truncatedResponse": "プロバイダーの応答は切り詰められています(元のサイズ:{{bytes}} バイト)。",
"retrying": "再試行中。詳細を表示。",
"retryingCount": "再試行中({{attempt}}/{{max}})。詳細を表示。",
"rateLimitedRetrying": "レート制限のため再試行中。詳細を表示。",
"rateLimitedRetryingCount": "レート制限のため再試行中({{attempt}}/{{max}})。詳細を表示。",
"codexConfigurationWarning": "Codex の設定に関する警告。詳細を表示。",
"codexDeprecationNotice": "Codex の非推奨に関する通知。詳細を表示。",
"providerResponse": "プロバイダーの応答",
"forkedFrom": "分岐元"
},
"providerFrame": {
+7
View File
@@ -19114,6 +19114,13 @@
"safetyReviewApproved": "{{agent}}이(가) 이 작업을 승인했습니다. 세부 정보 보기.",
"safetyReview": "{{agent}}이(가) 안전 검토 경고를 보고했습니다. 세부 정보 보기.",
"truncatedResponse": "공급자 응답이 잘렸습니다(원래 크기: {{bytes}}바이트).",
"retrying": "다시 시도 중. 세부 정보 보기.",
"retryingCount": "다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
"rateLimitedRetrying": "요청 한도 도달, 다시 시도 중. 세부 정보 보기.",
"rateLimitedRetryingCount": "요청 한도 도달, 다시 시도 중({{attempt}}/{{max}}). 세부 정보 보기.",
"codexConfigurationWarning": "Codex 구성 경고. 세부 정보 보기.",
"codexDeprecationNotice": "Codex 지원 중단 알림. 세부 정보 보기.",
"providerResponse": "공급자 응답",
"forkedFrom": "분기 원본"
},
"providerFrame": {
+7
View File
@@ -20712,6 +20712,13 @@
"safetyReviewApproved": "{{agent}} aprovou esta ação. Ver detalhes.",
"safetyReview": "{{agent}} relatou um aviso de revisão de segurança. Ver detalhes.",
"truncatedResponse": "Resposta do provedor truncada (tamanho original: {{bytes}} bytes).",
"retrying": "Tentando novamente. Ver detalhes.",
"retryingCount": "Tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
"rateLimitedRetrying": "Limite de requisições atingido, tentando novamente. Ver detalhes.",
"rateLimitedRetryingCount": "Limite de requisições atingido, tentando novamente ({{attempt}} de {{max}}). Ver detalhes.",
"codexConfigurationWarning": "Aviso de configuração do Codex. Ver detalhes.",
"codexDeprecationNotice": "Aviso de descontinuação do Codex. Ver detalhes.",
"providerResponse": "Resposta do provedor",
"forkedFrom": "Ramificado de",
"modelFallback": "Claude mudou de modelo: {{from}} → {{to}}."
},
+7
View File
@@ -20084,6 +20084,13 @@
"safetyReviewApproved": "{{agent}} одобрил это действие. Подробнее.",
"safetyReview": "{{agent}} сообщил о предупреждении проверки безопасности. Подробнее.",
"truncatedResponse": "Ответ поставщика обрезан (исходный размер: {{bytes}} байт).",
"retrying": "Повторная попытка. Подробнее.",
"retryingCount": "Повторная попытка ({{attempt}} из {{max}}). Подробнее.",
"rateLimitedRetrying": "Превышен лимит запросов, повторная попытка. Подробнее.",
"rateLimitedRetryingCount": "Превышен лимит запросов, повторная попытка ({{attempt}} из {{max}}). Подробнее.",
"codexConfigurationWarning": "Предупреждение о конфигурации Codex. Подробнее.",
"codexDeprecationNotice": "Уведомление Codex об устаревшей функции. Подробнее.",
"providerResponse": "Ответ поставщика",
"modelFallback": "Claude переключил модель: {{from}} → {{to}}.",
"forkedFrom": "Ответвление от"
},
+7
View File
@@ -19494,6 +19494,13 @@
"safetyReviewApproved": "{{agent}} bu işleme onay verdi. Ayrıntıları görüntüleyin.",
"safetyReview": "{{agent}} bir güvenlik incelemesi uyarısı bildirdi. Ayrıntıları görüntüleyin.",
"truncatedResponse": "Sağlayıcı yanıtı kısaltıldı (özgün boyut: {{bytes}} bayt).",
"retrying": "Yeniden deneniyor. Ayrıntıları görüntüleyin.",
"retryingCount": "Yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
"rateLimitedRetrying": "İstek sınırına ulaşıldı, yeniden deneniyor. Ayrıntıları görüntüleyin.",
"rateLimitedRetryingCount": "İstek sınırına ulaşıldı, yeniden deneniyor ({{attempt}}/{{max}}). Ayrıntıları görüntüleyin.",
"codexConfigurationWarning": "Codex yapılandırma uyarısı. Ayrıntıları görüntüleyin.",
"codexDeprecationNotice": "Codex kullanımdan kaldırma bildirimi. Ayrıntıları görüntüleyin.",
"providerResponse": "Sağlayıcı yanıtı",
"forkedFrom": "Çatallandığı kaynak"
},
"composer": {
@@ -19254,6 +19254,13 @@
"safetyReviewApproved": "{{agent}} 已核准此操作。查看詳細資訊。",
"safetyReview": "{{agent}} 回報了安全審查警告。查看詳細資訊。",
"truncatedResponse": "供應商回應已截斷(原始大小:{{bytes}} 位元組)。",
"retrying": "正在重試。查看詳細資訊。",
"retryingCount": "正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
"rateLimitedRetrying": "已達速率限制,正在重試。查看詳細資訊。",
"rateLimitedRetryingCount": "已達速率限制,正在重試(第 {{attempt}} 次,共 {{max}} 次)。查看詳細資訊。",
"codexConfigurationWarning": "Codex 設定警告。查看詳細資訊。",
"codexDeprecationNotice": "Codex 淘汰通知。查看詳細資訊。",
"providerResponse": "供應商回應",
"forkedFrom": "分支來源"
},
"composer": {
+7
View File
@@ -19375,6 +19375,13 @@
"safetyReviewApproved": "{{agent}} 已批准此操作。查看详情。",
"safetyReview": "{{agent}} 报告了安全审查警告。查看详情。",
"truncatedResponse": "提供方响应已截断(原始大小:{{bytes}} 字节)。",
"retrying": "正在重试。查看详情。",
"retryingCount": "正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
"rateLimitedRetrying": "已达速率限制,正在重试。查看详情。",
"rateLimitedRetryingCount": "已达速率限制,正在重试(第 {{attempt}} 次,共 {{max}} 次)。查看详情。",
"codexConfigurationWarning": "Codex 配置警告。查看详情。",
"codexDeprecationNotice": "Codex 弃用通知。查看详情。",
"providerResponse": "提供方响应",
"forkedFrom": "分支来源"
},
"providerFrame": {
@@ -12,6 +12,10 @@ export function withRetryCause(sentence: string, cause: string | undefined): str
return cause ? `${sentence}\n${cause}` : sentence
}
export function isProviderRetryRateLimited(retry: AgentSessionProviderRetry | undefined): boolean {
return retry?.error === 'rate_limit' || retry?.status === 429
}
export function providerRetryWords(
say: AgentSessionFailureSay,
agent: AgentSessionFailureCopyValues,
@@ -19,12 +23,7 @@ export function providerRetryWords(
): string {
return withRetryCause(
joinSentences([
say(
retry?.error === 'rate_limit' || retry?.status === 429
? 'providerRateLimited'
: 'providerRetrying',
agent
),
say(isProviderRetryRateLimited(retry) ? 'providerRateLimited' : 'providerRetrying', agent),
...retryNumber(say, retry)
]),
retry?.cause ?? retryLastError(say, retry)
@@ -8,7 +8,10 @@ import { STRUCTURED_AGENT_SESSION_SUBMISSION_MISSING } from '../requests/structu
import { agentJournalSubmissionKey } from '../journal/agent-session-journal-item-key'
import { selectAgentSessionCurrentRequests } from '../requests/agent-session-current-requests'
import { isQueuedAgentJournalSubmission } from '../requests/agent-session-queued-submission'
import { collapseProviderRetryRuns } from '../../native-chat/native-chat-provider-retry-runs'
import {
collapseProviderRetryRuns,
type NativeChatProviderRetryStamps
} from '../../native-chat/native-chat-provider-retry-runs'
import {
keepStoppedSendsInSendOrder,
placeStoppedSends,
@@ -117,7 +120,8 @@ export function projectStructuredAgentSessionMessageRows(
items: readonly AgentJournalRenderItem[],
optimistic: readonly StructuredAgentSessionOptimisticMessage[],
submissions: readonly AgentJournalSubmission[],
projectItems: StructuredAgentSessionItemProjector
projectItems: StructuredAgentSessionItemProjector,
retryStamps?: NativeChatProviderRetryStamps
): StructuredAgentSessionMessageRows {
// Keep stopped-before-start sends in conversation history; queued handoffs stay in their cards.
const stoppedBeforeStart = new Map(
@@ -198,9 +202,10 @@ export function projectStructuredAgentSessionMessageRows(
if (shownStopped.size > 0) {
moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved
}
const collapsed = collapseProviderRetryRuns(delivered, retryStamps)
const ordered = moved
? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages)
: collapseProviderRetryRuns(delivered)
? Array.from(collapsed).sort(compareNativeChatTranscriptMessages)
: collapsed
const conversation =
shownStopped.size > 0 ? withStopRowsAfterStoppedSends(ordered, shownStopped) : ordered
// Merge unsent rows at journal positions after stop grouping so each run retains one stop notice.
@@ -8,7 +8,10 @@ import {
isAgentSessionContextClear,
isNativeChatContextClear
} from '../agent-session-context-clear'
import { isNativeChatProviderRetryRow } from '../../native-chat/native-chat-provider-retry-runs'
import {
createNativeChatProviderRetryStamps,
isNativeChatProviderRetryRow
} from '../../native-chat/native-chat-provider-retry-runs'
import {
compareNativeChatTranscriptMessages,
sameNativeChatRowPlacement
@@ -171,6 +174,7 @@ export function createStructuredAgentSessionMessageProjection(
projectItems: StructuredAgentSessionItemProjector = projectStructuredItemsToNativeChat
) {
let previous: MessageStream | null = null
const retryStamps = createNativeChatProviderRetryStamps()
return (
items: readonly AgentJournalRenderItem[],
optimistic: readonly StructuredAgentSessionOptimisticMessage[],
@@ -185,7 +189,13 @@ export function createStructuredAgentSessionMessageProjection(
previous =
reused ??
withRowIndex(
projectStructuredAgentSessionMessageRows(items, optimistic, submissions, projectItems),
projectStructuredAgentSessionMessageRows(
items,
optimistic,
submissions,
projectItems,
retryStamps
),
items,
optimistic,
submissions
@@ -35,3 +35,77 @@ it('reads only approved Codex automatic reviews as neutral notices', () => {
})
}
})
const frame = (kind: string): NonNullable<NativeChatTextBlock['providerFrame']> => ({
provider: 'codex',
kind,
payload: { head: '{}', byteLength: 2, digest: 'synthetic', truncated: false }
})
it('collapses only provider retries and Codex configuration or deprecation notices behind a summary', () => {
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'warning',
text: 'Claude hit a temporary problem and is retrying. Retry 2 of 10.',
failure: {
kind: 'providerRetrying',
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
retry: { error: 'overloaded', status: 529, attempt: 2, maxRetries: 10 }
}
})
).toEqual({ kind: 'collapsed-notice', label: 'retrying', attempt: 2, maxRetries: 10 })
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'warning',
text: 'Claude is rate limited.',
failure: { kind: 'providerRetrying', retry: { status: 429, attempt: 1, maxRetries: 10 } }
})
).toEqual({ kind: 'collapsed-notice', label: 'rate-limited', attempt: 1, maxRetries: 10 })
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'warning',
text: 'Codex is retrying: Reconnecting... 2/5.',
failure: {
kind: 'providerRetrying',
detail: { text: 'Reconnecting... 2/5', audience: 'person' }
},
providerFrame: frame('notification:error')
})
).toEqual({ kind: 'collapsed-notice', label: 'retrying' })
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'warning',
text: 'Unknown key in config.toml.',
providerFrame: frame('notification:configWarning')
})
).toEqual({ kind: 'collapsed-notice', label: 'configuration' })
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'notice',
text: 'This option will be removed.',
providerFrame: frame('notification:deprecationNotice')
})
).toEqual({ kind: 'collapsed-notice', label: 'deprecation' })
// Errors and other notifications keep the boxed line.
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'error',
text: 'The configuration could not be read.',
providerFrame: frame('notification:configWarning')
})
).toEqual({ kind: 'line', tone: 'error' })
expect(
nativeChatNoticeLook({
type: 'text',
tone: 'warning',
text: 'Disk is almost full.',
providerFrame: frame('notification:warning')
})
).toEqual({ kind: 'line', tone: 'warning' })
})
+37 -1
View File
@@ -9,7 +9,9 @@ import {
import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../agent-session/lifecycle/agent-session-orca-stop'
import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from './native-chat-stopped-before-start'
import { AGENT_SESSION_FORKED_FROM_PRESENTATION } from '../agent-session/agent-session-fork'
import type { NativeChatBlock, NativeChatTextBlock } from './native-chat-types'
import { readProviderRetry } from '../agent-session/failures/agent-session-failure'
import { isProviderRetryRateLimited } from '../agent-session/failures/agent-session-provider-retry-words'
import type { NativeChatBlock, NativeChatMessage, NativeChatTextBlock } from './native-chat-types'
export const NATIVE_CHAT_NOTICE_COPY = {
compaction: 'Context compacted',
@@ -39,6 +41,13 @@ export type NativeChatNoticeLook =
agent: 'Codex' | 'Claude'
tone: NativeChatNoticeTone
}
/** One summary line that opens the host's own text; counts only when the provider gave both. */
| {
kind: 'collapsed-notice'
label: 'retrying' | 'rate-limited' | 'configuration' | 'deprecation'
attempt?: number
maxRetries?: number
}
/** A boxed line with its icon when it has a tone; plain text when it has none. */
| { kind: 'line'; tone: NativeChatNoticeTone | null }
@@ -93,9 +102,28 @@ export function nativeChatNoticeLook(block: NativeChatTextBlock): NativeChatNoti
if (modelFallback) {
return { kind: 'model-fallback', ...modelFallback }
}
const collapsible = presentation === undefined && !block.orcaStop && block.text.trim() !== ''
if (collapsible && block.failure?.kind === 'providerRetrying') {
const retry = readProviderRetry(block.failure.retry)
return {
kind: 'collapsed-notice',
label: isProviderRetryRateLimited(retry) ? 'rate-limited' : 'retrying',
...(retry?.attempt && retry.maxRetries
? { attempt: retry.attempt, maxRetries: retry.maxRetries }
: {})
}
}
// Declared notification kinds select disclosure; prose and nested failures do not.
if (presentation === undefined && !block.failure && !block.orcaStop) {
const frame = block.providerFrame
if (collapsible && frame?.provider === 'codex' && block.tone !== 'error') {
if (frame.kind === 'notification:configWarning') {
return { kind: 'collapsed-notice', label: 'configuration' }
}
if (frame.kind === 'notification:deprecationNotice') {
return { kind: 'collapsed-notice', label: 'deprecation' }
}
}
if (frame?.provider === 'codex' && frame.kind === 'notification:guardianWarning') {
// Codex also reports approvals here; its own TUI hides them by this prefix.
const approved = block.text.startsWith('Automatic approval review approved (')
@@ -117,6 +145,14 @@ export function nativeChatNoticeLook(block: NativeChatTextBlock): NativeChatNoti
}
}
/** Where a notice row's open choice may be stored, newest first: its own id, then its run's earlier retries. */
export function nativeChatNoticeDisclosureKeys(
message: Pick<NativeChatMessage, 'id' | 'providerRetryRunIds'>
): string[] {
const ids = message.providerRetryRunIds ?? [message.id]
return ids.map((_, index) => `notice:${ids[ids.length - 1 - index]}`)
}
/** The host writes "Forked from <name>"; the name is what links to the source. */
function forkedFromSourceName(text: string): string {
const lead = `${NATIVE_CHAT_NOTICE_COPY.forkedFrom} `
@@ -129,3 +129,35 @@ describe('a run of provider retry rows', () => {
])
})
})
describe("a drawn retry row's run", () => {
const named = (itemId: string, row: AgentJournalRenderItem): AgentJournalRenderItem => ({
...row,
itemId
})
const lead = named('lead', assistant('Working'))
const [r1, r2, r3, r4] = ['r1', 'r2', 'r3', 'r4'].map((id, index) => named(id, retry(index + 1)))
const runs = (items: AgentJournalRenderItem[]) =>
projectStructuredAgentSessionMessages(items, [], [])
.filter((message) => message.role === 'system')
.map((message) => [message.id, message.providerRetryRunIds])
it('lists the loaded retries it replaced, oldest first, wherever loaded history starts', () => {
expect(runs([lead, r2, r3])).toEqual([['r3', ['r2', 'r3']]])
expect(runs([lead, r1, r2, r3])).toEqual([['r3', ['r1', 'r2', 'r3']]])
expect(runs([r3, r4])).toEqual([['r4', ['r3', 'r4']]])
expect(runs([r4])).toEqual([['r4', undefined]])
})
it('never shares a retry between two runs or two agents', () => {
const worker = [named('w1', retry(1, 'worker')), named('w2', retry(2, 'worker'))]
expect(runs([r1, r2, named('reply', assistant('Partial answer')), r3, r4])).toEqual([
['r2', ['r1', 'r2']],
['r4', ['r3', 'r4']]
])
expect(runs([r1, worker[0]!, r2, worker[1]!])).toEqual([
['r2', ['r1', 'r2']],
['w2', ['w1', 'w2']]
])
})
})
@@ -13,15 +13,41 @@ export function isNativeChatProviderRetryRow(message: NativeChatMessage): boolea
)
}
/** One consumer's stamped rows from its last pass, so an unchanged run keeps its row object. */
export type NativeChatProviderRetryStamps = {
generation: Map<NativeChatMessage, NativeChatMessage>
}
export function createNativeChatProviderRetryStamps(): NativeChatProviderRetryStamps {
return { generation: new Map() }
}
type ProviderRetryRun = { at: number; source: NativeChatMessage; ids: string[] }
function sameIds(left: readonly string[] | undefined, right: readonly string[]): boolean {
return (
left !== undefined &&
left.length === right.length &&
left.every((id, index) => id === right[index])
)
}
/** Track runs per agent: interleaved conversation/subagent sections cannot end or hide one another’s runs. */
export function collapseProviderRetryRuns(
messages: readonly NativeChatMessage[]
messages: readonly NativeChatMessage[],
stamps?: NativeChatProviderRetryStamps
): readonly NativeChatMessage[] {
const previous = stamps?.generation
// Replaced on every pass so no stamp outlives the output that draws it.
const generation = new Map<NativeChatMessage, NativeChatMessage>()
if (stamps) {
stamps.generation = generation
}
if (!messages.some(isNativeChatProviderRetryRow)) {
return messages
}
// Each agent's open run, as the index of its latest row in `drawn`.
const openRuns = new Map<string | null, number>()
const openRuns = new Map<string | null, ProviderRetryRun>()
const runs: ProviderRetryRun[] = []
const drawn: (NativeChatMessage | null)[] = []
for (const message of messages) {
if (isNativeChatContextClear(message)) {
@@ -35,12 +61,30 @@ export function collapseProviderRetryRuns(
continue
}
const agent = agentJournalItemSubagentId(message)
const previous = openRuns.get(agent)
if (previous !== undefined) {
drawn[previous] = null
const run = openRuns.get(agent)
if (run) {
drawn[run.at] = null
run.at = drawn.length
run.source = message
run.ids.push(message.id)
} else {
const started = { at: drawn.length, source: message, ids: [message.id] }
openRuns.set(agent, started)
runs.push(started)
}
openRuns.set(agent, drawn.length)
drawn.push(message)
}
for (const run of runs) {
if (run.ids.length < 2) {
continue
}
const kept = previous?.get(run.source)
const row =
kept && sameIds(kept.providerRetryRunIds, run.ids)
? kept
: { ...run.source, providerRetryRunIds: run.ids }
generation.set(run.source, row)
drawn[run.at] = row
}
return drawn.filter((message) => message !== null)
}
@@ -255,6 +255,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & {
awaitsRetry?: true
/** Withdrawn sends have no rail tick because the host conversation outline excludes them. */
stoppedBeforeStart?: true
/** Client-derived on a drawn retry row: its run's loaded retry ids, oldest first, ending with its own. */
providerRetryRunIds?: readonly string[]
/** Structured journal ordering outranks timestamps; terminal transcripts omit it and worker reads strip it. */
journalPosition?: AgentJournalPosition
/** Newest absorbed tool row’s position; the folded row still sorts by its own position. */
@@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test'
test('Enter and Space keep the retry summary and disclosure choice in sync', async ({ page }) => {
test.setTimeout(120_000)
await page.goto(
'/fixtures.html?id=native-chat/messages/notices/NativeChatNoticeRow:storedRetryNotice&theme=light'
)
await expect(page.locator('html')).toHaveAttribute('data-fixture-state', 'ready', {
timeout: 90_000
})
const summary = page.locator('summary')
const choice = page.locator('[data-disclosure-choice]')
await summary.focus()
await expect(summary).toBeFocused()
await expect(summary).toHaveAttribute('aria-expanded', 'false')
await expect(choice).toHaveAttribute('data-disclosure-choice', 'undefined')
await summary.press('Enter')
await expect(summary).toHaveAttribute('aria-expanded', 'true')
await expect(choice).toHaveAttribute('data-disclosure-choice', 'true')
await summary.press('Space')
await expect(summary).toHaveAttribute('aria-expanded', 'false')
await expect(choice).toHaveAttribute('data-disclosure-choice', 'false')
})