feat: enable turn stats by default and prepare release notes

Turn stats was hidden unless users opted in. Show it by default, migrate implicit hidden lists, and preserve explicit section choices through reloads and settings sync.

Prepare App and VS Code release notes with Turn stats as the headline and BTW composer changes under improvements.

Testing: 115 focused tests passed; UI type-check passed; UI lint has one existing warning. Changelog validation and diff checks passed. Oxlint findings are limited to existing code in sections.ts; interactive app validation was not run.
This commit is contained in:
Bohdan Triapitsyn
2026-09-08 01:38:28 +03:00
parent 5df08ade90
commit 9e2163d839
9 changed files with 157 additions and 62 deletions
+75 -1
View File
@@ -1,7 +1,81 @@
---
title:
title: Turn stats in work status
---
## App
### New
- **Turn stats:** The work status panel now shows response speed, model and tool time, tokens, and reported cost after a turn finishes. It's on by default; hide it in the panel settings (thanks to @alvins82).
- **Projects:** Move project actions, worktree setup commands, and draft starters into the repository for teammates to use. Repository commands ask for trust before running, and ask again when they change.
- Plans: Move plans into the repository, or point the Plans tab at an existing folder of Markdown files in your project.
- Sessions: Search projects by name or path in the new-session project picker on web and desktop (thanks to @maximtop).
- Usage: Charm Hyper now shows your remaining Hypercredits and their dollar value (thanks to @airtaxi).
- Settings: "Always show scrollbars" keeps scrollbars visible on this device when you move the pointer away.
### Improvements
- Chat: `/btw` now opens a separate composer with its own draft, model, and effort. Select message text and choose "By the way…" to ask about it, or use `/btw <question>` to send immediately (thanks to @ChangeHow).
- Settings: Theme, fonts, and chat layout can differ between web, desktop, mobile, and VS Code. Panel sizes and other device choices stay on the device.
- Terminal: Text renders consistently across tabs, borders and block graphics join cleanly, and touch users get a copy button beside the tabs.
- Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. Reopening the model picker brings the selected model into view (thanks to @ChangeHow).
- Settings/Chat: Send-shortcut choices and large-text paste behavior have clearer descriptions (thanks to @ChangeHow).
- Chat: More compact text and spacing, stronger headings and contrast, consistent Activity rows, and a divider before the final answer make replies easier to read.
- Chat: Selected text uses the same visible highlight in messages, file previews, and comments across themes.
### Fixes
- Chat: Forking a user message restores its text and attachments in the new composer's draft and preserves the source draft (thanks to @karimodm).
- Chat: Interrupted tools stop showing an endless running timer after a reload (thanks to @alvins82).
- Chat: Attached images no longer appear twice just after sending.
- Chat: Opening panels or resizing the window keeps you at the end when following the latest reply. Sending no longer leaves a large blank area below the message.
- Chat: Streaming Thinking stays inside its scroll box. Scrolling or dragging upward pauses its automatic scrolling so you can read earlier reasoning (thanks to @alvins82).
- Chat: Enter adds a newline in the expanded composer; Ctrl/Cmd+Enter sends. Keyboard selection of a project or worktree returns focus to the input (thanks to @ChangeHow).
- Chat: Narrow Markdown tables fit their columns, removing the empty bordered space on the right (thanks to @ChangeHow).
- Sessions: Opening or restoring a session whose worktree was deleted leaves moving it to another directory up to you.
- Mobile: The uncommitted-changes tooltip no longer flashes over the startup screen while the last session loads.
- Terminal: Switching projects or tabs keeps each terminal's output separate. Reopening or resizing the panel no longer leaves stray prompt fragments.
- Terminal: Exiting Node-based commands on macOS and Linux no longer prints an empty IPC-channel warning.
- Updates: Updating a desktop host from the browser uses its native updater, confirms the installed version, and reports restart failures with a retry option (thanks to @ChangeHow).
- Git: Switching to a token-based identity no longer fails with a credential-helper permission error (thanks to @ICEY16360).
- Usage: OpenRouter shows per-key spending and limits, or monthly spending for unlimited keys, fixing misleading zero balances (thanks to @leducmaxime).
- Usage: Ollama Cloud's dollar-based plans show monthly spending and extra credits, fixing missing usage and rejected credentials (thanks to @kydorn).
- Usage: NeuralWatt allowance rows show usage percentages and respond to the used/remaining toggle (thanks to @kydorn).
- Usage: Slow connections to providers such as z.ai no longer fail because the connection attempt ends too early (thanks to @ouyangjian28).
- Desktop/Linux: "Open in" no longer lists unrelated editors or launches the wrong app when an installed app has a non-Latin name (thanks to @ouyangjian28).
- Scrollbars: Hovering over a scrollable area reveals its scrollbar, including in Settings and dialogs, without shifting the content (thanks to @sergiofspedro).
### Misc
- Server: `OPENCHAMBER_DATA_DIR` also covers project settings, themes, speech models, and new managed chats. Existing managed chats stay in their current location.
## VS Code
### New
- Projects: Store worktree setup commands and draft starters in the repository from Project settings. Repository commands require trust before running and after changes.
- Usage: Charm Hyper shows your remaining Hypercredits and their dollar value (thanks to @airtaxi).
- Settings: "Always show scrollbars" keeps scrollbars visible when the pointer leaves a scrollable area.
### Improvements
- **Chat:** `/btw` now has a separate composer with its own draft, model, and effort. The "By the way…" text-selection action prefills a question with the selected passage (thanks to @ChangeHow).
- Settings: VS Code keeps its own appearance and chat layout preferences, separate from web, desktop, and mobile.
- Chat: Ctrl+N/P navigation works across model lists, menus, and autocomplete. The model picker reopens with your selected model in view (thanks to @ChangeHow).
- Settings/Chat: Send-shortcut and large-text paste options have clearer descriptions (thanks to @ChangeHow).
- Chat: More compact Markdown, stronger headings and contrast, and a divider before the final answer make replies easier to scan.
- Chat: Text selection and comment highlights use a consistent, readable accent tint across themes.
### Fixes
- Chat: Forking a user message fills the destination composer with its prompt and attachments while keeping the original session's draft intact (thanks to @karimodm).
- Chat: Tools interrupted before a reload no longer keep a running timer indefinitely (thanks to @alvins82).
- Chat: Images attached to a sent message appear only once.
- Chat: Resizing the chat keeps the latest reply in view when following the end. Sending no longer creates a large blank space below the message.
- Chat: Long Thinking output stays in a capped scroll box while streaming; scrolling upward pauses its automatic scrolling (thanks to @alvins82).
- Chat: Narrow tables keep their border and toolbar close to the columns (thanks to @ChangeHow).
- Usage: OpenRouter reports key spending and limits accurately, including monthly spending for unlimited keys (thanks to @leducmaxime).
- Usage: Ollama Cloud dollar-based plans show monthly spending and extra credits; credential checks reject unreadable usage pages (thanks to @kydorn).
- Usage: NeuralWatt shows allowance percentages correctly in both used and remaining modes (thanks to @kydorn).
- Usage: Provider requests have enough time to connect on slower networks, fixing premature "fetch failed" errors (thanks to @ouyangjian28).
- Scrollbars: Hover reveals scrollbars in chat, Settings, and shared dialogs without moving the content sideways (thanks to @sergiofspedro).
@@ -17,7 +17,7 @@ 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. Opt-in Turn stats keeps its header for a
instead of reserving empty space. Turn stats keeps its header for a
selected session even without metrics, so a saved collapsed state can reopen.
## What it is not
@@ -104,7 +104,7 @@ 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 |
| Turn stats | `telemetry.ts` over `useSessionMessageRecords` | 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 |
@@ -230,7 +230,7 @@ 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), **Usage**, and **Turn stats** (opt-in session telemetry:
changes, PR, checks), **Usage**, and **Turn stats** (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;
@@ -241,12 +241,12 @@ 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. 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
sections are stored rather than visible ones. Every section, including Turn
stats, is enabled by default. UI-store v21 migration and server-list hydration
remove the old automatic telemetry hiding unless `workStatusHiddenSectionsExplicit`
records a user-chosen list. Explicit hiding and other hidden sections survive.
The marker and list travel together through autosave, sanitization, and server
settings; an empty list enables everything. Complete settings
snapshots own this preference; unrelated partial save echoes leave it unchanged.
`workStatusPanelVisible` is separate and transient: the switch can be on while
@@ -112,8 +112,18 @@ describe('sanitizeWorkStatusHiddenSections', () => {
});
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']);
expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]);
expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]);
expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]);
});
test('removes only the old implicit telemetry default', () => {
expect(sanitizeWorkStatusHiddenSections(['mcp', 'telemetry'], false)).toEqual(['mcp']);
expect(sanitizeWorkStatusHiddenSections([], false)).toEqual([]);
});
test('preserves explicit hiding, including hiding every section', () => {
expect(sanitizeWorkStatusHiddenSections(['mcp', 'telemetry'], true)).toEqual(['mcp', 'telemetry']);
expect(sanitizeWorkStatusHiddenSections([...WORK_STATUS_SECTION_IDS], true)).toEqual([...WORK_STATUS_SECTION_IDS]);
});
});
@@ -42,8 +42,7 @@ const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId =>
typeof value === 'string' && KNOWN_IDS.has(value);
/**
* 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.
* Hidden sections are stored, not visible ones. Every section is on by default.
*/
export const isWorkStatusSectionVisible = (
hidden: readonly string[] | null | undefined,
@@ -76,16 +75,13 @@ export const getWorkStatusPanelPresentation = ({
showEmptyState: contentMounted && allSectionsHidden,
});
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];
if (!Array.isArray(value)) return [];
const seen = new Set<WorkStatusSectionId>();
for (const entry of value) {
if (isWorkStatusSectionId(entry)) seen.add(entry);
}
if (!explicit) seen.add('telemetry');
// Older clients hid telemetry automatically until the user chose a list.
if (!explicit) seen.delete('telemetry');
return [...seen];
};
+12 -15
View File
@@ -863,22 +863,22 @@ 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 () => {
test('legacy server lists show telemetry, while explicit hiding survives hydration', async () => {
getWindow();
for (const explicit of [undefined, false, true]) {
invalidateSettingsCache();
registerSettingsApi(async (changes) => changes, async () => ({
settings: { workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: explicit,
settings: { workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: explicit,
draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().workStatusHiddenSections).toEqual(explicit ? ['mcp'] : ['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(explicit ? ['mcp', 'telemetry'] : ['mcp']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(explicit === true);
}
});
test('autosaves telemetry opt-in and its list together, then restores them through settings load', async () => {
test('autosaves telemetry hiding and its list together, then restores them through settings load', async () => {
getWindow();
invalidateSettingsCache();
let server: SettingsPayload = { workStatusHiddenSections: [], draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true };
@@ -886,23 +886,20 @@ describe('updateDesktopSettings', () => {
registerSettingsApi(async (changes) => { saves.push(changes); server = { ...server, ...changes }; return changes; },
async () => ({ settings: server, source: 'web' }));
await syncDesktopSettings();
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']);
expect(useUIStore.getState().workStatusHiddenSections).toEqual([]);
startAppearanceAutoSave();
useUIStore.getState().setWorkStatusSectionVisible('telemetry', true);
useUIStore.getState().setWorkStatusSectionVisible('telemetry', false);
await delay(600);
// The list itself already matches the server ([]), so only the explicit
// marker needs to travel; the server merges per key, so the end state is
// the same as sending both.
expect(saves.some((changes) => changes.workStatusHiddenSectionsExplicit === true)).toBe(true);
expect(server.workStatusHiddenSections).toEqual([]);
expect(server.workStatusHiddenSections).toEqual(['telemetry']);
expect(server.workStatusHiddenSectionsExplicit).toBe(true);
invalidateSettingsCache();
await syncDesktopSettings();
expect(useUIStore.getState().workStatusHiddenSections).toEqual([]);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
// An unrelated partial save response must not turn an opt-in back off.
// An unrelated partial save response must not re-enable a hidden section.
await updateDesktopSettings({ workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled });
expect(useUIStore.getState().workStatusHiddenSections).toEqual([]);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['telemetry']);
});
test('applies persisted autoSaveEnabled from server settings', async () => {
@@ -1116,7 +1113,7 @@ describe('updateDesktopSettings', () => {
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
// A legacy list the client normalises on read: the normalised copy is
// still not this window's change and must not be written back.
workStatusHiddenSections: ['mcp'],
workStatusHiddenSections: ['mcp', 'telemetry'],
draftStartersCraftGoalAdded: true,
draftStartersScheduleTaskAdded: true,
},
@@ -1137,7 +1134,7 @@ describe('updateDesktopSettings', () => {
expect(useUIStore.getState().showReasoningTraces).toBe(false);
expect(useUIStore.getState().terminalShell).toBe('fish');
expect(useUIStore.getState().favoriteModels).toHaveLength(1);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']);
expect(saveCalls).toEqual([]);
} finally {
stopModelPrefs();
@@ -87,11 +87,11 @@ describe('settings registry', () => {
});
test('applies the hidden-sections list together with its explicit marker', () => {
applySettingsToStores({ workStatusHiddenSections: ['mcp'] });
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false);
applySettingsToStores({ workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true });
applySettingsToStores({ workStatusHiddenSections: ['mcp', 'telemetry'] });
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false);
applySettingsToStores({ workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: true });
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
});
+2 -2
View File
@@ -296,8 +296,8 @@ export const SETTINGS_REGISTRY = {
parse: parseWorkStatusHiddenSections,
ui: {
read: () => useUIStore.getState().workStatusHiddenSections,
// The explicit marker decides whether a legacy list implicitly hides
// telemetry; both land in one store update so subscribers never see the
// The explicit marker distinguishes chosen lists from the old telemetry
// default; both land in one store update so subscribers never see the
// list without its marker.
write: (value, snapshot) => {
const explicit = snapshot.workStatusHiddenSectionsExplicit === true;
@@ -9,32 +9,54 @@ afterEach(() => {
});
describe('telemetry settings migration', () => {
for (const version of [18, 19]) {
test('shows telemetry by default', () => {
expect(useUIStore.getInitialState().workStatusHiddenSections).toEqual([]);
});
for (const version of [18, 19, 20]) {
test(`migrates real v${version} hydration without losing existing hidden sections`, async () => {
useUIStore.persist.setOptions({ storage: {
getItem: () => ({ version, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp'] } }),
getItem: () => ({ version, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp', 'telemetry'] } }),
setItem: () => undefined,
removeItem: () => undefined,
} });
await useUIStore.persist.rehydrate();
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(false);
expect(useUIStore.persist.getOptions().version).toBe(20);
expect(useUIStore.persist.getOptions().version).toBe(21);
});
}
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 };
test('preserves an explicitly hidden section from v20', async () => {
useUIStore.persist.setOptions({ storage: {
getItem: () => ({ version: 20, state: { ...useUIStore.getInitialState(), workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: true } }),
setItem: () => undefined,
removeItem: () => undefined,
} });
await useUIStore.persist.rehydrate();
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
});
test('explicit hiding round-trips through the actual persisted projection and hydration', async () => {
let saved: Parameters<NonNullable<typeof originalOptions.storage>['setItem']>[1] = { state: useUIStore.getInitialState(), version: originalOptions.version };
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.setState({ workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: false });
useUIStore.getState().setWorkStatusSectionVisible('telemetry', false);
useUIStore.persist.setOptions({ storage: { getItem: () => saved, setItem: () => undefined, removeItem: () => undefined } });
useUIStore.setState({ workStatusHiddenSections: ['telemetry'], workStatusHiddenSectionsExplicit: false });
useUIStore.setState({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: false });
await useUIStore.persist.rehydrate();
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp', 'telemetry']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
});
test('can show telemetry again after hiding it', () => {
useUIStore.setState({ workStatusHiddenSections: ['mcp', 'telemetry'], workStatusHiddenSectionsExplicit: true });
useUIStore.getState().setWorkStatusSectionVisible('telemetry', true);
expect(useUIStore.getState().workStatusHiddenSections).toEqual(['mcp']);
expect(useUIStore.getState().workStatusHiddenSectionsExplicit).toBe(true);
});
+7 -11
View File
@@ -1190,7 +1190,7 @@ export const useUIStore = create<UIStore>()(
workStatusPanelVisible: false,
workStatusPanelFits: false,
workStatusOverlayOpen: false,
workStatusHiddenSections: ['telemetry'],
workStatusHiddenSections: [],
workStatusHiddenSectionsExplicit: false,
isSessionSwitcherOpen: false,
isSessionDropdownOpen: false,
@@ -2720,22 +2720,18 @@ export const useUIStore = create<UIStore>()(
{
name: 'ui-store',
storage: createDeferredSafeJSONStorage(),
version: 20,
version: 21,
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'];
}
// v20 -> v21: enable telemetry by default; preserve explicit choices.
if (version < 21 && state.workStatusHiddenSectionsExplicit !== true) {
state.workStatusHiddenSections = Array.isArray(state.workStatusHiddenSections)
? state.workStatusHiddenSections.filter((id) => id !== 'telemetry')
: [];
state.workStatusHiddenSectionsExplicit = false;
}