diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 080ef99262d..93d0dad75e2 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -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", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index ee677e5aca6..eabd074c454 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -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", diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 9ae48aea8df..0b9f6f0008e 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -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", diff --git a/mobile/src/i18n/locales/fr.json b/mobile/src/i18n/locales/fr.json index eea40512fb7..8e690547efb 100644 --- a/mobile/src/i18n/locales/fr.json +++ b/mobile/src/i18n/locales/fr.json @@ -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", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 36f4c2aff3b..672ccf1f34c 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -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": "このチャットを実行しているコンピューターでターミナルを開きます", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index 3b478623298..a40c5ee033d 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -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": "이 채팅을 실행 중인 컴퓨터에서 터미널을 엽니다", diff --git a/mobile/src/i18n/locales/pt.json b/mobile/src/i18n/locales/pt.json index d7f11c1a401..59b27e03c2c 100644 --- a/mobile/src/i18n/locales/pt.json +++ b/mobile/src/i18n/locales/pt.json @@ -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", diff --git a/mobile/src/i18n/locales/ru.json b/mobile/src/i18n/locales/ru.json index a48953f4f52..a070ae9214a 100644 --- a/mobile/src/i18n/locales/ru.json +++ b/mobile/src/i18n/locales/ru.json @@ -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": "Открывает терминал на компьютере с этим чатом", diff --git a/mobile/src/i18n/locales/tr.json b/mobile/src/i18n/locales/tr.json index 3986dd06aff..6e31484b605 100644 --- a/mobile/src/i18n/locales/tr.json +++ b/mobile/src/i18n/locales/tr.json @@ -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", diff --git a/mobile/src/i18n/locales/zh-Hant.json b/mobile/src/i18n/locales/zh-Hant.json index 5d5a9d41f7b..daa84233940 100644 --- a/mobile/src/i18n/locales/zh-Hant.json +++ b/mobile/src/i18n/locales/zh-Hant.json @@ -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": "在執行此聊天的電腦上開啟終端機", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 99baccd066a..f13889f65bd 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -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": "在运行此聊天的电脑上打开终端", diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatCollapsedNotice.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatCollapsedNotice.tsx new file mode 100644 index 00000000000..878e7bfee1c --- /dev/null +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatCollapsedNotice.tsx @@ -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 + +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 ( + + setOpen(!open)} + onLongPress={onLongPress} + style={[styles.summary, look.label === 'configuration' && styles.configuration]} + /> + {open ? ( + + + {notice.text} + {detail ? `\n\n${detail}` : ''} + + {payload ? ( + <> + + {t('chat.notice.providerResponse', 'Provider response')} + + + {payload.truncated + ? `${t( + 'chat.notice.truncatedResponse', + 'Provider response truncated (original size: {{bytes}} bytes).', + { bytes: payload.byteLength } + )}\n` + : ''} + {payload.head} + + + ) : null} + + ) : null} + + ) +} + +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 + } +})) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.collapsed-notice.test.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.collapsed-notice.test.tsx new file mode 100644 index 00000000000..f8ea5859664 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.collapsed-notice.test.tsx @@ -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) + } +) diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tsx index ab29aada0df..d1b53c8fb92 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatMessage.tsx @@ -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} @@ -320,6 +327,7 @@ function MobileNativeChatMessageImpl({ ) : null} diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeDisclosure.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeDisclosure.tsx index 1af0c0e2c4f..eaf8c11d15f 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeDisclosure.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeDisclosure.tsx @@ -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 }): React.JSX.Element { return ( [styles.disclosure, pressed && pressedStyle]} + style={({ pressed }) => [styles.disclosure, style, pressed && pressedStyle]} > diff --git a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx index c1b8600e34d..84a047a4e16 100644 --- a/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx +++ b/mobile/src/session/native-chat/transcript/MobileNativeChatNoticeRow.tsx @@ -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({ ) } + case 'collapsed-notice': + return ( + + ) case 'divider': { const label = copy[look.label] return ( diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-disclosure-store.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-disclosure-store.ts index a2bb914cbce..31f2815ccec 100644 --- a/mobile/src/session/native-chat/transcript/mobile-native-chat-disclosure-store.ts +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-disclosure-store.ts @@ -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) => { diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-message-styles.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-message-styles.ts index d8d4f3cd8ee..bb72f677e16 100644 --- a/mobile/src/session/native-chat/transcript/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-message-styles.ts @@ -16,6 +16,9 @@ export const styles = themedStyleSheet(() => ({ paddingHorizontal: spacing.lg, paddingVertical: spacing.sm }, + collapsedNoticeRow: { + paddingVertical: 0 + }, rowUser: { alignItems: 'flex-end' }, diff --git a/mobile/src/session/native-chat/transcript/mobile-native-chat-notice-line-text.ts b/mobile/src/session/native-chat/transcript/mobile-native-chat-notice-line-text.ts new file mode 100644 index 00000000000..5953f7bda08 --- /dev/null +++ b/mobile/src/session/native-chat/transcript/mobile-native-chat-notice-line-text.ts @@ -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 +} diff --git a/src/renderer/src/components/native-chat/messages/diagnostics/NativeChatProviderDetails.tsx b/src/renderer/src/components/native-chat/messages/diagnostics/NativeChatProviderDetails.tsx index ed724b54d2b..6910cba090a 100644 --- a/src/renderer/src/components/native-chat/messages/diagnostics/NativeChatProviderDetails.tsx +++ b/src/renderer/src/components/native-chat/messages/diagnostics/NativeChatProviderDetails.tsx @@ -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 ( -
- +
{ + // Mounting already open also fires; only a reader's toggle is a choice. + if (event.currentTarget.open !== open) { + onOpenChange?.(event.currentTarget.open) + } + }} + > + › @@ -69,7 +97,7 @@ export function NativeChatDetailsDisclosure({ {summary} {trail} -
         {children}
-      
+
) } diff --git a/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts b/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts index 254f3e048d7..329475c38e1 100644 --- a/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts +++ b/src/renderer/src/components/native-chat/messages/disclosure/native-chat-disclosure-store.ts @@ -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 }) diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatCollapsedNotice.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatCollapsedNotice.tsx new file mode 100644 index 00000000000..1f814cd2b85 --- /dev/null +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatCollapsedNotice.tsx @@ -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 + +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 ( + + {block.text} + {detail ? `\n\n${detail}` : ''} + {payload ? ( + <> + + {t('components.native-chat.notices.providerResponse', 'Provider response')} + + + {payload.truncated + ? `${t( + 'components.native-chat.notices.truncatedResponse', + 'Provider response truncated (original size: {{bytes}} bytes).', + { bytes: payload.byteLength } + )}\n` + : ''} + {payload.head} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.collapsed-notice.test.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.collapsed-notice.test.tsx new file mode 100644 index 00000000000..ef89d5c4312 --- /dev/null +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.collapsed-notice.test.tsx @@ -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 ( + + {project(items, [], []).map((message) => { + const notice = nativeChatNoticeBlock(message.blocks) + return notice ? ( +
+ +
+ ) : null + })} +
+ ) +} + +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() + const row = (id: string) => + container.querySelector(`[data-row="${id}"] details`)! + expect(row('r3').open).toBe(false) + fireEvent.click(row('r3').querySelector('summary')!) + expect(row('r3').open).toBe(true) + + rerender() + expect(row('r3').open).toBe(true) + + rerender() + expect(row('r4').open).toBe(true) + expect(row('r4')).toHaveTextContent('Codex is retrying: stream disconnected r4.') + + rerender() + expect(row('r4').open).toBe(true) + + rerender() + 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() + expect(row('r4').open).toBe(false) +}) + +it('shows a retry with no provider frame as its own text once opened', () => { + const { container } = render( + + ) + 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( + + ) + expect(container.querySelector('[data-native-chat-code-content]')?.textContent?.trim()).toBe( + expected + ) + } +) diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx index 54ff875e949..89a0a44f1b8 100644 --- a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.fixture.tsx @@ -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 ( +
+ {collapsedNotices.map((block) => ( + + ))} +
+ ) +} + +export const collapsedNoticesClosed = defineFixture({ + width: 720, + render: () => +}) + +export const collapsedNoticesOpened = defineFixture({ + width: 720, + render: () => , + interact: (frame) => { + frame.querySelectorAll('summary').forEach((summary) => summary.click()) + } +}) + +function StoredRetryNotice() { + const store = useNativeChatDisclosures() + return ( + +
+ + +
+
+ ) +} + +export const storedRetryNotice = defineFixture({ + width: 720, + render: () => +}) diff --git a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx index 95ceca40858..4eab50cfcb8 100644 --- a/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/messages/notices/NativeChatNoticeRow.tsx @@ -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 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({ ) } + if (look.kind === 'collapsed-notice') { + return + } // 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 diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx index 0ee81443a4a..40df7fd94c7 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatMessageRow.tsx @@ -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 (
- +
) } diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 062bab6b7c7..6e2997dab9e 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a5efbd1c672..04aa232f5e7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c85e0fedcdd..c40a4d57c1c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 91e2377e60a..9bfca85deb1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 511abc5dd2d..3ede0172665 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e15a1ef2e38..502d1bf2eda 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index e6a95271fa6..89c6086d78b 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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}}." }, diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 2a19b39d005..0c4a57c4c9a 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -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": "Ответвление от" }, diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 84d6397c7f8..80ab1f56d4c 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 9a250e4ea66..29bad792e08 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2d291616a23..ca237c6d3c7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/agent-session/failures/agent-session-provider-retry-words.ts b/src/shared/agent-session/failures/agent-session-provider-retry-words.ts index 6dec38301fe..00b3b47ceab 100644 --- a/src/shared/agent-session/failures/agent-session-provider-retry-words.ts +++ b/src/shared/agent-session/failures/agent-session-provider-retry-words.ts @@ -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) diff --git a/src/shared/agent-session/view/structured-agent-session-message-projection.ts b/src/shared/agent-session/view/structured-agent-session-message-projection.ts index 60931d822ca..e7c0f4bc9f5 100644 --- a/src/shared/agent-session/view/structured-agent-session-message-projection.ts +++ b/src/shared/agent-session/view/structured-agent-session-message-projection.ts @@ -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. diff --git a/src/shared/agent-session/view/structured-agent-session-message-stream.ts b/src/shared/agent-session/view/structured-agent-session-message-stream.ts index eadedacb111..4d1720b8606 100644 --- a/src/shared/agent-session/view/structured-agent-session-message-stream.ts +++ b/src/shared/agent-session/view/structured-agent-session-message-stream.ts @@ -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 diff --git a/src/shared/native-chat/native-chat-notice.test.ts b/src/shared/native-chat/native-chat-notice.test.ts index d7c4f10149f..3a5f3346f95 100644 --- a/src/shared/native-chat/native-chat-notice.test.ts +++ b/src/shared/native-chat/native-chat-notice.test.ts @@ -35,3 +35,77 @@ it('reads only approved Codex automatic reviews as neutral notices', () => { }) } }) + +const frame = (kind: string): NonNullable => ({ + 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' }) +}) diff --git a/src/shared/native-chat/native-chat-notice.ts b/src/shared/native-chat/native-chat-notice.ts index 2f979fb76d3..107cf3a5d0c 100644 --- a/src/shared/native-chat/native-chat-notice.ts +++ b/src/shared/native-chat/native-chat-notice.ts @@ -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 +): string[] { + const ids = message.providerRetryRunIds ?? [message.id] + return ids.map((_, index) => `notice:${ids[ids.length - 1 - index]}`) +} + /** The host writes "Forked from "; the name is what links to the source. */ function forkedFromSourceName(text: string): string { const lead = `${NATIVE_CHAT_NOTICE_COPY.forkedFrom} ` diff --git a/src/shared/native-chat/native-chat-provider-retry-runs.test.ts b/src/shared/native-chat/native-chat-provider-retry-runs.test.ts index c86a23de151..4cabcc41e7e 100644 --- a/src/shared/native-chat/native-chat-provider-retry-runs.test.ts +++ b/src/shared/native-chat/native-chat-provider-retry-runs.test.ts @@ -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']] + ]) + }) +}) diff --git a/src/shared/native-chat/native-chat-provider-retry-runs.ts b/src/shared/native-chat/native-chat-provider-retry-runs.ts index deb63ccae8a..17a9b16d62f 100644 --- a/src/shared/native-chat/native-chat-provider-retry-runs.ts +++ b/src/shared/native-chat/native-chat-provider-retry-runs.ts @@ -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 +} + +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() + 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() + const openRuns = new Map() + 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) } diff --git a/src/shared/native-chat/native-chat-types.ts b/src/shared/native-chat/native-chat-types.ts index d7590bdc32a..b6aa24b07c5 100644 --- a/src/shared/native-chat/native-chat-types.ts +++ b/src/shared/native-chat/native-chat-types.ts @@ -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. */ diff --git a/tests/component-fixtures/native-chat-notice-disclosure.spec.ts b/tests/component-fixtures/native-chat-notice-disclosure.spec.ts new file mode 100644 index 00000000000..c427c8cdd4a --- /dev/null +++ b/tests/component-fixtures/native-chat-notice-disclosure.spec.ts @@ -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') +})