diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c6cec48cb..591493114 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -1515,8 +1515,9 @@ deriving an older display status from `workflowStatusHistory`. The session route injects that prompt through `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` `session_prompt` lines 1313-1333. Pulse adapts that by keeping the active - model route selector, control-mode selector, and last-turn usage in composer - chrome while the drawer header stays limited to drawer/session commands. + model route selector, control-mode selector, and last-turn summary in + composer chrome while the drawer header stays limited to drawer/session + commands. Empty Assistant sessions are prompt-first transcript surfaces, not product marketing or instruction panels. The referenced OpenCode source at commit `9ed17da55ab1f7360cc0e01075f763e27fa899e9` @@ -1536,20 +1537,23 @@ deriving an older display status from `workflowStatusHistory`. than token counts. Pulse Assistant rows adapt that by keeping visible token accounting out of the transcript while showing a compact completed-turn duration beside the effective model label once a turn reaches `done`, - `error`, or user interruption. Runtime token usage may surface in the - composer chrome instead: OpenCode commit - `9ed17da55ab1f7360cc0e01075f763e27fa899e9` - `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` derives - prompt usage from the last assistant message with output tokens in `Prompt` - lines 246-264 and renders it in the prompt footer at lines 1652-1667. Pulse - adapts that source-backed contract by deriving the latest completed - `ChatMessage.tokens` with positive output tokens and rendering only the - backend-proven total token count as low-priority Assistant composer chrome. - The input/output split remains in the footer title/accessibility text, not - the visible transcript or composer copy, so shorthand such as `4358 in · 943 - out` cannot read like assistant output. Cost and context-limit percentages - stay absent until the runtime exposes those values through a governed - contract. + `error`, or user interruption. Runtime token usage may surface in + low-priority composer chrome instead, but the completed-turn surface is a + route summary rather than a token counter: the referenced OpenCode source at + fetched `dev` commit `3867fa2bad0e644166e360e2e99cfe426fe71105` imports + `turnSummaryCommit` in + `packages/opencode/src/cli/cmd/run/scrollback.surface.ts` lines 19-20, and + `packages/opencode/src/cli/cmd/run/turn-summary.ts` lines 5-46 renders a + final system summary from agent, model, and duration. Pulse adapts that by + deriving the latest non-streaming assistant `ChatMessage`, rendering the + effective provider/model route, completed-turn duration, and backend-proven + total token count in the composer footer. The input/output split remains in + the footer title/accessibility text, not the visible transcript or composer + copy, so shorthand such as `4358 in · 943 out` cannot read like assistant + output. If provider usage is missing, the route and duration remain visible; + active streaming turns never become the completed-turn summary. Cost and + context-limit percentages stay absent until the runtime exposes those values + through a governed contract. Assistant tool activity is visible transcript activity, not a hidden context footer. The referenced OpenCode source at fetched `origin/dev` commit `e82542b8023a8374f29c23b70ec019c8f256354e` @@ -2243,6 +2247,18 @@ provider route-switch metadata without changing the selected route, and labeling failed-turn or readiness recovery buttons as explicit route/model-route choices instead of implying automatic route adoption. +Assistant completed-turn chrome is route-owned summary, not raw usage output. +The OpenCode reference at fetched `dev` commit +`3867fa2bad0e644166e360e2e99cfe426fe71105` imports `turnSummaryCommit` in +`packages/opencode/src/cli/cmd/run/scrollback.surface.ts` lines 19-20, while +`packages/opencode/src/cli/cmd/run/turn-summary.ts` lines 5-46 emits a final +system summary with agent, model, and duration. Pulse adapts that by deriving +the latest completed, non-streaming assistant turn and surfacing its effective +provider/model route, duration, and backend-proven token total in low-priority +composer chrome. Input/output usage remains title/accessibility detail only, +missing provider usage does not hide route and duration, and active streaming +turns never appear as the completed-turn summary. + Assistant tool activity now follows an OpenCode-referenced chronological row model where appropriate for Pulse. Consecutive context/read/query tools render as visible transcript rows in arrival order instead of being replaced by a diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 654f51385..0b7964691 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -25,6 +25,7 @@ import { QuestionCard } from './QuestionCard'; import { ThinkingBlock } from './ThinkingBlock'; import { getAssistantAnswerText } from './assistantAnswerText'; import { stripAssistantOutputArtifacts } from './assistantOutputHygiene'; +import { formatAssistantTurnDuration } from './assistantTurnSummary'; import { formatAssistantWorkflowStatus } from './activeTurnStatus'; import { groupStreamEventsForDisplay } from './streamEventGrouping'; import { @@ -64,24 +65,6 @@ interface MessageItemProps { onCancelQueued?: () => void; } -const formatAssistantTurnDuration = (startedAt: Date, completedAt?: Date): string => { - if (!completedAt) return ''; - const durationMs = completedAt.getTime() - startedAt.getTime(); - if (!Number.isFinite(durationMs) || durationMs < 0) return ''; - if (durationMs < 1000) return '<1s'; - - const totalSeconds = Math.max(1, Math.round(durationMs / 1000)); - if (totalSeconds < 60) return `${totalSeconds}s`; - - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`; - - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; -}; - const markdownClass = 'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none prose-p:leading-relaxed prose-p:my-2 prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-md prose-pre:text-xs prose-pre:border prose-pre:border-slate-800 prose-code:text-blue-700 dark:prose-code:text-blue-300 prose-code:bg-blue-50 dark:prose-code:bg-blue-900 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:font-mono prose-code:text-[0.9em] prose-code:border prose-code:border-blue-100 dark:prose-code:border-blue-800 prose-code:before:content-none prose-code:after:content-none prose-headings:font-semibold prose-hr:border-slate-200 dark:prose-hr:border-slate-700 prose-ul:my-2 prose-ol:my-2 prose-li:my-1'; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 32c0680a5..bbb2b42fe 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -5894,42 +5894,45 @@ describe('AIChat', () => { }); }); - // ── Last-turn usage footer ──────────────────────────────────────────────── + // ── Last-turn summary footer ─────────────────────────────────────────────── - describe('last-turn usage footer', () => { - it('shows completed assistant token usage in the composer chrome', () => { + describe('last-turn summary footer', () => { + it('shows completed assistant route, duration, and token usage in the composer chrome', () => { mockChat.messages.mockReturnValue([ { id: 'asst-1', role: 'assistant' as const, content: 'Done.', - timestamp: new Date(), - completedAt: new Date(), + timestamp: new Date('2026-06-06T12:00:00Z'), + completedAt: new Date('2026-06-06T12:00:03Z'), isStreaming: false, + model: 'openrouter:qwen/qwen3.7-plus', tokens: { input: 500, output: 200 }, }, ]); renderChat(); - const usage = screen.getByLabelText(/Last assistant turn usage/); - expect(usage).toHaveTextContent('Last turn: 700 tokens'); - expect(usage).not.toHaveTextContent('500 in'); - expect(usage).not.toHaveTextContent('200 out'); - expect(usage).toHaveAttribute( + const summary = screen.getByLabelText(/Last assistant turn summary/); + expect(summary).toHaveTextContent( + 'Last turn: Qwen: Qwen3.7 Plus via OpenRouter · 3s · 700 tokens', + ); + expect(summary).not.toHaveTextContent('500 in'); + expect(summary).not.toHaveTextContent('200 out'); + expect(summary).toHaveAttribute( 'title', - 'Last assistant turn usage: 700 total, 500 input, 200 output', + 'Last assistant turn summary: Model: Qwen: Qwen3.7 Plus via OpenRouter. Duration: 3s. Usage: 700 total, 500 input, 200 output', ); }); - it('uses the latest completed assistant turn with output tokens', () => { + it('uses the latest completed assistant turn and skips the active streaming turn', () => { mockChat.messages.mockReturnValue([ { id: 'asst-1', role: 'assistant' as const, content: 'Earlier result.', - timestamp: new Date(), - completedAt: new Date(), + timestamp: new Date('2026-06-06T12:00:00Z'), + completedAt: new Date('2026-06-06T12:00:02Z'), isStreaming: false, tokens: { input: 100, output: 50 }, }, @@ -5945,29 +5948,50 @@ describe('AIChat', () => { renderChat(); - const usage = screen.getByLabelText(/Last assistant turn usage/); - expect(usage).toHaveTextContent('Last turn: 150 tokens'); - expect(usage).not.toHaveTextContent('100 in'); - expect(usage).not.toHaveTextContent('50 out'); - expect(usage).not.toHaveTextContent('1,700 tokens'); + const summary = screen.getByLabelText(/Last assistant turn summary/); + expect(summary).toHaveTextContent('Last turn: 2s · 150 tokens'); + expect(summary).not.toHaveTextContent('100 in'); + expect(summary).not.toHaveTextContent('50 out'); + expect(summary).not.toHaveTextContent('1,700 tokens'); }); - it('does not show usage before an assistant turn has output tokens', () => { + it('still summarizes completed turns when token usage is missing', () => { mockChat.messages.mockReturnValue([ { id: 'asst-1', role: 'assistant' as const, - content: '', - timestamp: new Date(), - completedAt: new Date(), + content: 'Done.', + timestamp: new Date('2026-06-06T12:00:00Z'), + completedAt: new Date('2026-06-06T12:00:05Z'), isStreaming: false, + model: 'deepseek:deepseek-chat', tokens: { input: 500, output: 0 }, }, ]); renderChat(); - expect(screen.queryByLabelText(/Last assistant turn usage/)).not.toBeInTheDocument(); + const summary = screen.getByLabelText(/Last assistant turn summary/); + expect(summary).toHaveTextContent('Last turn: DeepSeek: DeepSeek Chat · 5s'); + expect(summary).not.toHaveTextContent('tokens'); + }); + + it('does not show the summary for an active streaming turn', () => { + mockChat.messages.mockReturnValue([ + { + id: 'asst-1', + role: 'assistant' as const, + content: 'Streaming.', + timestamp: new Date('2026-06-06T12:00:00Z'), + isStreaming: true, + model: 'openrouter:qwen/qwen3.7-plus', + tokens: { input: 500, output: 200 }, + }, + ]); + + renderChat(); + + expect(screen.queryByLabelText(/Last assistant turn summary/)).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.test.ts new file mode 100644 index 000000000..3b1cd27ab --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantTurnSummary.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + formatAssistantTurnDuration, + getAssistantTurnSummary, +} from '../assistantTurnSummary'; +import type { ChatMessage } from '../types'; + +const makeAssistantMessage = (overrides: Partial = {}): ChatMessage => ({ + id: 'asst-1', + role: 'assistant', + content: 'Done.', + timestamp: new Date('2026-06-06T12:00:00Z'), + completedAt: new Date('2026-06-06T12:00:03Z'), + isStreaming: false, + model: 'openrouter:qwen/qwen3.7-plus', + tokens: { input: 500, output: 200 }, + ...overrides, +}); + +describe('assistantTurnSummary', () => { + it('formats assistant turn duration for short and longer completed turns', () => { + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T12:00:00.400Z'), + ), + ).toBe('<1s'); + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T12:01:05Z'), + ), + ).toBe('1m 5s'); + expect( + formatAssistantTurnDuration( + new Date('2026-06-06T12:00:00Z'), + new Date('2026-06-06T11:59:59Z'), + ), + ).toBe(''); + }); + + it('summarizes completed assistant route, duration, and billed usage', () => { + expect( + getAssistantTurnSummary(makeAssistantMessage(), { + getModelRouteLabel: () => 'Qwen via OpenRouter', + }), + ).toEqual({ + label: 'Last turn: Qwen via OpenRouter · 3s · 700 tokens', + title: + 'Last assistant turn summary: Model: Qwen via OpenRouter. Duration: 3s. Usage: 700 total, 500 input, 200 output', + }); + }); + + it('keeps route and duration visible when provider usage is missing', () => { + expect( + getAssistantTurnSummary( + makeAssistantMessage({ + tokens: { input: 500, output: 0 }, + }), + { + getModelRouteLabel: () => 'DeepSeek direct', + }, + ), + ).toEqual({ + label: 'Last turn: DeepSeek direct · 3s', + title: 'Last assistant turn summary: Model: DeepSeek direct. Duration: 3s', + }); + }); + + it('does not summarize active streaming or non-assistant turns', () => { + expect(getAssistantTurnSummary(makeAssistantMessage({ isStreaming: true }))).toBeNull(); + expect( + getAssistantTurnSummary( + makeAssistantMessage({ + role: 'user', + content: 'Question', + }), + ), + ).toBeNull(); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/assistantTurnSummary.ts b/frontend-modern/src/components/AI/Chat/assistantTurnSummary.ts new file mode 100644 index 000000000..74ed8e7e1 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/assistantTurnSummary.ts @@ -0,0 +1,76 @@ +import { AI_CHAT_LAST_TURN_SUMMARY_LABEL } from '@/utils/aiChatPresentation'; +import type { ChatMessage } from './types'; + +export interface AssistantTurnSummary { + label: string; + title: string; +} + +const tokenNumberFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); + +const normalizeAssistantTokenCount = (value: number | undefined): number => { + if (!Number.isFinite(value) || !value || value < 0) return 0; + return Math.floor(value); +}; + +const formatAssistantTokenCount = (count: number): string => tokenNumberFormat.format(count); + +export const formatAssistantTurnDuration = (startedAt: Date, completedAt?: Date): string => { + if (!completedAt) return ''; + const durationMs = completedAt.getTime() - startedAt.getTime(); + if (!Number.isFinite(durationMs) || durationMs < 0) return ''; + if (durationMs < 1000) return '<1s'; + + const totalSeconds = Math.max(1, Math.round(durationMs / 1000)); + if (totalSeconds < 60) return `${totalSeconds}s`; + + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`; + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +}; + +const assistantTokenSummary = (message: ChatMessage) => { + const input = normalizeAssistantTokenCount(message.tokens?.input); + const output = normalizeAssistantTokenCount(message.tokens?.output); + if (output <= 0) return null; + + const total = input + output; + return { + label: `${formatAssistantTokenCount(total)} ${total === 1 ? 'token' : 'tokens'}`, + detail: `${formatAssistantTokenCount(total)} total, ${formatAssistantTokenCount( + input, + )} input, ${formatAssistantTokenCount(output)} output`, + }; +}; + +export const getAssistantTurnSummary = ( + message: ChatMessage, + options: { getModelRouteLabel?: (modelId: string) => string } = {}, +): AssistantTurnSummary | null => { + if (message.role !== 'assistant' || message.isStreaming) return null; + + const model = message.model?.trim(); + const modelLabel = model ? options.getModelRouteLabel?.(model) || model : ''; + const durationLabel = formatAssistantTurnDuration(message.timestamp, message.completedAt); + const tokenSummary = assistantTokenSummary(message); + + const labelParts = [modelLabel, durationLabel, tokenSummary?.label].filter( + (part): part is string => Boolean(part?.trim()), + ); + if (labelParts.length === 0) return null; + + const titleParts = [ + modelLabel ? `Model: ${modelLabel}` : undefined, + durationLabel ? `Duration: ${durationLabel}` : undefined, + tokenSummary ? `Usage: ${tokenSummary.detail}` : undefined, + ].filter((part): part is string => Boolean(part)); + + return { + label: `Last turn: ${labelParts.join(' · ')}`, + title: `${AI_CHAT_LAST_TURN_SUMMARY_LABEL}: ${titleParts.join('. ')}`, + }; +}; diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 47317ed80..5f0f92bfe 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -75,7 +75,6 @@ import { AI_CHAT_FORK_SESSION_LOADING_MESSAGE, AI_CHAT_FORK_SESSION_SUCCESS_MESSAGE, AI_CHAT_INPUT_PLACEHOLDER, - AI_CHAT_LAST_TURN_USAGE_LABEL, AI_CHAT_NEW_SESSION_BUTTON_TITLE, AI_CHAT_NEW_SESSION_MENU_ARIA_LABEL, AI_CHAT_NEW_SESSION_MENU_LABEL, @@ -134,6 +133,7 @@ import { import { normalizeChatMentionKeyPart } from '@/utils/chatIdentifiers'; import { getGlobalWebSocketStore } from '@/stores/websocket-global'; import { copyToClipboard } from '@/utils/clipboard'; +import { getAssistantTurnSummary } from './assistantTurnSummary'; import { getPreferredResourceDisplayName, getPreferredResourceHostname, @@ -255,11 +255,6 @@ interface ComposerDraftStash { restoredPromptDraft: RestoredPromptDraft | null; } -interface AssistantUsageSummary { - label: string; - title: string; -} - interface TranscriptCopyFallback { generatedAt: Date; transcript: string; @@ -297,35 +292,6 @@ const compactText = (items: Array): string[] => const pluralizeCount = (count: number, singular: string, plural: string) => `${count} ${count === 1 ? singular : plural}`; -const tokenNumberFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); - -const normalizeAssistantTokenCount = (value: number | undefined): number => { - if (!Number.isFinite(value) || !value || value < 0) return 0; - return Math.floor(value); -}; - -const formatAssistantTokenCount = (count: number): string => tokenNumberFormat.format(count); - -const getAssistantUsageSummary = (message: ChatMessage): AssistantUsageSummary | null => { - if (message.role !== 'assistant' || message.isStreaming) return null; - const input = normalizeAssistantTokenCount(message.tokens?.input); - const output = normalizeAssistantTokenCount(message.tokens?.output); - if (output <= 0) return null; - - const total = input + output; - const totalLabel = `${formatAssistantTokenCount(total)} ${total === 1 ? 'token' : 'tokens'}`; - const titleDetail = [ - `${formatAssistantTokenCount(total)} total`, - `${formatAssistantTokenCount(input)} input`, - `${formatAssistantTokenCount(output)} output`, - ].join(', '); - - return { - label: `Last turn: ${totalLabel}`, - title: `${AI_CHAT_LAST_TURN_USAGE_LABEL}: ${titleDetail}`, - }; -}; - const normalizePromptHistoryEntry = (value: unknown): PromptHistoryEntry | null => { if (!value || typeof value !== 'object') return null; const record = value as Record; @@ -2475,11 +2441,13 @@ export const AIChat: Component = (props) => { ]).join('. '), }; }); - const lastAssistantUsage = createMemo(() => { + const lastAssistantTurnSummary = createMemo(() => { const messages = chat.messages(); for (let index = messages.length - 1; index >= 0; index -= 1) { - const usage = getAssistantUsageSummary(messages[index]); - if (usage) return usage; + const summary = getAssistantTurnSummary(messages[index], { + getModelRouteLabel: formatChatMessageModelRoute, + }); + if (summary) return summary; } return null; }); @@ -4917,14 +4885,14 @@ export const AIChat: Component = (props) => { - - {(usage) => ( + + {(summary) => (
- {usage().label} + {summary().label}
)}
diff --git a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts index 038a5eb98..33e22f2f1 100644 --- a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts @@ -21,7 +21,7 @@ import { AI_CHAT_DRAWER_SUBTITLE, AI_CHAT_DRAWER_TITLE, AI_CHAT_LAUNCHER_ARIA_LABEL, - AI_CHAT_LAST_TURN_USAGE_LABEL, + AI_CHAT_LAST_TURN_SUMMARY_LABEL, AI_CHAT_MODEL_SELECTOR_EMPTY_STATE, AI_CHAT_NEW_SESSION_BUTTON_TITLE, AI_CHAT_NEW_SESSION_MENU_ARIA_LABEL, @@ -116,7 +116,7 @@ describe('aiChatPresentation', () => { expect(AI_CHAT_QUESTION_CARD_TITLE).toBe('Pulse Assistant needs your input'); expect(AI_CHAT_ASSISTANT_MESSAGE_LABEL).toBe('Pulse Assistant'); expect(AI_CHAT_CONTEXT_USED_LABEL).toBe('Context used'); - expect(AI_CHAT_LAST_TURN_USAGE_LABEL).toBe('Last assistant turn usage'); + expect(AI_CHAT_LAST_TURN_SUMMARY_LABEL).toBe('Last assistant turn summary'); expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF).toBe('/settings/system-ai'); expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL).toBe('Open settings'); expect(AI_CHAT_PROVIDER_READINESS_RETRY_LABEL).toBe('Retry'); diff --git a/frontend-modern/src/utils/aiChatPresentation.ts b/frontend-modern/src/utils/aiChatPresentation.ts index 579da327a..798766d2c 100644 --- a/frontend-modern/src/utils/aiChatPresentation.ts +++ b/frontend-modern/src/utils/aiChatPresentation.ts @@ -67,7 +67,7 @@ export const AI_CHAT_QUESTION_CARD_TITLE = 'Pulse Assistant needs your input'; export const AI_CHAT_QUESTION_CARD_PLACEHOLDER = 'Type your answer...'; export const AI_CHAT_ASSISTANT_MESSAGE_LABEL = 'Pulse Assistant'; export const AI_CHAT_CONTEXT_USED_LABEL = 'Context used'; -export const AI_CHAT_LAST_TURN_USAGE_LABEL = 'Last assistant turn usage'; +export const AI_CHAT_LAST_TURN_SUMMARY_LABEL = 'Last assistant turn summary'; export const AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF = '/settings/system-ai'; export const AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL = 'Open settings'; export const AI_CHAT_PROVIDER_READINESS_RETRY_LABEL = 'Retry';