feat(work-status): add opt-in turn statistics (#3177)
Add optional completed-turn statistics without changing the existing panel layout. Separate final text delivery speed from whole-turn throughput, preserve scope and opt-in settings, and explain each metric with localized delayed tooltips. Validated focused telemetry, lifecycle, sync and persistence tests, all-workspace type-check and lint, web builds, the 12-locale narrow layout, and full GitHub CI.
This commit is contained in:
@@ -17,7 +17,8 @@ conditionally; passing "am I first?" down would mean each one tracking what the
|
||||
sections above it decided to render.
|
||||
|
||||
Sections render nothing when they have no rows, so the panel collapses upward
|
||||
instead of reserving empty space.
|
||||
instead of reserving empty space. Opt-in Turn stats keeps its header for a
|
||||
selected session even without metrics, so a saved collapsed state can reopen.
|
||||
|
||||
## What it is not
|
||||
|
||||
@@ -103,11 +104,49 @@ which requests only providers enabled for this panel.
|
||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
||||
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
|
||||
| Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR |
|
||||
| Turn stats | `telemetry.ts` over `useSessionMessageRecords` | opt-in; computed only while expanded and authoritatively idle |
|
||||
| Goal | `useSessionGoal` | respects the Settings toggle |
|
||||
| MCP | `useMcpStore` | connect/disconnect reuses the dropdown's actions |
|
||||
| Pinned messages | `getContextObligatoryMessages` + `state.part` | see below |
|
||||
| Todos | live `state.todo[sessionId]`, persisted fallback | live channel wins |
|
||||
|
||||
### Turn stats
|
||||
|
||||
The section follows Usage and reuses the panel's existing rows. Only its header
|
||||
has an icon; metric rows use labels and values without leading icons. It
|
||||
reads already-loaded records without fetching history. The newest turn needs a
|
||||
preceding user message and completed assistant steps. A truncated or unfinished
|
||||
turn has no whole-turn result; later materialization can supply it.
|
||||
|
||||
Two rates answer different questions. Response speed uses the final assistant
|
||||
message's output tokens divided by the union of its nonempty text intervals.
|
||||
It excludes initial waiting, reasoning tokens and reasoning time, and earlier
|
||||
tool steps. It requires complete, valid text timing and a final message without
|
||||
tools, errors, synthetic text or ignored text. This measures text delivery from
|
||||
stored timestamps, not provider-side decode speed. The header shows only this
|
||||
rate; unavailable response timing never falls back to whole-turn speed.
|
||||
|
||||
Whole-turn speed uses output plus reasoning tokens from every step, divided by
|
||||
elapsed assistant time minus the union of completed and failed tool intervals.
|
||||
Waiting for each model response remains included. Invalid or missing inputs
|
||||
omit the dependent metric rather than becoming zero; reported zeros remain
|
||||
valid. TTFT averages the earliest text/reasoning start delay from every step,
|
||||
only when all steps have a valid sample.
|
||||
|
||||
Metric labels stay short. Every row is a single hover and keyboard-focus target
|
||||
for a shared tooltip, with a 750ms hover delay and a portal outside the panel's
|
||||
scroller. Tooltips explain the measurement in every locale. The token row uses
|
||||
compact input/output arrows; its tooltip gives full counts and explains that
|
||||
input excludes cached tokens and output includes reasoning across all steps.
|
||||
|
||||
Records subscriptions and aggregation stop while collapsed, busy, retrying, or
|
||||
awaiting status authority. Explicit idle events or a successful directory status
|
||||
snapshot allow computation. One component-owned committed result keeps the
|
||||
headline and rows stable during the next active turn. Its identity includes
|
||||
runtime, normalized directory and session. Scope changes discard it; fresh empty
|
||||
or reverted records clear it. There is no global message-ID cache. Corrections
|
||||
to existing message/part identities invalidate the current result.
|
||||
|
||||
### Context usage has its own computation, on purpose
|
||||
|
||||
`useSessionUIStore.getContextUsage` cannot serve this panel for two reasons:
|
||||
@@ -191,8 +230,9 @@ the row reflects the reset tree rather than a mid-creation snapshot.
|
||||
Ordering is by durability, not category:
|
||||
|
||||
1. **Session** (goal, context, cost), **Project** (attention, branch,
|
||||
changes, PR, checks) and **Usage** — true for as long as the session is
|
||||
open. Usage sits here rather than lower down because a spent quota stops the
|
||||
changes, PR, checks), **Usage**, and **Turn stats** (opt-in session telemetry:
|
||||
throughput, duration, TTFT, cache hit rate) — true for as long as the session
|
||||
is open. Usage sits here rather than lower down because a spent quota stops the
|
||||
work outright;
|
||||
2. **Subagents**, **Tasks** — what is happening right now;
|
||||
3. **MCP**, **Pinned messages**, **Context sources** — supporting material.
|
||||
@@ -201,10 +241,13 @@ Ordering is by durability, not category:
|
||||
|
||||
A persisted preference (`workStatusPanelEnabled`) drives a header toggle, and a
|
||||
dialog behind the equalizer icon switches individual sections off. Hidden
|
||||
sections are stored rather than visible ones, so a section added later appears
|
||||
for everyone instead of staying invisible to whoever had saved settings before
|
||||
it existed. Both travel the full settings pipeline, including the server
|
||||
whitelist without which the keys never reach `settings.json`.
|
||||
sections are stored rather than visible ones. Telemetry is the opt-in exception:
|
||||
UI-store v20 and legacy server-list hydration add it to the hidden set. A
|
||||
`workStatusHiddenSectionsExplicit` marker records that a list was chosen in a
|
||||
client with telemetry support. The marker and list travel together through
|
||||
autosave, sanitization, and server settings, so an explicit empty list enables
|
||||
everything while an old empty list does not enable telemetry. Complete settings
|
||||
snapshots own this preference; unrelated partial save echoes leave it unchanged.
|
||||
|
||||
`workStatusPanelVisible` is separate and transient: the switch can be on while
|
||||
layout still refuses the panel. The header and the git rail read it to drop the
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
|
||||
import { WorkStatusGoalRow } from './WorkStatusGoalRow';
|
||||
import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup';
|
||||
import { WorkStatusUsageSection } from './WorkStatusUsageSection';
|
||||
import { WorkStatusTelemetrySection } from './WorkStatusTelemetrySection';
|
||||
import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection';
|
||||
import { WorkStatusTasksSection } from './WorkStatusTasksSection';
|
||||
import { WorkStatusMcpSection } from './WorkStatusMcpSection';
|
||||
@@ -254,6 +255,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
|
||||
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
|
||||
/>
|
||||
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
|
||||
{sectionVisible('telemetry') ? <WorkStatusTelemetrySection sessionId={sessionId} directory={directory} /> : null}
|
||||
{sectionVisible('subagents') ? <WorkStatusSubagentsSection sessionId={sessionId} directory={directory} /> : null}
|
||||
{sectionVisible('tasks') ? <WorkStatusTasksSection sessionId={sessionId} directory={directory} /> : null}
|
||||
{sectionVisible('mcp') ? <WorkStatusMcpSection directory={directory} /> : null}
|
||||
|
||||
@@ -20,9 +20,8 @@ import {
|
||||
/**
|
||||
* Which sections the work-status panel may show.
|
||||
*
|
||||
* Everything is on by default and the choice is stored as the *hidden* set, so
|
||||
* a section added in a later release appears for everyone rather than staying
|
||||
* invisible to whoever had saved settings before it existed.
|
||||
* Choices are stored as the hidden set. Telemetry is opt-in; Show all is an
|
||||
* explicit choice to enable it along with the other sections.
|
||||
*/
|
||||
export const WorkStatusSectionsDialog: React.FC<{
|
||||
open: boolean;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
import { createOpencodeClient, type AssistantMessage, type Session, type UserMessage } from '@opencode-ai/sdk/v2';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { SyncProvider } from '@/sync/sync-context';
|
||||
import { getSyncChildStores } from '@/sync/sync-refs';
|
||||
import { getSyncPerformanceDiagnostics, resetSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from '@/sync/performance-diagnostics';
|
||||
let WorkStatusTelemetrySection: typeof import('./WorkStatusTelemetrySection').WorkStatusTelemetrySection;
|
||||
|
||||
const directory = '/repo';
|
||||
const sessionId = 'session-1';
|
||||
const user: UserMessage = { id: 'user-1', sessionID: sessionId, role: 'user', time: { created: 1000 }, agent: 'build', model: { providerID: 'test', modelID: 'test' } };
|
||||
const session: Session = { id: sessionId, slug: 'test', projectID: 'project', directory, title: 'test', version: '1', time: { created: 0, updated: 1 } };
|
||||
let tokenReads = 0;
|
||||
const assistant: AssistantMessage = {
|
||||
id: 'assistant-final', sessionID: sessionId, role: 'assistant', parentID: user.id,
|
||||
agent: 'build', mode: 'build', providerID: 'test', modelID: 'test', path: { cwd: directory, root: directory },
|
||||
time: { created: 2000, completed: 7000 }, cost: 0.01,
|
||||
get tokens() { tokenReads += 1; return { input: 100, output: 20, reasoning: 10, cache: { read: 40, write: 0 } }; },
|
||||
};
|
||||
|
||||
const DOM_GLOBAL_NAMES = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLIFrameElement', 'localStorage', 'getComputedStyle', 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame', 'IS_REACT_ACT_ENVIRONMENT'] as const;
|
||||
const installDom = () => {
|
||||
const win = new Window({ url: 'http://localhost' });
|
||||
const previous = DOM_GLOBAL_NAMES.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const);
|
||||
const values = { window: win, document: win.document, navigator: win.navigator, Node: win.Node, Element: win.Element,
|
||||
HTMLElement: win.HTMLElement, HTMLIFrameElement: win.HTMLIFrameElement, localStorage: win.localStorage,
|
||||
getComputedStyle: win.getComputedStyle.bind(win), ResizeObserver: win.ResizeObserver,
|
||||
requestAnimationFrame: win.requestAnimationFrame.bind(win), cancelAnimationFrame: win.cancelAnimationFrame.bind(win), IS_REACT_ACT_ENVIRONMENT: true };
|
||||
for (const name of DOM_GLOBAL_NAMES) Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true });
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
return { container, restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
void win.happyDOM.close();
|
||||
} };
|
||||
};
|
||||
|
||||
describe('mounted turn telemetry with live sync stores', () => {
|
||||
let root: Root;
|
||||
let dom: ReturnType<typeof installDom>;
|
||||
let messageRequests = 0;
|
||||
// Keep bootstrap pending so each test controls real store publications. No
|
||||
// hook/module replacements: subscription and materialization paths are real.
|
||||
const sdk = createOpencodeClient({ baseUrl: 'http://telemetry.test', fetch: (request) => {
|
||||
const url = new URL(request instanceof Request ? request.url : request.toString());
|
||||
if (/\/session\/[^/]+\/message$/.test(url.pathname)) messageRequests += 1;
|
||||
return new Promise<Response>(() => undefined);
|
||||
} });
|
||||
const render = async (visible = true, selectedDirectory = directory, selectedSession = sessionId) => {
|
||||
await act(async () => root.render(
|
||||
<SyncProvider sdk={sdk} directory={selectedDirectory}>
|
||||
<I18nProvider>{visible ? <WorkStatusTelemetrySection sessionId={selectedSession} directory={selectedDirectory} /> : null}</I18nProvider>
|
||||
</SyncProvider>,
|
||||
));
|
||||
};
|
||||
const store = (dir = directory) => {
|
||||
const result = getSyncChildStores().getChild(dir);
|
||||
if (!result) throw new Error('Expected mounted directory store');
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dom = installDom();
|
||||
({ WorkStatusTelemetrySection } = await import('./WorkStatusTelemetrySection'));
|
||||
root = createRoot(dom.container);
|
||||
tokenReads = 0;
|
||||
messageRequests = 0;
|
||||
setSyncPerformanceDiagnosticsEnabled(true);
|
||||
useUIStore.setState({ workStatusExpandedSections: {}, workStatusHiddenSections: ['telemetry'], workStatusHiddenSectionsExplicit: false });
|
||||
await render();
|
||||
await act(async () => store().setState({ session: [session], message: { [sessionId]: [user, assistant] }, part: { [assistant.id]: [] }, session_status: {} }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
setSyncPerformanceDiagnosticsEnabled(false);
|
||||
dom.restore();
|
||||
});
|
||||
|
||||
test('waits for authority, then shows actual token values even when idle is omitted from the snapshot', async () => {
|
||||
expect(dom.container.textContent).toContain('Turn stats');
|
||||
expect(dom.container.textContent).not.toContain('Whole turn');
|
||||
await act(async () => store().setState({ sessionStatusReady: true }));
|
||||
expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
expect(dom.container.textContent).toContain('100 ↑ · 30 ↓');
|
||||
const heading = dom.container.querySelector('button');
|
||||
if (!heading) throw new Error('Expected section heading');
|
||||
expect(heading.textContent).toBe('Turn stats');
|
||||
expect(heading.querySelectorAll('svg').length).toBe(2);
|
||||
expect(dom.container.querySelectorAll('svg').length).toBe(2);
|
||||
expect(tokenReads > 0).toBe(true);
|
||||
expect(messageRequests).toBe(0);
|
||||
});
|
||||
|
||||
test('collapsed remount keeps a usable header and reopening reads fresh data', async () => {
|
||||
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
|
||||
const button = dom.container.querySelector('button');
|
||||
if (!button) throw new Error('Expected collapse button');
|
||||
await act(async () => button.click());
|
||||
await render(false);
|
||||
await render();
|
||||
expect(dom.container.querySelector('button')?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(dom.container.textContent).toContain('Turn stats');
|
||||
expect(dom.container.textContent).not.toContain('Whole turn');
|
||||
const reopen = dom.container.querySelector('button');
|
||||
if (!reopen) throw new Error('Expected reopen button');
|
||||
await act(async () => reopen.click());
|
||||
expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
});
|
||||
|
||||
test('busy, retry and collapsed updates do not notify records subscribers or aggregate tokens', async () => {
|
||||
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
|
||||
// Positive control: on idle, part replacement reaches the subscriber and calculator.
|
||||
tokenReads = 0;
|
||||
resetSyncPerformanceDiagnostics();
|
||||
await act(async () => store().setState({ part: { [assistant.id]: [] } }));
|
||||
expect(tokenReads > 0).toBe(true);
|
||||
expect((getSyncPerformanceDiagnostics()?.sessionMessageChangeCallbacks ?? 0) > 0).toBe(true);
|
||||
|
||||
for (const mode of ['busy', 'retry', 'collapsed'] as const) {
|
||||
await act(async () => {
|
||||
store().setState({ session_status: { [sessionId]: mode === 'retry'
|
||||
? { type: 'retry', attempt: 1, message: 'retry', next: 0 }
|
||||
: { type: mode === 'busy' ? 'busy' : 'idle' } } });
|
||||
useUIStore.getState().setWorkStatusSectionExpanded('telemetry', mode !== 'collapsed');
|
||||
});
|
||||
resetSyncPerformanceDiagnostics();
|
||||
tokenReads = 0;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
await act(async () => store().setState({ part: { [assistant.id]: [{ id: 'text', sessionID: sessionId,
|
||||
messageID: assistant.id, type: 'text', text: String(i), time: { start: 2500 } }] } }));
|
||||
}
|
||||
expect(tokenReads).toBe(0);
|
||||
expect(getSyncPerformanceDiagnostics()?.sessionMessageChangeCallbacks).toBe(0);
|
||||
if (mode === 'collapsed') expect(dom.container.textContent).toBe('Turn stats');
|
||||
else expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
}
|
||||
});
|
||||
|
||||
test('session and directory changes cannot retain another scope, including equal IDs', async () => {
|
||||
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
|
||||
expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
await render(true, directory, 'another-session');
|
||||
expect(dom.container.textContent).not.toContain('~6 tok/s');
|
||||
await render(true, '/another-repo');
|
||||
await act(async () => store('/another-repo').setState({ session_status: { [sessionId]: { type: 'busy' } } }));
|
||||
expect(dom.container.textContent).not.toContain('~6 tok/s');
|
||||
await render(true, directory);
|
||||
expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
});
|
||||
|
||||
test('same-ID corrections, partial history and reverts replace rather than cache stale stats', async () => {
|
||||
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } }, message: { [sessionId]: [assistant] } }));
|
||||
expect(dom.container.textContent).not.toContain('Whole turn');
|
||||
await act(async () => store().setState({ message: { [sessionId]: [user, assistant] } }));
|
||||
expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
const corrected = { ...assistant, tokens: { ...assistant.tokens, output: 90 } };
|
||||
await act(async () => store().setState({ message: { [sessionId]: [user, corrected] } }));
|
||||
expect(dom.container.textContent).toContain('~20 tok/s');
|
||||
await act(async () => store().setState({ session: [{ ...session, revert: { messageID: user.id } }] }));
|
||||
expect(dom.container.textContent).not.toContain('~20 tok/s');
|
||||
await act(async () => store().setState({ session: [session] }));
|
||||
expect(dom.container.textContent).toContain('~20 tok/s');
|
||||
await act(async () => store().setState({ message: {} }));
|
||||
expect(dom.container.textContent).not.toContain('~20 tok/s');
|
||||
});
|
||||
|
||||
test('runtime identity changes discard retained results even with equal directory and session IDs', async () => {
|
||||
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'idle' } } }));
|
||||
await act(async () => store().setState({ session_status: { [sessionId]: { type: 'busy' } } }));
|
||||
expect(dom.container.textContent).toContain('~6 tok/s');
|
||||
Object.defineProperty(window, '__OPENCHAMBER_API_BASE_URL__', { value: 'https://second-runtime.test', configurable: true });
|
||||
await render();
|
||||
expect(dom.container.textContent).not.toContain('~6 tok/s');
|
||||
});
|
||||
|
||||
test('the heading shows response speed only, never whole-turn speed as a fallback', async () => {
|
||||
await act(async () => store().setState({ sessionStatusReady: true, part: { [assistant.id]: [
|
||||
{ id: 'text', type: 'text', sessionID: sessionId, messageID: assistant.id, text: 'Final reply', time: { start: 3000, end: 5000 } },
|
||||
] } }));
|
||||
expect(dom.container.querySelector('button')?.textContent).toBe('Turn stats~10 tok/s');
|
||||
expect(dom.container.textContent).toContain('Response~10 tok/s');
|
||||
expect(dom.container.textContent).toContain('Whole turn~6 tok/s');
|
||||
await act(async () => store().setState({ part: { [assistant.id]: [] } }));
|
||||
expect(dom.container.querySelector('button')?.textContent).toBe('Turn stats');
|
||||
expect(dom.container.textContent).not.toContain('Response');
|
||||
expect(dom.container.textContent).toContain('Whole turn~6 tok/s');
|
||||
});
|
||||
|
||||
test('every metric has a full-row focus target and hover waits 750ms', async () => {
|
||||
const earlier = { ...assistant, id: 'earlier', time: { created: 1100, completed: 1900 } };
|
||||
await act(async () => store().setState({ sessionStatusReady: true,
|
||||
message: { [sessionId]: [user, earlier, assistant] }, part: {
|
||||
earlier: [{ id: 'earlier-text', type: 'text', sessionID: sessionId, messageID: earlier.id, text: 'Earlier', time: { start: 1200, end: 1800 } }],
|
||||
[assistant.id]: [{ id: 'text', type: 'text', sessionID: sessionId, messageID: assistant.id, text: 'Final reply', time: { start: 3000, end: 5000 } }],
|
||||
},
|
||||
}));
|
||||
const triggers = dom.container.querySelectorAll<HTMLElement>('[data-slot="tooltip-trigger"]');
|
||||
expect(triggers.length).toBe(9);
|
||||
for (const trigger of triggers) expect(trigger.tabIndex).toBe(0);
|
||||
expect(dom.container.querySelectorAll('[title]').length).toBe(0);
|
||||
const response = triggers[0];
|
||||
await act(async () => {
|
||||
response.dispatchEvent(new window.PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' }));
|
||||
response.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true }));
|
||||
response.dispatchEvent(new window.MouseEvent('mouseenter', { bubbles: true }));
|
||||
response.dispatchEvent(new window.MouseEvent('mousemove', { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
});
|
||||
expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull();
|
||||
expect(response.hasAttribute('data-popup-open')).toBe(false);
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)); });
|
||||
expect(response.hasAttribute('data-popup-open')).toBe(true);
|
||||
expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain('How fast the final text arrived');
|
||||
expect(dom.container.querySelector('[data-slot="tooltip-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('keyboard focus exposes the cost explanation without adding an icon or native title', async () => {
|
||||
await act(async () => store().setState({ sessionStatusReady: true }));
|
||||
const triggers = dom.container.querySelectorAll<HTMLElement>('[data-slot="tooltip-trigger"]');
|
||||
const cost = triggers[triggers.length - 1];
|
||||
await act(async () => cost.focus());
|
||||
expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain('Cost reported by the provider');
|
||||
expect(cost.querySelector('svg')).toBeNull();
|
||||
expect(cost.hasAttribute('title')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useDirectorySync, useSessionMessageRecords, useSyncDirectory, useSyncRuntime } from '@/sync/sync-context';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
WorkStatusCollapsibleSection,
|
||||
WorkStatusRow,
|
||||
WorkStatusValue,
|
||||
} from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import {
|
||||
formatTelemetryDuration,
|
||||
formatTelemetryTokens,
|
||||
formatThroughputRate,
|
||||
getLatestCompletedTurnStats,
|
||||
type CompletedTurnStats,
|
||||
} from './telemetry';
|
||||
|
||||
type Props = {
|
||||
sessionId: string | null;
|
||||
directory: string | null;
|
||||
};
|
||||
|
||||
/** One hover/focus target covers both the label and its value. */
|
||||
const TelemetryRow: React.FC<{ label: string; description: string; value: React.ReactNode }> = ({ label, description, value }) => (
|
||||
<Tooltip delayDuration={750}>
|
||||
<TooltipTrigger asChild>
|
||||
<div tabIndex={0} className="min-w-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<WorkStatusRow label={label} value={value} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8} className="max-w-[min(320px,calc(100vw-24px))] whitespace-normal break-words text-left">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p>{description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export const WorkStatusTelemetrySection: React.FC<Props> = ({ sessionId, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const expanded = useUIStore(
|
||||
React.useCallback((state) => state.workStatusExpandedSections['telemetry'] ?? true, []),
|
||||
);
|
||||
const { runtimeKey } = useSyncRuntime();
|
||||
const syncDirectory = useSyncDirectory();
|
||||
const scope = JSON.stringify([runtimeKey, normalizePath(directory ?? syncDirectory), sessionId]);
|
||||
const status = useDirectorySync(
|
||||
React.useCallback((state) => sessionId
|
||||
? state.session_status[sessionId]?.type ?? (state.sessionStatusReady ? 'idle' : 'unknown')
|
||||
: 'unknown', [sessionId]),
|
||||
directory ?? undefined,
|
||||
);
|
||||
const eligibleForStats = Boolean(sessionId && expanded && status === 'idle');
|
||||
|
||||
const records = useSessionMessageRecords(
|
||||
sessionId ?? '',
|
||||
directory ?? undefined,
|
||||
{ enabled: eligibleForStats },
|
||||
);
|
||||
|
||||
const computed = React.useMemo(() => {
|
||||
if (!eligibleForStats) return null;
|
||||
return getLatestCompletedTurnStats(records);
|
||||
}, [eligibleForStats, records]);
|
||||
|
||||
// Retain only one committed result, never message history or a global ID cache.
|
||||
const [retained, setRetained] = React.useState<{ scope: string; stats: CompletedTurnStats | null } | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (eligibleForStats) {
|
||||
setRetained({ scope, stats: computed });
|
||||
} else {
|
||||
setRetained((previous) => previous?.scope === scope && status !== 'unknown' ? previous : null);
|
||||
}
|
||||
}, [scope, status, eligibleForStats, computed]);
|
||||
const stats = eligibleForStats ? computed : status !== 'unknown' && retained?.scope === scope ? retained.stats : null;
|
||||
const summary = stats && stats.responseTokensPerSecond !== null
|
||||
? formatThroughputRate(stats.responseTokensPerSecond)
|
||||
: undefined;
|
||||
|
||||
useReportWorkStatusPresence('telemetry', Boolean(sessionId));
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
return (
|
||||
<WorkStatusCollapsibleSection
|
||||
id="telemetry"
|
||||
title={t('chat.workStatus.section.telemetry')}
|
||||
icon="bar-chart-2"
|
||||
summary={summary}
|
||||
defaultExpanded
|
||||
>
|
||||
{stats ? (
|
||||
<>
|
||||
{stats.responseTokensPerSecond !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.responseSpeed')}
|
||||
description={t('chat.workStatus.telemetry.responseSpeedDescription')}
|
||||
value={<WorkStatusValue>{formatThroughputRate(stats.responseTokensPerSecond)}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
{stats.tokensPerSecond !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.speed')}
|
||||
description={t('chat.workStatus.telemetry.speedDescription')}
|
||||
value={<WorkStatusValue>{formatThroughputRate(stats.tokensPerSecond)}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.totalLlmDurationMs !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.llmDuration')}
|
||||
description={t('chat.workStatus.telemetry.llmDurationDescription')}
|
||||
value={<WorkStatusValue>{formatTelemetryDuration(stats.totalLlmDurationMs)}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.totalToolDurationMs !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.toolDuration')}
|
||||
description={t('chat.workStatus.telemetry.toolDurationDescription')}
|
||||
value={<WorkStatusValue>{formatTelemetryDuration(stats.totalToolDurationMs)}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.avgTtftMs !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.ttft')}
|
||||
description={t('chat.workStatus.telemetry.ttftDescription')}
|
||||
value={<WorkStatusValue>{formatTelemetryDuration(stats.avgTtftMs)}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.stepsCount > 1 ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.steps')}
|
||||
description={t('chat.workStatus.telemetry.stepsDescription')}
|
||||
value={<WorkStatusValue>{stats.stepsCount}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.inputTokens !== null && stats.outputTokens !== null && stats.reasoningTokens !== null && stats.totalGeneratedTokens !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.tokens')}
|
||||
description={t('chat.workStatus.telemetry.tokensDescription', {
|
||||
input: stats.inputTokens.toLocaleString(getCurrentIntlLocale()),
|
||||
output: stats.outputTokens.toLocaleString(getCurrentIntlLocale()),
|
||||
reasoning: stats.reasoningTokens.toLocaleString(getCurrentIntlLocale()),
|
||||
})}
|
||||
value={(
|
||||
<WorkStatusValue>
|
||||
{t('chat.workStatus.telemetry.tokens.inOut', {
|
||||
input: formatTelemetryTokens(stats.inputTokens),
|
||||
output: formatTelemetryTokens(stats.totalGeneratedTokens),
|
||||
})}
|
||||
</WorkStatusValue>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.cacheHitPercent !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.cacheHit')}
|
||||
description={t('chat.workStatus.telemetry.cacheHitDescription')}
|
||||
value={(
|
||||
<WorkStatusValue tone={stats.cacheHitPercent >= 50 ? 'success' : 'default'}>
|
||||
{`${stats.cacheHitPercent}%`}
|
||||
</WorkStatusValue>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stats.cost !== null ? (
|
||||
<TelemetryRow
|
||||
label={t('chat.workStatus.telemetry.cost')}
|
||||
description={t('chat.workStatus.telemetry.costDescription')}
|
||||
value={<WorkStatusValue tone="muted">{`$${stats.cost.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}`}</WorkStatusValue>}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</WorkStatusCollapsibleSection>
|
||||
);
|
||||
};
|
||||
@@ -111,9 +111,9 @@ describe('sanitizeWorkStatusHiddenSections', () => {
|
||||
expect(sanitizeWorkStatusHiddenSections(['usage', 'usage'])).toEqual(['usage']);
|
||||
});
|
||||
|
||||
test('treats a non-array payload as no preference', () => {
|
||||
expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]);
|
||||
expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]);
|
||||
expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]);
|
||||
test('treats a non-array payload as default hidden preference', () => {
|
||||
expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual(['telemetry']);
|
||||
expect(sanitizeWorkStatusHiddenSections('usage')).toEqual(['telemetry']);
|
||||
expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual(['telemetry']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export const WORK_STATUS_SECTION_IDS = [
|
||||
'session',
|
||||
'repository',
|
||||
'usage',
|
||||
'telemetry',
|
||||
'subagents',
|
||||
'tasks',
|
||||
'mcp',
|
||||
@@ -23,16 +24,17 @@ export const WORK_STATUS_SECTION_IDS = [
|
||||
|
||||
type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number];
|
||||
|
||||
export const WORK_STATUS_SECTION_LABEL_KEYS: Record<WorkStatusSectionId, I18nKey> = {
|
||||
export const WORK_STATUS_SECTION_LABEL_KEYS = {
|
||||
session: 'chat.workStatus.section.session',
|
||||
repository: 'chat.workStatus.section.project',
|
||||
usage: 'chat.workStatus.section.usage',
|
||||
telemetry: 'chat.workStatus.section.telemetry',
|
||||
subagents: 'chat.workStatus.section.subagents',
|
||||
tasks: 'chat.workStatus.section.tasks',
|
||||
mcp: 'chat.workStatus.section.mcp',
|
||||
pinned: 'chat.workStatus.section.pinned',
|
||||
contextSources: 'chat.workStatus.section.contextBreakdown',
|
||||
};
|
||||
} as const satisfies Record<WorkStatusSectionId, I18nKey>;
|
||||
|
||||
const KNOWN_IDS = new Set<string>(WORK_STATUS_SECTION_IDS);
|
||||
|
||||
@@ -40,9 +42,8 @@ const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId =>
|
||||
typeof value === 'string' && KNOWN_IDS.has(value);
|
||||
|
||||
/**
|
||||
* Hidden sections are stored, not visible ones: everything is on by default, so
|
||||
* an empty list means "the user has changed nothing" and a section added later
|
||||
* appears without touching anyone's saved settings.
|
||||
* Hidden sections are stored, not visible ones. Telemetry is opt-in; legacy
|
||||
* lists must be normalized before use so adding it does not enable it.
|
||||
*/
|
||||
export const isWorkStatusSectionVisible = (
|
||||
hidden: readonly string[] | null | undefined,
|
||||
@@ -75,11 +76,16 @@ export const getWorkStatusPanelPresentation = ({
|
||||
showEmptyState: contentMounted && allSectionsHidden,
|
||||
});
|
||||
|
||||
export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const WORK_STATUS_DEFAULT_HIDDEN_SECTIONS = [
|
||||
'telemetry',
|
||||
] as const satisfies readonly WorkStatusSectionId[];
|
||||
|
||||
export const sanitizeWorkStatusHiddenSections = (value: unknown, explicit = true): WorkStatusSectionId[] => {
|
||||
if (!Array.isArray(value)) return [...WORK_STATUS_DEFAULT_HIDDEN_SECTIONS];
|
||||
const seen = new Set<WorkStatusSectionId>();
|
||||
for (const entry of value) {
|
||||
if (isWorkStatusSectionId(entry)) seen.add(entry);
|
||||
}
|
||||
if (!explicit) seen.add('telemetry');
|
||||
return [...seen];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { AssistantMessage, Part, TextPart, UserMessage } from '@opencode-ai/sdk/v2';
|
||||
import { formatTelemetryDuration, formatTelemetryTokens, formatThroughputRate, getLatestCompletedTurnStats, mergeTimeIntervals, sumIntervalsDuration } from './telemetry';
|
||||
|
||||
const user: UserMessage = { id: 'u1', sessionID: 'session-1', role: 'user', time: { created: 0 }, agent: 'build', model: { providerID: 'test', modelID: 'test' } };
|
||||
const assistant = (overrides: Partial<AssistantMessage> = {}): AssistantMessage => ({
|
||||
id: 'a1', sessionID: 'session-1', role: 'assistant', parentID: user.id,
|
||||
agent: 'build', mode: 'build', providerID: 'test', modelID: 'test', path: { cwd: '/repo', root: '/repo' },
|
||||
time: { created: 1000, completed: 5000 }, cost: 0,
|
||||
tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
...overrides,
|
||||
});
|
||||
const tool = (start: number, end: number): Part => ({
|
||||
id: `tool-${start}`, sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'call',
|
||||
state: { status: 'completed', input: {}, output: '', title: 'test', metadata: {}, time: { start, end } },
|
||||
});
|
||||
const text = (start: number): TextPart => ({ id: `text-${start}`, sessionID: user.sessionID, messageID: 'a1', type: 'text', text: '', time: { start } });
|
||||
const turn = (info = assistant(), parts: Part[] = []) => [{ info: user, parts: [] }, { info, parts }];
|
||||
|
||||
describe('turn telemetry', () => {
|
||||
test('merges unsorted parallel, nested, adjoining and invalid tool intervals', () => {
|
||||
expect(mergeTimeIntervals([])).toEqual([]);
|
||||
const merged = mergeTimeIntervals([[3000, 4000], [1000, 3000], [1500, 2500], [6000, 7000], [NaN, 1], [9, 8]]);
|
||||
expect(merged).toEqual([[1000, 4000], [6000, 7000]]);
|
||||
expect(sumIntervalsDuration(merged)).toBe(4000);
|
||||
});
|
||||
|
||||
test('formats durations, counts and approximate throughput', () => {
|
||||
expect(formatTelemetryDuration(0)).toBe('0.0s');
|
||||
expect(formatTelemetryDuration(1234)).toBe('1.2s');
|
||||
expect(formatTelemetryDuration(84000)).toBe('1m24s');
|
||||
expect(formatTelemetryTokens(0)).toBe('0');
|
||||
expect(formatTelemetryTokens(500)).toBe('500');
|
||||
expect(formatTelemetryTokens(1234)).toBe('1.2K');
|
||||
expect(formatTelemetryTokens(1500000)).toBe('1.5M');
|
||||
expect(formatThroughputRate(52.3)).toBe('~52 tok/s');
|
||||
});
|
||||
|
||||
test('aggregates a multi-step turn, subtracting the tool union and including reasoning tokens', () => {
|
||||
const records = turn(assistant({
|
||||
time: { created: 10000, completed: 20000 }, cost: 0.01,
|
||||
tokens: { input: 1000, output: 200, reasoning: 300, cache: { read: 2000, write: 0 } },
|
||||
}), [text(11500), tool(13000, 15000), tool(14000, 16000)]);
|
||||
records.push({ info: assistant({ id: 'a2', time: { created: 21000, completed: 24000 }, cost: 0.005,
|
||||
tokens: { input: 1500, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}), parts: [text(21500)] });
|
||||
const stats = getLatestCompletedTurnStats(records);
|
||||
expect(stats).toEqual({ stepsCount: 2, lastAssistantMessageId: 'a2', totalToolDurationMs: 3000,
|
||||
totalLlmDurationMs: 10000, outputTokens: 300, reasoningTokens: 300, totalGeneratedTokens: 600,
|
||||
inputTokens: 2500, cost: 0.015, tokensPerSecond: 60, responseTokensPerSecond: null, avgTtftMs: 1000, cacheHitPercent: 44 });
|
||||
});
|
||||
|
||||
test('uses only the latest user-bounded turn', () => {
|
||||
const records = [...turn(), ...turn(assistant({ id: 'new' }))];
|
||||
expect(getLatestCompletedTurnStats(records)?.stepsCount).toBe(1);
|
||||
expect(getLatestCompletedTurnStats(records)?.lastAssistantMessageId).toBe('new');
|
||||
});
|
||||
|
||||
test('does not publish unfinished or truncated turns, or substitute older results', () => {
|
||||
expect(getLatestCompletedTurnStats(null)).toBeNull();
|
||||
expect(getLatestCompletedTurnStats([])).toBeNull();
|
||||
expect(getLatestCompletedTurnStats([{ info: assistant(), parts: [] }])).toBeNull();
|
||||
expect(getLatestCompletedTurnStats([...turn(), { info: user, parts: [] }])).toBeNull();
|
||||
expect(getLatestCompletedTurnStats([...turn(), ...turn(assistant({ time: { created: 1000 } }))])).toBeNull();
|
||||
expect(getLatestCompletedTurnStats([
|
||||
...turn(assistant({ time: { created: 1000 } })), { info: assistant({ id: 'a2' }), parts: [] },
|
||||
])).toBeNull();
|
||||
});
|
||||
|
||||
test('recomputes after history materializes and after same-ID message or part corrections', () => {
|
||||
const info = assistant();
|
||||
expect(getLatestCompletedTurnStats([{ info, parts: [] }])).toBeNull();
|
||||
expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBe(25);
|
||||
expect(getLatestCompletedTurnStats(turn({ ...info, tokens: { ...info.tokens, output: 200 } }))?.tokensPerSecond).toBe(50);
|
||||
expect(getLatestCompletedTurnStats(turn(info, [tool(2000, 4000)]))?.tokensPerSecond).toBe(50);
|
||||
// A second directory/runtime may reuse IDs but must never reuse the result.
|
||||
expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBe(25);
|
||||
});
|
||||
|
||||
test('missing usage in one step invalidates whole-turn usage, not valid durations', () => {
|
||||
const missing = assistant({ id: 'a2', time: { created: 5000, completed: 6000 } });
|
||||
Reflect.deleteProperty(missing, 'tokens');
|
||||
Reflect.deleteProperty(missing, 'cost');
|
||||
const stats = getLatestCompletedTurnStats([...turn(), { info: missing, parts: [] }]);
|
||||
expect(stats?.stepsCount).toBe(2);
|
||||
expect(stats?.totalLlmDurationMs).toBe(5000);
|
||||
expect(stats?.tokensPerSecond).toBeNull();
|
||||
expect(stats?.inputTokens).toBeNull();
|
||||
expect(stats?.cost).toBeNull();
|
||||
});
|
||||
|
||||
test('missing reasoning is not treated as zero and invalid token counts are not summed', () => {
|
||||
const info = assistant();
|
||||
Reflect.deleteProperty(info.tokens, 'reasoning');
|
||||
expect(getLatestCompletedTurnStats(turn(info))?.tokensPerSecond).toBeNull();
|
||||
for (const output of [-1, NaN, Infinity]) {
|
||||
expect(getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output } })))?.totalGeneratedTokens).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves genuine zero usage, cache hits and cost', () => {
|
||||
const stats = getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output: 0 } })));
|
||||
expect(stats?.tokensPerSecond).toBe(0);
|
||||
expect(stats?.cost).toBe(0);
|
||||
expect(stats?.cacheHitPercent).toBe(0);
|
||||
});
|
||||
|
||||
test('includes failed tools and chooses the earliest text or reasoning timestamp', () => {
|
||||
const failed: Part = { id: 'failed', sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'failed',
|
||||
state: { status: 'error', input: {}, error: 'failed', time: { start: 2500, end: 4000 } } };
|
||||
const reasoning: Part = { id: 'reasoning', sessionID: user.sessionID, messageID: 'a1', type: 'reasoning', text: '', time: { start: 1200 } };
|
||||
const stats = getLatestCompletedTurnStats(turn(assistant(), [text(1600), reasoning, tool(2000, 3000), failed]));
|
||||
expect(stats?.totalToolDurationMs).toBe(2000);
|
||||
expect(stats?.totalLlmDurationMs).toBe(2000);
|
||||
expect(stats?.avgTtftMs).toBe(200);
|
||||
});
|
||||
|
||||
for (const [start, end] of [[0, 2000], [2000, 6000], [3000, 2000], [NaN, 3000]]) {
|
||||
test(`invalid tool interval ${start}..${end} omits duration-dependent metrics`, () => {
|
||||
const stats = getLatestCompletedTurnStats(turn(assistant(), [tool(start, end)]));
|
||||
expect(stats?.totalToolDurationMs).toBeNull();
|
||||
expect(stats?.totalLlmDurationMs).toBeNull();
|
||||
expect(stats?.tokensPerSecond).toBeNull();
|
||||
expect(stats?.outputTokens).toBe(100);
|
||||
});
|
||||
}
|
||||
|
||||
test('unfinished tools and missing tool timing cannot produce a rate', () => {
|
||||
const unfinished: Part = { id: 'pending', sessionID: user.sessionID, messageID: 'a1', type: 'tool', tool: 'bash', callID: 'pending',
|
||||
state: { status: 'pending', input: {}, raw: '' } };
|
||||
const missing = tool(2000, 3000);
|
||||
if (missing.type !== 'tool') throw new Error('Expected tool fixture');
|
||||
Reflect.deleteProperty(missing.state, 'time');
|
||||
expect(getLatestCompletedTurnStats(turn(assistant(), [unfinished]))?.tokensPerSecond).toBeNull();
|
||||
expect(getLatestCompletedTurnStats(turn(assistant(), [missing]))?.tokensPerSecond).toBeNull();
|
||||
});
|
||||
|
||||
test('invalid step time does not silently remove that step from totals', () => {
|
||||
const stats = getLatestCompletedTurnStats([...turn(), { info: assistant({ id: 'a2', time: { created: 6000, completed: 5000 } }), parts: [] }]);
|
||||
expect(stats?.stepsCount).toBe(2);
|
||||
expect(stats?.totalGeneratedTokens).toBe(200);
|
||||
expect(stats?.totalLlmDurationMs).toBeNull();
|
||||
expect(stats?.tokensPerSecond).toBeNull();
|
||||
});
|
||||
|
||||
test('separates final text delivery from whole-turn throughput on the measured tool-heavy shape', () => {
|
||||
const records = turn(assistant({
|
||||
time: { created: 1000, completed: 38438 },
|
||||
tokens: { ...assistant().tokens, output: 223 },
|
||||
}), [tool(19950, 38438)]);
|
||||
records.push({ info: assistant({ id: 'final', time: { created: 40000, completed: 45598 },
|
||||
tokens: { ...assistant().tokens, output: 338 },
|
||||
}), parts: [{ ...text(42661), text: 'Final answer', time: { start: 42661, end: 45442 } }] });
|
||||
const stats = getLatestCompletedTurnStats(records);
|
||||
expect(Math.round(stats?.tokensPerSecond ?? 0)).toBe(23);
|
||||
expect(Math.round(stats?.responseTokensPerSecond ?? 0)).toBe(122);
|
||||
});
|
||||
|
||||
test('measures the final text only, excluding reasoning tokens and their time', () => {
|
||||
const stats = getLatestCompletedTurnStats(turn(assistant({ tokens: { ...assistant().tokens, output: 260, reasoning: 100 } }), [
|
||||
{ id: 'reasoning', sessionID: user.sessionID, messageID: 'a1', type: 'reasoning', text: 'Thinking', time: { start: 1200, end: 2000 } },
|
||||
{ ...text(2500), text: 'Final answer', time: { start: 2500, end: 4500 } },
|
||||
]));
|
||||
expect(stats?.responseTokensPerSecond).toBe(130);
|
||||
expect(stats?.tokensPerSecond).toBe(90);
|
||||
});
|
||||
|
||||
test('unions overlapping text intervals without mutating the authoritative parts', () => {
|
||||
const parts = [
|
||||
{ ...text(2000), text: 'First', time: { start: 2000, end: 3500 } },
|
||||
{ ...text(3000), text: 'Second', time: { start: 3000, end: 4000 } },
|
||||
];
|
||||
const stats = getLatestCompletedTurnStats(turn(assistant(), parts));
|
||||
expect(stats?.responseTokensPerSecond).toBe(50);
|
||||
expect(parts[0].time.end).toBe(3500);
|
||||
});
|
||||
|
||||
test('missing, partial or invalid response timing never falls back to whole-turn speed', () => {
|
||||
const invalidParts: Part[][] = [
|
||||
[], [{ ...text(2000), text: 'No end' }],
|
||||
[{ ...text(2000), text: 'Bad end', time: { start: 2000, end: 1000 } }],
|
||||
[{ ...text(2000), text: 'Late end', time: { start: 2000, end: 6000 } }],
|
||||
[{ ...text(2000), text: 'Zero span', time: { start: 2000, end: 2000 } }],
|
||||
[{ ...text(2000), text: 'Bad time', time: { start: NaN, end: 4000 } }],
|
||||
[{ ...text(2000), text: 'Synthetic', synthetic: true, time: { start: 2000, end: 4000 } }],
|
||||
[{ ...text(2000), text: 'Tool preface', time: { start: 2000, end: 3000 } }, tool(3000, 4000)],
|
||||
[{ ...text(2000), text: 'Timed', time: { start: 2000, end: 3000 } }, { ...text(3000), text: 'Untimed' }],
|
||||
];
|
||||
for (const parts of invalidParts) {
|
||||
const stats = getLatestCompletedTurnStats(turn(assistant(), parts));
|
||||
expect(stats?.responseTokensPerSecond).toBeNull();
|
||||
expect(stats?.tokensPerSecond !== null).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('response speed needs valid output usage and a successful final reply', () => {
|
||||
const parts = [{ ...text(2000), text: 'Final reply', time: { start: 2000, end: 4000 } }];
|
||||
const missingUsage = assistant();
|
||||
Reflect.deleteProperty(missingUsage.tokens, 'output');
|
||||
expect(getLatestCompletedTurnStats(turn(missingUsage, parts))?.responseTokensPerSecond).toBeNull();
|
||||
expect(getLatestCompletedTurnStats(turn(assistant({ error: { name: 'MessageAbortedError', data: { message: 'Stopped' } } }), parts))?.responseTokensPerSecond).toBeNull();
|
||||
expect(getLatestCompletedTurnStats(turn(assistant({ time: { created: NaN, completed: 5000 } }), parts))?.responseTokensPerSecond).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
|
||||
|
||||
type SessionMessageRecord = {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
type CompletedStepStats = {
|
||||
toolDurationMs: number | null;
|
||||
adjustedLlmDurationMs: number | null;
|
||||
ttftMs: number | null;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
reasoningTokens: number | null;
|
||||
cacheReadTokens: number | null;
|
||||
cacheWriteTokens: number | null;
|
||||
cost: number | null;
|
||||
};
|
||||
|
||||
export type CompletedTurnStats = {
|
||||
lastAssistantMessageId: string;
|
||||
stepsCount: number;
|
||||
totalLlmDurationMs: number | null;
|
||||
totalToolDurationMs: number | null;
|
||||
avgTtftMs: number | null;
|
||||
tokensPerSecond: number | null;
|
||||
responseTokensPerSecond: number | null;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
reasoningTokens: number | null;
|
||||
totalGeneratedTokens: number | null;
|
||||
cacheHitPercent: number | null;
|
||||
cost: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge an array of [start, end] time intervals into a disjoint union of intervals.
|
||||
* Correctly accounts for parallel / overlapping tool executions without double-counting.
|
||||
*/
|
||||
export function mergeTimeIntervals(intervals: readonly (readonly [number, number])[]): Array<[number, number]> {
|
||||
if (intervals.length === 0) return [];
|
||||
|
||||
const valid: Array<[number, number]> = [];
|
||||
for (const [start, end] of intervals) {
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
|
||||
valid.push([start, end]);
|
||||
}
|
||||
}
|
||||
|
||||
valid.sort((a, b) => a[0] - b[0]);
|
||||
if (valid.length === 0) return [];
|
||||
|
||||
const merged: Array<[number, number]> = [valid[0]];
|
||||
|
||||
for (let i = 1; i < valid.length; i += 1) {
|
||||
const current = valid[i];
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
if (current[0] <= last[1]) {
|
||||
last[1] = Math.max(last[1], current[1]);
|
||||
} else {
|
||||
merged.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the total duration spanned by an array of disjoint intervals.
|
||||
*/
|
||||
export function sumIntervalsDuration(intervals: readonly (readonly [number, number])[]): number {
|
||||
return intervals.reduce((sum, [start, end]) => sum + (end - start), 0);
|
||||
}
|
||||
|
||||
export const formatTelemetryDuration = (ms: number): string => {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return '0.0s';
|
||||
}
|
||||
if (ms < 60_000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.floor((ms % 60_000) / 1000);
|
||||
return `${minutes}m${seconds}s`;
|
||||
};
|
||||
|
||||
export const formatTelemetryTokens = (tokens: number): string => {
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||
return '0';
|
||||
}
|
||||
if (tokens >= 1_000_000) {
|
||||
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (tokens >= 1_000) {
|
||||
return `${(tokens / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return String(Math.round(tokens));
|
||||
};
|
||||
|
||||
export const formatThroughputRate = (tps: number): string => {
|
||||
return `~${Math.round(tps)} tok/s`;
|
||||
};
|
||||
|
||||
const nonnegative = (value: number | undefined): number | null =>
|
||||
value !== undefined && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
|
||||
const add = (left: number | null, right: number | null): number | null =>
|
||||
left === null || right === null ? null : nonnegative(left + right);
|
||||
|
||||
/** Text delivery rate for the final reply, not throughput of the agent loop. */
|
||||
function calculateResponseTokenRate(record: SessionMessageRecord): number | null {
|
||||
const { info, parts } = record;
|
||||
if (info.role !== 'assistant' || info.error || parts.some((part) => part.type === 'tool')) return null;
|
||||
const output = nonnegative(info.tokens?.output);
|
||||
const { created, completed } = info.time;
|
||||
if (output === null || completed === undefined || nonnegative(created) === null || nonnegative(completed) === null) return null;
|
||||
|
||||
const intervals: Array<[number, number]> = [];
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'text') continue;
|
||||
// Synthetic/ignored text cannot be matched to the provider's output count.
|
||||
if (part.synthetic || part.ignored) return null;
|
||||
if (!part.text) continue;
|
||||
const start = part.time?.start;
|
||||
const end = part.time?.end;
|
||||
if (start === undefined || end === undefined || !Number.isFinite(start) || !Number.isFinite(end)
|
||||
|| start < created || end > completed || end <= start) return null;
|
||||
intervals.push([start, end]);
|
||||
}
|
||||
const duration = sumIntervalsDuration(mergeTimeIntervals(intervals));
|
||||
return duration > 0 ? nonnegative(output / (duration / 1000)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate stats for a single completed assistant step.
|
||||
*/
|
||||
function calculateCompletedStepStats(record: SessionMessageRecord): CompletedStepStats | null {
|
||||
const { info, parts } = record;
|
||||
if (info.role !== 'assistant') return null;
|
||||
|
||||
const { created } = info.time;
|
||||
const completed = info.time.completed;
|
||||
|
||||
if (completed === undefined) return null;
|
||||
|
||||
const validWindow = nonnegative(created) !== null && nonnegative(completed) !== null && completed >= created;
|
||||
const totalDurationMs = validWindow ? nonnegative(completed - created) : null;
|
||||
|
||||
// An unfinished or invalid tool makes duration-dependent metrics unknown.
|
||||
const rawToolIntervals: Array<[number, number]> = [];
|
||||
let validTools = validWindow;
|
||||
for (const part of parts) {
|
||||
if (part.type !== 'tool') continue;
|
||||
if (part.state.status !== 'completed' && part.state.status !== 'error') {
|
||||
validTools = false;
|
||||
continue;
|
||||
}
|
||||
const start = part.state.time?.start;
|
||||
const end = part.state.time?.end;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < created || end > completed || end < start) {
|
||||
validTools = false;
|
||||
continue;
|
||||
}
|
||||
rawToolIntervals.push([start, end]);
|
||||
}
|
||||
|
||||
const toolDurationMs = validTools ? nonnegative(sumIntervalsDuration(mergeTimeIntervals(rawToolIntervals))) : null;
|
||||
const adjustedLlmDurationMs = totalDurationMs !== null && toolDurationMs !== null
|
||||
? nonnegative(totalDurationMs - toolDurationMs)
|
||||
: null;
|
||||
|
||||
// Measure TTFT from first text or reasoning part start timestamp
|
||||
let ttftMs: number | null = null;
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text' || part.type === 'reasoning') {
|
||||
const partStart = part.time?.start;
|
||||
if (validWindow && partStart !== undefined && Number.isFinite(partStart) && partStart >= created && partStart <= completed) {
|
||||
const delta = partStart - created;
|
||||
ttftMs = ttftMs === null ? delta : Math.min(ttftMs, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inputTokens = nonnegative(info.tokens?.input);
|
||||
const outputTokens = nonnegative(info.tokens?.output);
|
||||
const reasoningTokens = nonnegative(info.tokens?.reasoning);
|
||||
const cacheReadTokens = nonnegative(info.tokens?.cache?.read);
|
||||
const cacheWriteTokens = nonnegative(info.tokens?.cache?.write);
|
||||
const cost = nonnegative(info.cost);
|
||||
|
||||
return {
|
||||
toolDurationMs,
|
||||
adjustedLlmDurationMs,
|
||||
ttftMs,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
cost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates telemetry metrics for the latest completed turn in the session.
|
||||
* A turn encompasses all assistant steps since the preceding user message up to the final completed assistant step.
|
||||
*/
|
||||
export function getLatestCompletedTurnStats(
|
||||
records: readonly SessionMessageRecord[] | null | undefined,
|
||||
): CompletedTurnStats | null {
|
||||
if (!records || records.length === 0) return null;
|
||||
|
||||
// Only the newest user-bounded turn qualifies. A partial newer turn must not
|
||||
// be published as complete or silently replaced with an older turn's stats.
|
||||
const lastCompletedAssistantIdx = records.length - 1;
|
||||
if (records[lastCompletedAssistantIdx].info.role !== 'assistant') return null;
|
||||
let turnStartIdx = -1;
|
||||
for (let i = records.length - 1; i >= 0; i -= 1) {
|
||||
const record = records[i];
|
||||
if (record.info.role === 'user') {
|
||||
turnStartIdx = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (turnStartIdx === -1) return null;
|
||||
|
||||
const stepStatsList: CompletedStepStats[] = [];
|
||||
for (let i = turnStartIdx; i <= lastCompletedAssistantIdx; i += 1) {
|
||||
const record = records[i];
|
||||
if (record.info.role === 'assistant') {
|
||||
const stepStats = calculateCompletedStepStats(record);
|
||||
if (!stepStats) return null;
|
||||
stepStatsList.push(stepStats);
|
||||
}
|
||||
}
|
||||
|
||||
if (stepStatsList.length === 0) return null;
|
||||
|
||||
let totalLlmDurationMs: number | null = 0;
|
||||
let totalToolDurationMs: number | null = 0;
|
||||
let totalInputTokens: number | null = 0;
|
||||
let totalOutputTokens: number | null = 0;
|
||||
let totalReasoningTokens: number | null = 0;
|
||||
let totalCacheReadTokens: number | null = 0;
|
||||
let totalCacheWriteTokens: number | null = 0;
|
||||
let totalCost: number | null = 0;
|
||||
let totalTtft: number | null = 0;
|
||||
|
||||
for (const step of stepStatsList) {
|
||||
totalLlmDurationMs = add(totalLlmDurationMs, step.adjustedLlmDurationMs);
|
||||
totalToolDurationMs = add(totalToolDurationMs, step.toolDurationMs);
|
||||
totalInputTokens = add(totalInputTokens, step.inputTokens);
|
||||
totalOutputTokens = add(totalOutputTokens, step.outputTokens);
|
||||
totalReasoningTokens = add(totalReasoningTokens, step.reasoningTokens);
|
||||
totalCacheReadTokens = add(totalCacheReadTokens, step.cacheReadTokens);
|
||||
totalCacheWriteTokens = add(totalCacheWriteTokens, step.cacheWriteTokens);
|
||||
totalCost = add(totalCost, step.cost);
|
||||
totalTtft = add(totalTtft, step.ttftMs);
|
||||
}
|
||||
|
||||
const avgTtftMs = totalTtft === null ? null : totalTtft / stepStatsList.length;
|
||||
|
||||
const totalGeneratedTokens = add(totalOutputTokens, totalReasoningTokens);
|
||||
const tokensPerSecond = totalGeneratedTokens !== null && totalLlmDurationMs !== null && totalLlmDurationMs > 0
|
||||
? nonnegative(totalGeneratedTokens / (totalLlmDurationMs / 1000))
|
||||
: null;
|
||||
|
||||
const cacheHit = totalInputTokens !== null && totalCacheReadTokens !== null && totalCacheWriteTokens !== null ? computeCacheHitRate({
|
||||
input: totalInputTokens,
|
||||
cache: { read: totalCacheReadTokens, write: totalCacheWriteTokens },
|
||||
}) : null;
|
||||
|
||||
return {
|
||||
lastAssistantMessageId: records[lastCompletedAssistantIdx].info.id,
|
||||
stepsCount: stepStatsList.length,
|
||||
totalLlmDurationMs,
|
||||
totalToolDurationMs,
|
||||
avgTtftMs,
|
||||
tokensPerSecond,
|
||||
responseTokensPerSecond: calculateResponseTokenRate(records[lastCompletedAssistantIdx]),
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
reasoningTokens: totalReasoningTokens,
|
||||
totalGeneratedTokens,
|
||||
cacheHitPercent: cacheHit?.hasInput ? Math.round(cacheHit.percent) : null,
|
||||
cost: totalCost,
|
||||
};
|
||||
}
|
||||
@@ -698,6 +698,9 @@ export interface ProjectEntry {
|
||||
}
|
||||
|
||||
export interface SettingsPayload {
|
||||
workStatusPanelEnabled?: boolean;
|
||||
workStatusHiddenSections?: string[];
|
||||
workStatusHiddenSectionsExplicit?: boolean;
|
||||
themeId?: string;
|
||||
useSystemTheme?: boolean;
|
||||
themeVariant?: 'light' | 'dark';
|
||||
|
||||
@@ -10,6 +10,7 @@ type AppearanceSlice = {
|
||||
streamingAutoFollowEnabled: boolean;
|
||||
workStatusPanelEnabled: boolean;
|
||||
workStatusHiddenSections: string[];
|
||||
workStatusHiddenSectionsExplicit: boolean;
|
||||
sessionRecapEnabled: boolean;
|
||||
sessionSuggestionEnabled: boolean;
|
||||
sessionGoalEnabled: boolean;
|
||||
@@ -67,6 +68,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled,
|
||||
workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
|
||||
workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
|
||||
workStatusHiddenSectionsExplicit: useUIStore.getState().workStatusHiddenSectionsExplicit,
|
||||
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
|
||||
sessionSuggestionEnabled: useUIStore.getState().sessionSuggestionEnabled,
|
||||
sessionGoalEnabled: useUIStore.getState().sessionGoalEnabled,
|
||||
@@ -111,6 +113,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
|
||||
workStatusPanelEnabled: state.workStatusPanelEnabled,
|
||||
workStatusHiddenSections: state.workStatusHiddenSections,
|
||||
workStatusHiddenSectionsExplicit: state.workStatusHiddenSectionsExplicit,
|
||||
sessionRecapEnabled: state.sessionRecapEnabled,
|
||||
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
|
||||
sessionGoalEnabled: state.sessionGoalEnabled,
|
||||
@@ -156,8 +159,10 @@ export const startAppearanceAutoSave = (): void => {
|
||||
}
|
||||
// Compared by content: the store hands back a new array on every change,
|
||||
// so an identity check would push a write on unrelated store updates.
|
||||
if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000')) {
|
||||
if (current.workStatusHiddenSections.join('\u0000') !== previous.workStatusHiddenSections.join('\u0000')
|
||||
|| current.workStatusHiddenSectionsExplicit !== previous.workStatusHiddenSectionsExplicit) {
|
||||
diff.workStatusHiddenSections = current.workStatusHiddenSections;
|
||||
diff.workStatusHiddenSectionsExplicit = current.workStatusHiddenSectionsExplicit;
|
||||
}
|
||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||
diff.showReasoningTraces = current.showReasoningTraces;
|
||||
|
||||
@@ -79,6 +79,8 @@ export type DesktopSettings = {
|
||||
workStatusPanelEnabled?: boolean;
|
||||
/** Work-status panel sections the user switched off. */
|
||||
workStatusHiddenSections?: string[];
|
||||
/** True when the hidden-section list was explicitly chosen by the user. */
|
||||
workStatusHiddenSectionsExplicit?: boolean;
|
||||
collapsibleThinkingBlocks?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
|
||||
@@ -44,4 +44,27 @@ describe('i18n dictionaries', () => {
|
||||
expect(dictionary['common.language.japanese']).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('telemetry translations retain the numeric token placeholders', () => {
|
||||
for (const dictionary of Object.values(localeDictionaries)) {
|
||||
expect(dictionary['chat.workStatus.telemetry.tokens.inOut']).toContain('{input}');
|
||||
expect(dictionary['chat.workStatus.telemetry.tokens.inOut']).toContain('{output}');
|
||||
for (const parameter of ['input', 'output', 'reasoning']) {
|
||||
expect(dictionary['chat.workStatus.telemetry.tokensDescription']).toContain(`{${parameter}}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all telemetry rows have translated explanations and compact labels', () => {
|
||||
const metrics = ['responseSpeed', 'speed', 'llmDuration', 'toolDuration', 'ttft', 'steps', 'tokens', 'cacheHit', 'cost'] as const;
|
||||
for (const [locale, dictionary] of Object.entries(localeDictionaries)) {
|
||||
for (const metric of metrics) {
|
||||
const label = dictionary[`chat.workStatus.telemetry.${metric}`];
|
||||
const description = dictionary[`chat.workStatus.telemetry.${metric}Description`];
|
||||
expect(label.length <= 16).toBe(true);
|
||||
expect(description.length > 30).toBe(true);
|
||||
if (locale !== 'en') expect(description === enDict[`chat.workStatus.telemetry.${metric}Description`]).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3245,6 +3245,26 @@ export const dict = {
|
||||
'chat.workStatus.action.openPr': 'Pull Request öffnen',
|
||||
'chat.workStatus.action.openSubagent': '{name} öffnen',
|
||||
'chat.workStatus.section.usage': 'Nutzung',
|
||||
'chat.workStatus.section.telemetry': 'Turn-Statistiken',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Antwort',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'Wie schnell der abschließende Text ankam. Ohne anfängliche Wartezeit, Denken und frühere Werkzeugaufrufe. Eine Schätzung aus Textzeitstempeln, keine Geschwindigkeitsmessung des Anbieters.',
|
||||
'chat.workStatus.telemetry.speed': 'Anfrage',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Modellzeit',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Zeit aller Modellschritte einschließlich Warten auf Antworten. Die Werkzeuglaufzeit ist abgezogen. Das ist nicht nur die Zeit zur Texterzeugung.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Werkzeugzeit',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Laufzeit der Werkzeuge einschließlich fehlgeschlagener Aufrufe. Parallel laufende Werkzeuge zählen zeitlich nur einmal.',
|
||||
'chat.workStatus.telemetry.ttft': 'Mittlere TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Mittlere Wartezeit bis zum ersten Text oder Denkabschnitt jedes Modellschritts. Fehlt bei einem Schritt der Startzeitstempel, wird kein Wert angezeigt. Das ist bei reinen Werkzeugaufrufen häufig der Fall.',
|
||||
'chat.workStatus.telemetry.steps': 'Schritte',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Wie oft das Modell für diesen Prompt aufgerufen wurde. Werkzeugergebnisse lesen und den nächsten Schritt entscheiden erfordert meist einen weiteren Aufruf.',
|
||||
'chat.workStatus.telemetry.tokens': 'Tokens',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Erzeugte Tokens aller Schritte einschließlich Denken, geteilt durch die Zeit ohne Werkzeugausführung. Warten auf das Modell zählt mit, daher können viele kurze Aufrufe den Wert senken.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Eingabetokens ohne Cache: {input}. ↓ Erzeugte Tokens: {output} für Text und Werkzeugaufrufe plus {reasoning} zum Denken. Summen über alle Schritte dieses Prompts.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Cache',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Anteil der Eingabetokens, die über alle Schritte aus dem Prompt-Cache wiederverwendet wurden. Das kann Kosten und Wartezeit senken, ist aber kein Geschwindigkeitswert.',
|
||||
'chat.workStatus.telemetry.cost': 'Kosten',
|
||||
'chat.workStatus.telemetry.costDescription': 'Vom Anbieter gemeldete Kosten aller Modellschritte dieses Prompts in US-Dollar. Separate Subagent-Sitzungen sind nicht enthalten. Null kann ein kostenloses Modell oder fehlende Kostenangaben bedeuten.',
|
||||
'chat.workStatus.goal.open': 'Ziel verwalten',
|
||||
'chat.workStatus.goal.pause': 'Pausieren',
|
||||
'chat.workStatus.goal.resume': 'Fortsetzen',
|
||||
|
||||
@@ -3247,6 +3247,26 @@ export const dict = {
|
||||
'chat.workStatus.action.openPr': 'Open pull request',
|
||||
'chat.workStatus.action.openSubagent': 'Open {name}',
|
||||
'chat.workStatus.section.usage': 'Usage',
|
||||
'chat.workStatus.section.telemetry': 'Turn stats',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Response',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'How fast the final text arrived. Excludes the initial wait, reasoning, and earlier tool calls. An estimate from text timestamps, not a provider speed measurement.',
|
||||
'chat.workStatus.telemetry.speed': 'Whole turn',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Tokens generated across all steps, including reasoning, divided by time with tool execution removed. Waiting for the model still counts, so many short tool calls can lower this number.',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Model time',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Time spent on all model steps, including waiting for responses. Tool execution time is removed. This is not just time spent generating text.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Tool time',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Time spent running tools, including failed calls. Tools running at the same time are counted once, not added together.',
|
||||
'chat.workStatus.telemetry.ttft': 'Average TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Average wait before the first text or reasoning starts in each model step. Hidden when any step lacks a start timestamp, as tool-only steps often do.',
|
||||
'chat.workStatus.telemetry.steps': 'Steps',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'How many times the model was called for this prompt. Reading tool results and deciding what to do next usually takes another step.',
|
||||
'chat.workStatus.telemetry.tokens': 'Tokens',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Input without cached tokens: {input}. ↓ Generated tokens: {output} for text and tool calls, plus {reasoning} for reasoning. Totals cover all steps of this prompt.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Cache',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Share of input tokens reused from the prompt cache across all steps. Reusing context can reduce cost and waiting, but this is not a speed score.',
|
||||
'chat.workStatus.telemetry.cost': 'Cost',
|
||||
'chat.workStatus.telemetry.costDescription': 'Cost reported by the provider for all model steps of this prompt, in US dollars. Excludes separate subagent sessions. Zero can mean a free model or a provider that reports no charge.',
|
||||
'chat.workStatus.goal.open': 'Manage goal',
|
||||
'chat.workStatus.goal.pause': 'Pause',
|
||||
'chat.workStatus.goal.resume': 'Resume',
|
||||
|
||||
@@ -3248,6 +3248,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': 'Abrir pull request',
|
||||
'chat.workStatus.action.openSubagent': 'Abrir {name}',
|
||||
'chat.workStatus.section.usage': 'Uso',
|
||||
'chat.workStatus.section.telemetry': 'Estadísticas del turno',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Respuesta',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'La velocidad a la que llegó el texto final. Excluye la espera inicial, el razonamiento y las llamadas anteriores a herramientas. Es una estimación basada en las marcas de tiempo del texto, no una medición del proveedor.',
|
||||
'chat.workStatus.telemetry.speed': 'Solicitud',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Modelo',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Tiempo de todos los pasos del modelo, incluida la espera de respuestas. Se resta la ejecución de herramientas. No es solo el tiempo de generación de texto.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Herramientas',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Tiempo de ejecución de herramientas, incluidas las llamadas fallidas. Las herramientas que se ejecutan a la vez cuentan una sola vez.',
|
||||
'chat.workStatus.telemetry.ttft': 'TTFT medio',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Espera media hasta el primer texto o razonamiento de cada paso. Se oculta si falta la marca de inicio de algún paso, algo habitual en pasos que solo llaman a herramientas.',
|
||||
'chat.workStatus.telemetry.steps': 'Pasos',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Cuántas veces se llamó al modelo para este prompt. Leer el resultado de una herramienta y decidir qué hacer suele requerir otro paso.',
|
||||
'chat.workStatus.telemetry.tokens': 'Tokens',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Tokens generados en todos los pasos, incluido el razonamiento, divididos por el tiempo sin ejecución de herramientas. La espera del modelo sí cuenta, por lo que muchas llamadas cortas pueden reducir este valor.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Entrada sin tokens en caché: {input}. ↓ Generados: {output} para texto y llamadas a herramientas, más {reasoning} de razonamiento. Totales de todos los pasos de este prompt.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Caché',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Proporción de tokens de entrada reutilizados de la caché del prompt en todos los pasos. Reutilizar el contexto puede reducir el costo y la espera, pero no es una medida de velocidad.',
|
||||
'chat.workStatus.telemetry.cost': 'Costo',
|
||||
'chat.workStatus.telemetry.costDescription': 'Costo comunicado por el proveedor para todos los pasos de este prompt, en dólares estadounidenses. No incluye sesiones separadas de subagentes. Cero puede indicar un modelo gratuito o un proveedor que no informa del cobro.',
|
||||
'chat.workStatus.goal.open': 'Gestionar objetivo',
|
||||
'chat.workStatus.goal.pause': 'Pausar',
|
||||
'chat.workStatus.goal.resume': 'Reanudar',
|
||||
|
||||
@@ -3245,6 +3245,26 @@ export const dict = {
|
||||
'chat.workStatus.action.openPr': 'Ouvrir la pull request',
|
||||
'chat.workStatus.action.openSubagent': 'Ouvrir {name}',
|
||||
'chat.workStatus.section.usage': 'Utilisation',
|
||||
'chat.workStatus.section.telemetry': 'Stats du tour',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Réponse',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'La vitesse à laquelle le texte final est arrivé. Sans attente initiale, raisonnement ni appels précédents aux outils. Une estimation basée sur les horodatages du texte, pas une mesure du fournisseur.',
|
||||
'chat.workStatus.telemetry.speed': 'Requête',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Modèle',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Durée de toutes les étapes du modèle, attente des réponses comprise. Le temps des outils est soustrait. Ce ne sont pas uniquement les secondes de génération du texte.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Outils',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Temps passé à exécuter les outils, y compris les appels échoués. Les outils exécutés en parallèle ne sont comptés qu’une fois.',
|
||||
'chat.workStatus.telemetry.ttft': 'TTFT moyen',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Attente moyenne avant le premier texte ou raisonnement de chaque étape. Masquée si une étape manque d’horodatage de début, ce qui arrive souvent pour les appels aux outils sans texte.',
|
||||
'chat.workStatus.telemetry.steps': 'Étapes',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Nombre d’appels au modèle pour ce prompt. Lire les résultats d’un outil et décider de la suite demande généralement une nouvelle étape.',
|
||||
'chat.workStatus.telemetry.tokens': 'Jetons',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Jetons générés à toutes les étapes, raisonnement compris, divisés par la durée hors exécution des outils. L’attente du modèle compte, donc de nombreux appels courts peuvent réduire ce chiffre.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Entrée hors cache : {input}. ↓ Jetons générés : {output} pour le texte et les appels aux outils, plus {reasoning} pour le raisonnement. Totaux de toutes les étapes de ce prompt.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Cache',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Part des jetons d’entrée réutilisés depuis le cache du prompt, sur toutes les étapes. Réutiliser le contexte peut réduire le coût et l’attente, mais ce n’est pas un indice de vitesse.',
|
||||
'chat.workStatus.telemetry.cost': 'Coût',
|
||||
'chat.workStatus.telemetry.costDescription': 'Coût indiqué par le fournisseur pour toutes les étapes de ce prompt, en dollars américains. Les sessions séparées des sous-agents sont exclues. Zéro peut signifier un modèle gratuit ou un fournisseur sans indication de coût.',
|
||||
'chat.workStatus.goal.open': 'Gérer l’objectif',
|
||||
'chat.workStatus.goal.pause': 'Mettre en pause',
|
||||
'chat.workStatus.goal.resume': 'Reprendre',
|
||||
|
||||
@@ -3247,6 +3247,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': 'プルリクエストを開く',
|
||||
'chat.workStatus.action.openSubagent': '{name} を開く',
|
||||
'chat.workStatus.section.usage': '使用量',
|
||||
'chat.workStatus.section.telemetry': 'ターンの統計',
|
||||
'chat.workStatus.telemetry.responseSpeed': '回答',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': '最終テキストが届いた速さです。開始前の待ち時間、推論、先行するツール呼び出しは含みません。テキストの時刻から求めた推定値で、プロバイダー側の速度測定ではありません。',
|
||||
'chat.workStatus.telemetry.speed': 'リクエスト全体',
|
||||
'chat.workStatus.telemetry.llmDuration': 'モデル時間',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': '応答待ちを含む全モデルステップの時間です。ツール実行時間は差し引いています。テキスト生成だけの時間ではありません。',
|
||||
'chat.workStatus.telemetry.toolDuration': 'ツール時間',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': '失敗した呼び出しも含むツールの実行時間です。同時に動いたツールの時間は重複して加算しません。',
|
||||
'chat.workStatus.telemetry.ttft': '平均 TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': '各ステップで最初のテキストや推論が始まるまでの平均待ち時間です。開始時刻がないステップがあれば表示しません。ツール呼び出しのみのステップでは時刻がないことがあります。',
|
||||
'chat.workStatus.telemetry.steps': 'ステップ数',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'このプロンプトでモデルを呼び出した回数です。ツールの結果を読み、次の処理を決める際は通常もう一度呼び出します。',
|
||||
'chat.workStatus.telemetry.tokens': 'トークン',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': '推論を含む全ステップの生成トークン数を、ツール実行を除いた時間で割った値です。モデルの待ち時間は含むため、短い呼び出しが多いと低くなります。',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ キャッシュを除く入力: {input}。↓ 生成: テキストとツール呼び出し {output}、推論 {reasoning}。このプロンプトの全ステップの合計です。',
|
||||
'chat.workStatus.telemetry.cacheHit': 'キャッシュ',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': '全ステップの入力トークンのうち、プロンプトキャッシュから再利用した割合です。費用や待ち時間を減らせる場合がありますが、速度の指標ではありません。',
|
||||
'chat.workStatus.telemetry.cost': '費用',
|
||||
'chat.workStatus.telemetry.costDescription': 'このプロンプトの全モデルステップについてプロバイダーが報告した米ドル建ての費用です。別のサブエージェントセッションは含みません。ゼロは無料モデル、または費用の報告がない場合があります。',
|
||||
'chat.workStatus.goal.open': '目標を管理',
|
||||
'chat.workStatus.goal.pause': '一時停止',
|
||||
'chat.workStatus.goal.resume': '再開',
|
||||
|
||||
@@ -3247,6 +3247,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': '풀 리퀘스트 열기',
|
||||
'chat.workStatus.action.openSubagent': '{name} 열기',
|
||||
'chat.workStatus.section.usage': '사용량',
|
||||
'chat.workStatus.section.telemetry': '턴 통계',
|
||||
'chat.workStatus.telemetry.responseSpeed': '응답',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': '최종 텍스트가 도착한 속도입니다. 시작 전 대기, 추론, 이전 도구 호출은 제외합니다. 텍스트 시간 기록으로 계산한 추정치이며 제공자 측 속도 측정값은 아닙니다.',
|
||||
'chat.workStatus.telemetry.speed': '전체 요청',
|
||||
'chat.workStatus.telemetry.llmDuration': '모델 시간',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': '응답 대기를 포함한 모든 모델 단계의 시간입니다. 도구 실행 시간은 뺍니다. 텍스트 생성 시간만을 뜻하지는 않습니다.',
|
||||
'chat.workStatus.telemetry.toolDuration': '도구 시간',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': '실패한 호출을 포함한 도구 실행 시간입니다. 동시에 실행된 도구의 시간은 중복해서 더하지 않습니다.',
|
||||
'chat.workStatus.telemetry.ttft': '평균 TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': '각 모델 단계에서 첫 텍스트나 추론이 시작되기까지의 평균 대기 시간입니다. 시작 시간이 없는 단계가 있으면 표시하지 않습니다. 도구만 호출하는 단계에서 흔히 발생합니다.',
|
||||
'chat.workStatus.telemetry.steps': '단계',
|
||||
'chat.workStatus.telemetry.stepsDescription': '이 프롬프트에서 모델을 호출한 횟수입니다. 도구 결과를 읽고 다음 작업을 결정하려면 보통 한 단계가 더 필요합니다.',
|
||||
'chat.workStatus.telemetry.tokens': '토큰',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': '추론을 포함한 모든 단계의 생성 토큰 수를 도구 실행 시간을 뺀 시간으로 나눈 값입니다. 모델 대기 시간은 포함되므로 짧은 호출이 많으면 낮아질 수 있습니다.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ 캐시를 제외한 입력 토큰: {input}. ↓ 생성 토큰: 텍스트와 도구 호출 {output}, 추론 {reasoning}. 이 프롬프트의 모든 단계 합계입니다.',
|
||||
'chat.workStatus.telemetry.cacheHit': '캐시 적중률',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': '모든 단계의 입력 토큰 중 프롬프트 캐시에서 재사용한 비율입니다. 컨텍스트 재사용은 비용과 대기를 줄일 수 있지만 속도 점수는 아닙니다.',
|
||||
'chat.workStatus.telemetry.cost': '비용',
|
||||
'chat.workStatus.telemetry.costDescription': '제공자가 보고한 이 프롬프트의 모든 모델 단계 비용이며 미국 달러 기준입니다. 별도 하위 에이전트 세션은 제외합니다. 무료 모델이거나 제공자가 비용을 보고하지 않으면 0일 수 있습니다.',
|
||||
'chat.workStatus.goal.open': '목표 관리',
|
||||
'chat.workStatus.goal.pause': '일시정지',
|
||||
'chat.workStatus.goal.resume': '재개',
|
||||
|
||||
@@ -3264,6 +3264,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': 'Otwórz pull request',
|
||||
'chat.workStatus.action.openSubagent': 'Otwórz {name}',
|
||||
'chat.workStatus.section.usage': 'Zużycie',
|
||||
'chat.workStatus.section.telemetry': 'Statystyki tury',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Odpowiedź',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'Jak szybko docierał końcowy tekst. Bez początkowego oczekiwania, rozumowania i wcześniejszych wywołań narzędzi. To szacunek z czasów tekstu, a nie pomiar po stronie dostawcy.',
|
||||
'chat.workStatus.telemetry.speed': 'Całe żądanie',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Czas modelu',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Czas wszystkich kroków modelu wraz z oczekiwaniem na odpowiedzi. Czas narzędzi jest odjęty. To nie tylko czas generowania tekstu.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Narzędzia',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Czas wykonywania narzędzi, także nieudanych wywołań. Narzędzia działające równolegle liczymy czasowo tylko raz.',
|
||||
'chat.workStatus.telemetry.ttft': 'Średni TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Średnie oczekiwanie na pierwszy tekst lub rozumowanie w każdym kroku. Ukryte, gdy choć jeden krok nie ma czasu rozpoczęcia, co często dotyczy kroków z samymi narzędziami.',
|
||||
'chat.workStatus.telemetry.steps': 'Kroki',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Liczba wywołań modelu dla tego promptu. Odczytanie wyniku narzędzia i decyzja o dalszym działaniu zwykle wymaga kolejnego kroku.',
|
||||
'chat.workStatus.telemetry.tokens': 'Tokeny',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Tokeny wygenerowane we wszystkich krokach, także rozumowania, podzielone przez czas bez wykonywania narzędzi. Oczekiwanie na model nadal się liczy, więc wiele krótkich wywołań obniża ten wynik.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Wejście bez tokenów z pamięci podręcznej: {input}. ↓ Wygenerowane: {output} dla tekstu i wywołań narzędzi oraz {reasoning} dla rozumowania. Sumy ze wszystkich kroków tego promptu.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Pamięć podr.',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Udział tokenów wejściowych użytych ponownie z pamięci podręcznej promptu we wszystkich krokach. Może to zmniejszyć koszt i oczekiwanie, ale nie jest miarą szybkości.',
|
||||
'chat.workStatus.telemetry.cost': 'Koszt',
|
||||
'chat.workStatus.telemetry.costDescription': 'Koszt wszystkich kroków tego promptu zgłoszony przez dostawcę, w dolarach amerykańskich. Bez oddzielnych sesji subagentów. Zero może oznaczać darmowy model lub brak informacji o opłacie.',
|
||||
'chat.workStatus.goal.open': 'Zarządzaj celem',
|
||||
'chat.workStatus.goal.pause': 'Wstrzymaj',
|
||||
'chat.workStatus.goal.resume': 'Wznów',
|
||||
|
||||
@@ -3248,6 +3248,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': 'Abrir pull request',
|
||||
'chat.workStatus.action.openSubagent': 'Abrir {name}',
|
||||
'chat.workStatus.section.usage': 'Uso',
|
||||
'chat.workStatus.section.telemetry': 'Estatísticas do turno',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Resposta',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'A velocidade com que o texto final chegou. Exclui a espera inicial, o raciocínio e as chamadas anteriores de ferramentas. É uma estimativa pelos horários do texto, não uma medição do provedor.',
|
||||
'chat.workStatus.telemetry.speed': 'Solicitação',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Modelo',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Tempo de todas as etapas do modelo, incluindo a espera pelas respostas. O tempo das ferramentas é descontado. Não é apenas o tempo de geração do texto.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Ferramentas',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Tempo de execução das ferramentas, incluindo chamadas que falharam. Ferramentas executadas ao mesmo tempo contam uma vez só.',
|
||||
'chat.workStatus.telemetry.ttft': 'TTFT médio',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Espera média até o primeiro texto ou raciocínio de cada etapa. Não aparece se faltar o horário de início de alguma etapa, algo comum em chamadas apenas de ferramentas.',
|
||||
'chat.workStatus.telemetry.steps': 'Etapas',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Quantas vezes o modelo foi chamado para este prompt. Ler o resultado de uma ferramenta e decidir o próximo passo geralmente exige outra chamada.',
|
||||
'chat.workStatus.telemetry.tokens': 'Tokens',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Tokens gerados em todas as etapas, incluindo raciocínio, divididos pelo tempo sem execução de ferramentas. A espera pelo modelo conta, então muitas chamadas curtas podem reduzir este valor.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Entrada sem tokens em cache: {input}. ↓ Gerados: {output} para texto e chamadas de ferramentas, mais {reasoning} de raciocínio. Totais de todas as etapas deste prompt.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Cache',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Parcela dos tokens de entrada reutilizados do cache do prompt em todas as etapas. Reutilizar o contexto pode reduzir custo e espera, mas não é uma medida de velocidade.',
|
||||
'chat.workStatus.telemetry.cost': 'Custo',
|
||||
'chat.workStatus.telemetry.costDescription': 'Custo informado pelo provedor para todas as etapas deste prompt, em dólares americanos. Não inclui sessões separadas de subagentes. Zero pode indicar um modelo gratuito ou um provedor que não informa a cobrança.',
|
||||
'chat.workStatus.goal.open': 'Gerenciar objetivo',
|
||||
'chat.workStatus.goal.pause': 'Pausar',
|
||||
'chat.workStatus.goal.resume': 'Retomar',
|
||||
|
||||
@@ -3162,6 +3162,26 @@ export const dict = {
|
||||
'chat.workStatus.action.openPr': 'Pull request\'i aç',
|
||||
'chat.workStatus.action.openSubagent': '{name} öğesini aç',
|
||||
'chat.workStatus.section.usage': 'Kullanım',
|
||||
'chat.workStatus.section.telemetry': 'Tur istatistikleri',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Yanıt',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'Son metnin ne hızla geldiği. İlk bekleme, akıl yürütme ve önceki araç çağrıları dahil değildir. Metin zamanlarından hesaplanan bir tahmindir, sağlayıcı tarafındaki hız ölçümü değildir.',
|
||||
'chat.workStatus.telemetry.speed': 'Tüm istek',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Model süresi',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Yanıt bekleme dahil tüm model adımlarının süresi. Araç çalışma süresi çıkarılır. Yalnızca metin üretme süresi değildir.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Araç süresi',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Başarısız çağrılar dahil araçların çalışma süresi. Aynı anda çalışan araçların süreleri bir kez sayılır.',
|
||||
'chat.workStatus.telemetry.ttft': 'Ortalama TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Her model adımında ilk metin veya akıl yürütme başlayana kadar ortalama bekleme. Bir adımın başlangıç zamanı yoksa gösterilmez; yalnızca araç çağıran adımlarda bu sık görülür.',
|
||||
'chat.workStatus.telemetry.steps': 'Adımlar',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Bu istem için modelin kaç kez çağrıldığı. Araç sonucunu okuyup sıradaki işi belirlemek genellikle yeni bir adım gerektirir.',
|
||||
'chat.workStatus.telemetry.tokens': 'Tokenlar',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Akıl yürütme dahil tüm adımlarda üretilen tokenların, araç çalışması çıkarılmış süreye bölümü. Modeli bekleme süresi sayılır; çok sayıda kısa çağrı bu değeri düşürebilir.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Önbellek hariç girdi: {input}. ↓ Üretilen tokenlar: metin ve araç çağrıları için {output}, akıl yürütme için {reasoning}. Bu istemin tüm adımlarının toplamıdır.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Önbellek',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Tüm adımlarda istem önbelleğinden yeniden kullanılan girdi tokenlarının oranı. Bağlamı yeniden kullanmak maliyeti ve beklemeyi azaltabilir, ancak bu bir hız puanı değildir.',
|
||||
'chat.workStatus.telemetry.cost': 'Maliyet',
|
||||
'chat.workStatus.telemetry.costDescription': 'Sağlayıcının bu istemin tüm model adımları için bildirdiği ABD doları tutarı. Ayrı alt ajan oturumları dahil değildir. Sıfır, ücretsiz model veya ücret bildirmeyen sağlayıcı anlamına gelebilir.',
|
||||
'chat.workStatus.goal.open': 'Hedefi yönet',
|
||||
'chat.workStatus.goal.pause': 'Duraklat',
|
||||
'chat.workStatus.goal.resume': 'Devam et',
|
||||
|
||||
@@ -3248,6 +3248,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': 'Відкрити pull request',
|
||||
'chat.workStatus.action.openSubagent': 'Відкрити {name}',
|
||||
'chat.workStatus.section.usage': 'Використання',
|
||||
'chat.workStatus.section.telemetry': 'Статистика ходу',
|
||||
'chat.workStatus.telemetry.responseSpeed': 'Відповідь',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': 'Як швидко надходив фінальний текст. Без очікування на початок, міркувань і попередніх викликів інструментів. Це оцінка за часовими мітками тексту, а не вимір швидкості на сервері провайдера.',
|
||||
'chat.workStatus.telemetry.speed': 'Увесь запит',
|
||||
'chat.workStatus.telemetry.llmDuration': 'Час моделі',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': 'Час усіх кроків моделі, включно з очікуванням відповідей. Час виконання інструментів віднято. Це не лише час генерації тексту.',
|
||||
'chat.workStatus.telemetry.toolDuration': 'Час інструментів',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': 'Час виконання інструментів, включно з невдалими викликами. Паралельне виконання рахується один раз, а не додається кілька разів.',
|
||||
'chat.workStatus.telemetry.ttft': 'Середній TTFT',
|
||||
'chat.workStatus.telemetry.ttftDescription': 'Середнє очікування до початку тексту або міркувань на кожному кроці моделі. Не показуємо, якщо хоча б один крок не має часової мітки початку, як часто буває з викликами лише інструментів.',
|
||||
'chat.workStatus.telemetry.steps': 'Кроки',
|
||||
'chat.workStatus.telemetry.stepsDescription': 'Скільки разів зверталися до моделі для цього промпту. Прочитати результат інструмента й вирішити, що робити далі, зазвичай означає ще один крок.',
|
||||
'chat.workStatus.telemetry.tokens': 'Токени',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': 'Згенеровані токени всіх кроків, включно з міркуваннями, поділені на час без виконання інструментів. Очікування моделі залишається, тому багато коротких викликів можуть знижувати цей показник.',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ Вхідні токени без кешованих: {input}. ↓ Згенеровані: {output} для тексту й викликів інструментів та {reasoning} для міркувань. Суми охоплюють усі кроки цього промпту.',
|
||||
'chat.workStatus.telemetry.cacheHit': 'Кеш',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': 'Частка вхідних токенів, повторно використаних із кешу промпту на всіх кроках. Повторне використання контексту може зменшити вартість і очікування, але це не оцінка швидкості.',
|
||||
'chat.workStatus.telemetry.cost': 'Вартість',
|
||||
'chat.workStatus.telemetry.costDescription': 'Вартість усіх кроків моделі для цього промпту за даними провайдера, у доларах США. Окремі сесії субагентів не включено. Нуль може означати безкоштовну модель або провайдера, який не повідомляє про оплату.',
|
||||
'chat.workStatus.goal.open': 'Керувати ціллю',
|
||||
'chat.workStatus.goal.pause': 'Пауза',
|
||||
'chat.workStatus.goal.resume': 'Відновити',
|
||||
|
||||
@@ -3248,6 +3248,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': '打开拉取请求',
|
||||
'chat.workStatus.action.openSubagent': '打开 {name}',
|
||||
'chat.workStatus.section.usage': '用量',
|
||||
'chat.workStatus.section.telemetry': '轮次统计',
|
||||
'chat.workStatus.telemetry.responseSpeed': '回答速度',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': '最终文本到达的速度。不含开始前的等待、推理和之前的工具调用。这是根据文本时间戳估算的速度,不是提供商测得的生成速度。',
|
||||
'chat.workStatus.telemetry.speed': '整个请求',
|
||||
'chat.workStatus.telemetry.llmDuration': '模型耗时',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': '所有模型步骤的耗时,包括等待回答的时间。已扣除工具执行时间,并不只是生成文本的时间。',
|
||||
'chat.workStatus.telemetry.toolDuration': '工具耗时',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': '工具执行所用的时间,包括失败的调用。多个工具同时运行的时间只计算一次,不重复相加。',
|
||||
'chat.workStatus.telemetry.ttft': '平均首字延迟',
|
||||
'chat.workStatus.telemetry.ttftDescription': '每个模型步骤开始输出文本或推理前的平均等待时间。如果有任何步骤缺少开始时间戳,就不显示。只调用工具的步骤经常没有这项数据。',
|
||||
'chat.workStatus.telemetry.steps': '步骤',
|
||||
'chat.workStatus.telemetry.stepsDescription': '处理这条提示时调用模型的次数。读取工具结果并决定下一步通常需要再次调用模型。',
|
||||
'chat.workStatus.telemetry.tokens': 'Token',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': '所有步骤生成的 token 数,包括推理,除以扣除工具执行后的时间。等待模型的时间仍计入,因此多次短工具调用可能拉低这个数值。',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ 不含缓存的输入 token:{input}。↓ 生成的 token:文本和工具调用 {output},推理 {reasoning}。统计这条提示的所有步骤。',
|
||||
'chat.workStatus.telemetry.cacheHit': '缓存命中率',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': '所有步骤中从提示缓存复用的输入 token 比例。复用上下文可能降低费用和等待时间,但这不是速度评分。',
|
||||
'chat.workStatus.telemetry.cost': '费用',
|
||||
'chat.workStatus.telemetry.costDescription': '提供商报告的这条提示所有模型步骤的费用,单位为美元。不含独立子代理会话。零可能表示免费模型,也可能是提供商未报告费用。',
|
||||
'chat.workStatus.goal.open': '管理目标',
|
||||
'chat.workStatus.goal.pause': '暂停',
|
||||
'chat.workStatus.goal.resume': '继续',
|
||||
|
||||
@@ -3247,6 +3247,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.workStatus.action.openPr': '開啟提取請求',
|
||||
'chat.workStatus.action.openSubagent': '開啟 {name}',
|
||||
'chat.workStatus.section.usage': '用量',
|
||||
'chat.workStatus.section.telemetry': '輪次統計',
|
||||
'chat.workStatus.telemetry.responseSpeed': '回答速度',
|
||||
'chat.workStatus.telemetry.responseSpeedDescription': '最終文字到達的速度。不含開始前的等待、推理和先前的工具呼叫。這是根據文字時間戳記估算的速度,不是供應商測得的生成速度。',
|
||||
'chat.workStatus.telemetry.speed': '整個請求',
|
||||
'chat.workStatus.telemetry.llmDuration': '模型耗時',
|
||||
'chat.workStatus.telemetry.llmDurationDescription': '所有模型步驟的耗時,包括等待回答的時間。已扣除工具執行時間,並不只是生成文字的時間。',
|
||||
'chat.workStatus.telemetry.toolDuration': '工具耗時',
|
||||
'chat.workStatus.telemetry.toolDurationDescription': '工具執行所用的時間,包括失敗的呼叫。多個工具同時執行的時間只計算一次,不重複相加。',
|
||||
'chat.workStatus.telemetry.ttft': '平均首字延遲',
|
||||
'chat.workStatus.telemetry.ttftDescription': '每個模型步驟開始輸出文字或推理前的平均等待時間。若任何步驟缺少開始時間戳記,就不顯示。僅呼叫工具的步驟經常沒有這項資料。',
|
||||
'chat.workStatus.telemetry.steps': '步驟',
|
||||
'chat.workStatus.telemetry.stepsDescription': '處理這則提示時呼叫模型的次數。讀取工具結果並決定下一步通常需要再次呼叫模型。',
|
||||
'chat.workStatus.telemetry.tokens': 'Token',
|
||||
'chat.workStatus.telemetry.tokens.inOut': '{input} ↑ · {output} ↓',
|
||||
'chat.workStatus.telemetry.speedDescription': '所有步驟生成的 token 數,包括推理,除以扣除工具執行後的時間。等待模型的時間仍計入,因此多次短工具呼叫可能拉低這個數值。',
|
||||
'chat.workStatus.telemetry.tokensDescription': '↑ 不含快取的輸入 token:{input}。↓ 生成的 token:文字和工具呼叫 {output},推理 {reasoning}。統計這則提示的所有步驟。',
|
||||
'chat.workStatus.telemetry.cacheHit': '快取命中率',
|
||||
'chat.workStatus.telemetry.cacheHitDescription': '所有步驟中從提示快取重複使用的輸入 token 比例。重複使用上下文可能降低費用和等待時間,但這不是速度評分。',
|
||||
'chat.workStatus.telemetry.cost': '費用',
|
||||
'chat.workStatus.telemetry.costDescription': '供應商回報的這則提示所有模型步驟的費用,單位為美元。不含獨立子代理工作階段。零可能表示免費模型,也可能是供應商未回報費用。',
|
||||
'chat.workStatus.goal.open': '管理目標',
|
||||
'chat.workStatus.goal.pause': '暫停',
|
||||
'chat.workStatus.goal.resume': '繼續',
|
||||
|
||||
@@ -835,6 +835,44 @@ describe('updateDesktopSettings', () => {
|
||||
expect(saveCalls.some((changes) => changes.toolJsonViewMode === 'formatted')).toBe(true);
|
||||
});
|
||||
|
||||
test('legacy server lists keep telemetry hidden, while explicit opt-ins survive hydration', async () => {
|
||||
getWindow();
|
||||
for (const explicit of [undefined, false, true]) {
|
||||
invalidateSettingsCache();
|
||||
registerSettingsApi(async (changes) => changes, async () => ({
|
||||
settings: { workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: explicit,
|
||||
draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
expect(useUIStore.getState().workStatusHiddenSections).toEqual(explicit ? ['mcp'] : ['mcp', 'telemetry']);
|
||||
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(explicit === true);
|
||||
}
|
||||
});
|
||||
|
||||
test('autosaves telemetry opt-in and its list together, then restores them through settings load', async () => {
|
||||
getWindow();
|
||||
invalidateSettingsCache();
|
||||
let server: SettingsPayload = { workStatusHiddenSections: [], draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true };
|
||||
const saves: Partial<SettingsPayload>[] = [];
|
||||
registerSettingsApi(async (changes) => { saves.push(changes); server = { ...server, ...changes }; return changes; },
|
||||
async () => ({ settings: server, source: 'web' }));
|
||||
await syncDesktopSettings();
|
||||
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']);
|
||||
startAppearanceAutoSave();
|
||||
useUIStore.getState().setWorkStatusSectionVisible('telemetry', true);
|
||||
await delay(600);
|
||||
expect(saves.some((changes) => changes.workStatusHiddenSections?.length === 0 && changes.workStatusHiddenSectionsExplicit === true)).toBe(true);
|
||||
expect(server.workStatusHiddenSections).toEqual([]);
|
||||
invalidateSettingsCache();
|
||||
await syncDesktopSettings();
|
||||
expect(useUIStore.getState().workStatusHiddenSections).toEqual([]);
|
||||
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
|
||||
// An unrelated partial save response must not turn an opt-in back off.
|
||||
await updateDesktopSettings({ workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled });
|
||||
expect(useUIStore.getState().workStatusHiddenSections).toEqual([]);
|
||||
});
|
||||
|
||||
test('applies persisted autoSaveEnabled from server settings', async () => {
|
||||
getWindow();
|
||||
invalidateSettingsCache();
|
||||
|
||||
@@ -559,6 +559,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
|
||||
streamingAutoFollowEnabled: defaults.streamingAutoFollowEnabled,
|
||||
workStatusPanelEnabled: defaults.workStatusPanelEnabled,
|
||||
workStatusHiddenSections: defaults.workStatusHiddenSections,
|
||||
workStatusHiddenSectionsExplicit: defaults.workStatusHiddenSectionsExplicit,
|
||||
sessionRecapEnabled: defaults.sessionRecapEnabled,
|
||||
sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
|
||||
sessionGoalEnabled: defaults.sessionGoalEnabled,
|
||||
@@ -661,9 +662,10 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
store.setWorkStatusPanelEnabled(settings.workStatusPanelEnabled);
|
||||
}
|
||||
if (Array.isArray(settings.workStatusHiddenSections)) {
|
||||
const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections);
|
||||
if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000')) {
|
||||
store.setWorkStatusHiddenSections(next);
|
||||
const explicit = settings.workStatusHiddenSectionsExplicit === true;
|
||||
const next = sanitizeWorkStatusHiddenSections(settings.workStatusHiddenSections, explicit);
|
||||
if (next.join('\u0000') !== store.workStatusHiddenSections.join('\u0000') || explicit !== store.workStatusHiddenSectionsExplicit) {
|
||||
useUIStore.setState({ workStatusHiddenSections: next, workStatusHiddenSectionsExplicit: explicit });
|
||||
}
|
||||
}
|
||||
if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) {
|
||||
@@ -1216,6 +1218,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
// accumulate forever as sections get renamed.
|
||||
result.workStatusHiddenSections = sanitizeWorkStatusHiddenSections(candidate.workStatusHiddenSections);
|
||||
}
|
||||
if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') {
|
||||
result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit;
|
||||
}
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useUIStore } from './useUIStore';
|
||||
|
||||
const originalOptions = useUIStore.persist.getOptions();
|
||||
const originalState = useUIStore.getState();
|
||||
afterEach(() => {
|
||||
useUIStore.persist.setOptions(originalOptions);
|
||||
useUIStore.setState(originalState, true);
|
||||
});
|
||||
|
||||
describe('telemetry settings migration', () => {
|
||||
for (const version of [18, 19]) {
|
||||
test(`migrates real v${version} hydration without losing existing hidden sections`, async () => {
|
||||
useUIStore.persist.setOptions({ storage: {
|
||||
getItem: () => ({ version, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp'] } }),
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
} });
|
||||
await useUIStore.persist.rehydrate();
|
||||
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
|
||||
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false);
|
||||
expect(useUIStore.persist.getOptions().version).toBe(20);
|
||||
});
|
||||
}
|
||||
|
||||
test('explicit opt-in round-trips through the actual persisted projection and hydration', async () => {
|
||||
let saved: Parameters<NonNullable<typeof originalOptions.storage>['setItem']>[1] = { state: useUIStore.getInitialState(), version: 20 };
|
||||
useUIStore.persist.setOptions({ storage: {
|
||||
getItem: () => saved,
|
||||
setItem: (_name, value) => { saved = value; },
|
||||
removeItem: () => undefined,
|
||||
} });
|
||||
useUIStore.setState({ workStatusHiddenSections: ['telemetry', 'mcp'], workStatusHiddenSectionsExplicit: false });
|
||||
useUIStore.getState().setWorkStatusSectionVisible('telemetry', true);
|
||||
useUIStore.persist.setOptions({ storage: { getItem: () => saved, setItem: () => undefined, removeItem: () => undefined } });
|
||||
useUIStore.setState({ workStatusHiddenSections: ['telemetry'], workStatusHiddenSectionsExplicit: false });
|
||||
await useUIStore.persist.rehydrate();
|
||||
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']);
|
||||
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -790,6 +790,8 @@ interface UIStore {
|
||||
* Persisted to server settings, not just this browser.
|
||||
*/
|
||||
workStatusHiddenSections: string[];
|
||||
/** Explicitly chosen hidden-section state. False keeps the default opt-in seed. */
|
||||
workStatusHiddenSectionsExplicit: boolean;
|
||||
isSessionSwitcherOpen: boolean;
|
||||
isSessionDropdownOpen: boolean;
|
||||
pendingDiffFile: string | null;
|
||||
@@ -1186,7 +1188,8 @@ export const useUIStore = create<UIStore>()(
|
||||
workStatusPanelVisible: false,
|
||||
workStatusPanelFits: false,
|
||||
workStatusOverlayOpen: false,
|
||||
workStatusHiddenSections: [],
|
||||
workStatusHiddenSections: ['telemetry'],
|
||||
workStatusHiddenSectionsExplicit: false,
|
||||
isSessionSwitcherOpen: false,
|
||||
isSessionDropdownOpen: false,
|
||||
pendingDiffFile: null,
|
||||
@@ -1809,6 +1812,7 @@ export const useUIStore = create<UIStore>()(
|
||||
const isHidden = hidden.includes(sectionId);
|
||||
if (visible === !isHidden) return state;
|
||||
return {
|
||||
workStatusHiddenSectionsExplicit: true,
|
||||
workStatusHiddenSections: visible
|
||||
? hidden.filter((entry) => entry !== sectionId)
|
||||
: [...hidden, sectionId],
|
||||
@@ -1817,7 +1821,7 @@ export const useUIStore = create<UIStore>()(
|
||||
},
|
||||
|
||||
setWorkStatusHiddenSections: (sectionIds) => {
|
||||
set({ workStatusHiddenSections: [...new Set(sectionIds)] });
|
||||
set({ workStatusHiddenSections: [...new Set(sectionIds)], workStatusHiddenSectionsExplicit: true });
|
||||
},
|
||||
|
||||
setContextRailSurfaceVisible: (surfaceId, visible) => {
|
||||
@@ -2710,13 +2714,25 @@ export const useUIStore = create<UIStore>()(
|
||||
{
|
||||
name: 'ui-store',
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
version: 19,
|
||||
version: 20,
|
||||
migrate: (persistedState, version) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState;
|
||||
}
|
||||
const state = persistedState as Record<string, unknown>;
|
||||
|
||||
// v19 -> v20: lists written before telemetry existed are not opt-ins.
|
||||
if (version < 20 && state.workStatusHiddenSectionsExplicit !== true) {
|
||||
if (Array.isArray(state.workStatusHiddenSections)) {
|
||||
if (!state.workStatusHiddenSections.includes('telemetry')) {
|
||||
state.workStatusHiddenSections.push('telemetry');
|
||||
}
|
||||
} else {
|
||||
state.workStatusHiddenSections = ['telemetry'];
|
||||
}
|
||||
state.workStatusHiddenSectionsExplicit = false;
|
||||
}
|
||||
|
||||
// v15 -> v16: the main-area surface concept is gone from persistence
|
||||
// (the chat always owns the desktop main area; panel surfaces have
|
||||
// their own state). Drop the historic fields so a stored non-chat
|
||||
@@ -2964,6 +2980,7 @@ export const useUIStore = create<UIStore>()(
|
||||
workStatusScrollTop: state.workStatusScrollTop,
|
||||
workStatusPanelEnabled: state.workStatusPanelEnabled,
|
||||
workStatusHiddenSections: state.workStatusHiddenSections,
|
||||
workStatusHiddenSectionsExplicit: state.workStatusHiddenSectionsExplicit,
|
||||
isSessionSwitcherOpen: state.isSessionSwitcherOpen,
|
||||
sidebarSection: state.sidebarSection,
|
||||
settingsPage: state.settingsPage,
|
||||
|
||||
@@ -195,6 +195,7 @@ Rules:
|
||||
4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion.
|
||||
5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth.
|
||||
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
|
||||
Directory `sessionStatusReady` records successful status-snapshot authority independently of bootstrap's general readiness. Before that flag or an explicit session status arrives, telemetry treats an omitted status as unknown. A failed status request cannot grant idle authority; the flag is not persisted.
|
||||
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
||||
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract.
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import { bootstrapDirectory } from "./bootstrap"
|
||||
import { INITIAL_STATE, type State } from "./types"
|
||||
|
||||
const createSdk = (options?: { commandList?: () => Promise<{ data: unknown[] }> }) => ({
|
||||
const createSdk = (options?: { commandList?: () => Promise<{ data: unknown[] }>; sessionStatus?: () => Promise<{ data: State['session_status'] }> }) => ({
|
||||
project: { current: async () => ({ data: { id: "project-a" } }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
path: { get: async () => ({ data: { state: "", config: "", worktree: "/repo", directory: "/repo", home: "/home" } }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
session: { status: options?.sessionStatus ?? (async () => ({ data: {} })) },
|
||||
command: { list: options?.commandList ?? (async () => ({ data: [] })) },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
lsp: { status: async () => ({ data: [] }) },
|
||||
@@ -65,6 +65,7 @@ describe("bootstrapDirectory", () => {
|
||||
|
||||
expect(await bootstrapping).toBe("complete")
|
||||
expect(state.status).toBe("complete")
|
||||
expect(state.sessionStatusReady).toBe(true)
|
||||
expect(deferredStarted).toBe(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(deferredStarted).toBe(true)
|
||||
@@ -108,4 +109,18 @@ describe("bootstrapDirectory", () => {
|
||||
expect(result).toBe("stale")
|
||||
expect(commits).toBe(0)
|
||||
})
|
||||
|
||||
test("a failed status request cannot grant idle authority even when bootstrap completes", async () => {
|
||||
let state = createState()
|
||||
const result = await bootstrapDirectory({
|
||||
directory: '/repo',
|
||||
sdk: createSdk({ sessionStatus: async () => { throw new Error('status unavailable') } }),
|
||||
getState: () => state,
|
||||
set: (patch) => { state = { ...state, ...patch } },
|
||||
global: { config: {}, projects: [project] },
|
||||
loadSessions: async () => undefined,
|
||||
})
|
||||
expect(result).toBe('complete')
|
||||
expect(state.sessionStatusReady).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function bootstrapDirectory(input: {
|
||||
if (next) commit({ project: next })
|
||||
}),
|
||||
),
|
||||
retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status") }))),
|
||||
retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status"), sessionStatusReady: true }))),
|
||||
])
|
||||
|
||||
if (input.isStale?.()) return "stale"
|
||||
|
||||
@@ -742,6 +742,7 @@ async function resyncDirectorySessionStatuses(
|
||||
if (nextStatuses === null) return null
|
||||
applySessionStatusSnapshot(store, nextStatuses, candidateSessionIds, mode)
|
||||
if (mode === "authoritative") {
|
||||
store.setState({ sessionStatusReady: true })
|
||||
applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds)
|
||||
// An authoritative snapshot that settles sessions previously observed
|
||||
// busy/retry can leave their trailing assistant message and tool parts
|
||||
|
||||
@@ -56,6 +56,8 @@ export type State = {
|
||||
sessionEventRevision?: Record<string, number>
|
||||
sessionDeletedRevision?: Record<string, number>
|
||||
session_status: Record<string, SessionStatus>
|
||||
/** A successful status snapshot makes omitted sessions authoritatively idle. */
|
||||
sessionStatusReady?: boolean
|
||||
session_diff: Record<string, FileDiff[]>
|
||||
todo: Record<string, Todo[]>
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
|
||||
@@ -203,6 +203,9 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
...new Set(candidate.workStatusHiddenSections.filter((entry) => typeof entry === 'string' && entry.length > 0)),
|
||||
];
|
||||
}
|
||||
if (typeof candidate.workStatusHiddenSectionsExplicit === 'boolean') {
|
||||
result.workStatusHiddenSectionsExplicit = candidate.workStatusHiddenSectionsExplicit;
|
||||
}
|
||||
if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
|
||||
result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,19 @@ const createTestHelpersWithRealSanitizers = () => {
|
||||
};
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('round-trips telemetry opt-in with the hidden list and preserves it across unrelated writes', () => {
|
||||
const helpers = createTestHelpers();
|
||||
const legacy = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [] });
|
||||
expect(legacy.workStatusHiddenSectionsExplicit).toBeUndefined();
|
||||
const changes = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: true });
|
||||
const saved = helpers.mergePersistedSettings(legacy, changes);
|
||||
const reloaded = helpers.formatSettingsResponse(JSON.parse(JSON.stringify(saved)));
|
||||
expect(reloaded.workStatusHiddenSections).toEqual([]);
|
||||
expect(reloaded.workStatusHiddenSectionsExplicit).toBe(true);
|
||||
const next = helpers.mergePersistedSettings(reloaded, helpers.sanitizeSettingsUpdate({ workStatusPanelEnabled: false }));
|
||||
expect(helpers.formatSettingsResponse(next).workStatusHiddenSectionsExplicit).toBe(true);
|
||||
expect(helpers.sanitizeSettingsUpdate({ workStatusHiddenSectionsExplicit: 'true' }).workStatusHiddenSectionsExplicit).toBeUndefined();
|
||||
});
|
||||
it('imports from the packed @openchamber/web tarball without escaping the published package', async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-'));
|
||||
const packDir = join(tempRoot, 'pack');
|
||||
|
||||
Reference in New Issue
Block a user