diff --git a/packages/cli/src/serve/routes/usage-stats.ts b/packages/cli/src/serve/routes/usage-stats.ts
index 68c29e67d2..e608087f74 100644
--- a/packages/cli/src/serve/routes/usage-stats.ts
+++ b/packages/cli/src/serve/routes/usage-stats.ts
@@ -8,16 +8,17 @@
* Read-only usage-dashboard surface behind the Web Shell Daemon Status
* "统计 / Usage" tab. Serves the selected range's (`today`/`week`/`month`)
* flattened token totals plus a trailing per-day heatmap, computed by core's
- * `buildUsageDashboard` from the durable local usage history (global `~/.qwen`,
- * cross-project) — the same source the TUI `/stats` command reads. The history
- * load runs read-only (`persistRebuild: false`), so serving a GET never writes
- * to `~/.qwen`, even when the persisted file is missing and the loader falls
- * back to transcript replay.
+ * `buildUsageDashboard` from the local usage history (global `~/.qwen`,
+ * cross-project). Uses `loadUsageHistoryWithLive`, which unions the durable
+ * `usage_record.jsonl` (written only by the TUI `/clear` path) with a replay of
+ * recent transcripts — so daemon / Web Shell sessions and any in-progress
+ * session are counted here, unlike the TUI `/stats` view. The load is
+ * read-only (never writes `~/.qwen`).
*
* Open GET (no `mutate` gate), consistent with `GET /daemon/status` and
* `GET /scheduled-tasks`: it exposes only aggregate local usage counts.
*
- * The heavy step — `loadUsageHistory` can replay every project's transcripts —
+ * The heavy step — `loadUsageHistoryWithLive` can replay recent transcripts —
* is cached once (range-independent), so toggling Today/7D/30D re-aggregates
* cheaply from a single disk read instead of re-loading per range, and
* concurrent requests coalesce onto the in-flight load.
@@ -26,7 +27,7 @@
import type { Application } from 'express';
import {
buildUsageDashboard,
- loadUsageHistory,
+ loadUsageHistoryWithLive,
type UsageSummaryRecord,
} from '@qwen-code/qwen-code-core';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
@@ -67,9 +68,7 @@ export function registerUsageStatsRoutes(
app: Application,
deps: RegisterUsageStatsRoutesDeps = {},
): void {
- const loadHistory =
- deps.loadHistory ??
- (() => loadUsageHistory(undefined, { persistRebuild: false }));
+ const loadHistory = deps.loadHistory ?? (() => loadUsageHistoryWithLive());
const ttlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
// Closure-scoped, range-independent history cache (one daemon per process).
diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
new file mode 100644
index 0000000000..e9f58f49ad
--- /dev/null
+++ b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from 'ink-testing-library';
+import { describe, it, expect } from 'vitest';
+import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
+import { ApprovalMode } from '@qwen-code/qwen-code-core';
+
+describe('', () => {
+ it('renders DEFAULT mode with pause badge and Ask permissions text', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('⏸');
+ expect(frame).toContain('Ask permissions');
+ });
+
+ it('renders PLAN mode indicator', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('plan mode');
+ });
+
+ it('renders AUTO_EDIT mode indicator', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('auto-accept edits');
+ });
+
+ it('renders AUTO mode indicator', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('auto mode (classifier-evaluated)');
+ });
+
+ it('renders YOLO mode indicator', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toContain('YOLO mode');
+ });
+});
diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
index 60ffd126a9..caa9234c17 100644
--- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
+++ b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx
@@ -45,6 +45,9 @@ export const AutoAcceptIndicator: React.FC = ({
subText = cycleText;
break;
case ApprovalMode.DEFAULT:
+ textContent = `⏸ ${t('Ask permissions')}`;
+ subText = cycleText;
+ break;
default:
break;
}
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
index ba71e4527a..66321a80e3 100644
--- a/packages/cli/src/ui/components/Footer.test.tsx
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -8,6 +8,7 @@ import { render } from 'ink-testing-library';
import { act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Footer } from './Footer.js';
+import { ApprovalMode } from '@qwen-code/qwen-code-core';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import * as useStatusLineModule from '../hooks/useStatusLine.js';
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
@@ -233,6 +234,19 @@ describe('', () => {
}
});
+ it('shows the default approval mode badge when in DEFAULT mode', () => {
+ const { lastFrame } = renderWithWidth(
+ 120,
+ createMockUIState({
+ showAutoAcceptIndicator: ApprovalMode.DEFAULT,
+ }),
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain('⏸');
+ expect(frame).toContain('Ask permissions');
+ expect(frame).not.toContain('? for shortcuts');
+ });
+
it('does not display the working directory or branch name', () => {
const { lastFrame } = renderWithWidth(120, createMockUIState());
expect(lastFrame()).not.toMatch(/\(.*\*\)/);
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
index a0843c3fb9..85f55e5497 100644
--- a/packages/cli/src/ui/components/Footer.tsx
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -21,7 +21,6 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimModeState } from '../contexts/VimModeContext.js';
-import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { GoalPill, useFooterGoalState } from './GoalPill.js';
import { CronPill, useFooterCronTaskCount } from './CronPill.js';
@@ -107,8 +106,7 @@ export const Footer: React.FC = () => {
message: uiState.startupIdeConnectionStatus.message,
})}
- ) : showAutoAcceptIndicator !== undefined &&
- showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
+ ) : showAutoAcceptIndicator !== undefined ? (
) : suppressHint ? null : (
{t('? for shortcuts')}
diff --git a/packages/cli/src/ui/components/agent-view/AgentFooter.tsx b/packages/cli/src/ui/components/agent-view/AgentFooter.tsx
index 7b05e4e478..0f40c4ad77 100644
--- a/packages/cli/src/ui/components/agent-view/AgentFooter.tsx
+++ b/packages/cli/src/ui/components/agent-view/AgentFooter.tsx
@@ -12,7 +12,7 @@
import type React from 'react';
import { Box, Text } from 'ink';
-import { ApprovalMode } from '@qwen-code/qwen-code-core';
+import type { ApprovalMode } from '@qwen-code/qwen-code-core';
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
import { theme } from '../../semantic-colors.js';
@@ -30,8 +30,7 @@ export const AgentFooter: React.FC = ({
contextWindowSize,
terminalWidth,
}) => {
- const showApproval =
- approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
+ const showApproval = approvalMode !== undefined;
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
if (!showApproval && !showContext) {
diff --git a/packages/cli/src/ui/components/approvalModeVisuals.ts b/packages/cli/src/ui/components/approvalModeVisuals.ts
index 9a432871f4..46d8ad4d7d 100644
--- a/packages/cli/src/ui/components/approvalModeVisuals.ts
+++ b/packages/cli/src/ui/components/approvalModeVisuals.ts
@@ -20,6 +20,7 @@ export function getApprovalModeIndicatorColor(
case ApprovalMode.YOLO:
return theme.status.error;
case ApprovalMode.DEFAULT:
+ return theme.text.secondary;
default:
return undefined;
}
diff --git a/packages/core/src/services/usage-dashboard-service.test.ts b/packages/core/src/services/usage-dashboard-service.test.ts
index 5c5017d8f5..d1c43afb5f 100644
--- a/packages/core/src/services/usage-dashboard-service.test.ts
+++ b/packages/core/src/services/usage-dashboard-service.test.ts
@@ -98,6 +98,89 @@ describe('loadUsageDashboard', () => {
);
}
+ /**
+ * Plant a transcript-only (never-persisted, e.g. daemon / Web Shell) session
+ * timestamped now, so it lands in the `today` window when replayed.
+ */
+ function plantTranscript(sessionId: string, totalTokens: number): void {
+ const cwd = '/daemon/project';
+ const ts = new Date().toISOString();
+ const chatsDir = path.join(
+ process.env['QWEN_HOME']!,
+ 'projects',
+ 'daemon-project',
+ 'chats',
+ );
+ fs.mkdirSync(chatsDir, { recursive: true });
+ const records = [
+ {
+ sessionId,
+ cwd,
+ uuid: 'u1',
+ parentUuid: null,
+ timestamp: ts,
+ type: 'user',
+ message: { role: 'user', content: 'hi' },
+ },
+ {
+ sessionId,
+ cwd,
+ uuid: 'u2',
+ parentUuid: 'u1',
+ timestamp: ts,
+ type: 'system',
+ subtype: 'ui_telemetry',
+ systemPayload: {
+ uiEvent: {
+ 'event.name': 'qwen-code.api_response',
+ 'event.timestamp': ts,
+ response_id: 'r1',
+ model: 'qwen-max',
+ duration_ms: 1200,
+ input_token_count: totalTokens * 0.6,
+ output_token_count: totalTokens * 0.4,
+ cached_content_token_count: 0,
+ thoughts_token_count: 0,
+ total_token_count: totalTokens,
+ prompt_id: 'p1',
+ },
+ },
+ },
+ {
+ sessionId,
+ cwd,
+ uuid: 'u3',
+ parentUuid: 'u2',
+ timestamp: ts,
+ type: 'assistant',
+ message: { role: 'assistant', content: 'ok' },
+ },
+ ];
+ fs.writeFileSync(
+ path.join(chatsDir, `${sessionId}.jsonl`),
+ records.map((r) => JSON.stringify(r)).join('\n') + '\n',
+ );
+ }
+
+ it('today totals include a daemon / Web Shell transcript session, not just the persisted file', async () => {
+ // Guards the wiring: loadUsageDashboard must read the live-merging loader,
+ // so a session that only lives in a transcript (never persisted by /clear)
+ // still counts. A revert to the persisted-only loader would drop it.
+ seed([
+ rec({
+ sessionId: 'persisted',
+ timestamp: Date.now(),
+ model: { totalTokens: 1000, inputTokens: 1000 },
+ }),
+ ]);
+ plantTranscript('daemon-live', 1600);
+
+ const dash = await loadUsageDashboard({ range: 'today' });
+
+ expect(dash.summary.sessions).toBe(2);
+ expect(dash.summary.totalTokens).toBe(2600);
+ });
+
it('flattens today totals across all sessions active today', async () => {
const now = Date.now();
seed([
diff --git a/packages/core/src/services/usage-dashboard-service.ts b/packages/core/src/services/usage-dashboard-service.ts
index 47b7daca92..385b52894f 100644
--- a/packages/core/src/services/usage-dashboard-service.ts
+++ b/packages/core/src/services/usage-dashboard-service.ts
@@ -7,7 +7,7 @@
import {
aggregateUsage,
getTimeRangeBounds,
- loadUsageHistory,
+ loadUsageHistoryWithLive,
type TimeRange,
type UsageSummaryRecord,
} from './usageHistoryService.js';
@@ -275,14 +275,15 @@ export function buildUsageDashboard(
/**
* Read-only snapshot of local token usage for the daemon usage-dashboard API.
- * Loads the global cross-project history (`~/.qwen`) via {@link loadUsageHistory}
- * (persisted `usage_record.jsonl`, falling back to transcript replay) and builds
- * the dashboard — consistent with the TUI `/stats` view. The load can be I/O
- * heavy on large histories, so callers should cache (the daemon route caches the
- * loaded records and re-runs {@link buildUsageDashboard} per range).
+ * Loads the global cross-project history (`~/.qwen`) via
+ * {@link loadUsageHistoryWithLive} — the persisted `usage_record.jsonl` unioned
+ * with a replay of recent transcripts — so the totals include daemon / Web Shell
+ * and in-progress sessions that the persisted file never captures. The load can
+ * be I/O heavy on large histories, so callers should cache (the daemon route
+ * caches the loaded records and re-runs {@link buildUsageDashboard} per range).
*/
export async function loadUsageDashboard(
options: LoadUsageDashboardOptions = {},
): Promise {
- return buildUsageDashboard(await loadUsageHistory(), options);
+ return buildUsageDashboard(await loadUsageHistoryWithLive(), options);
}
diff --git a/packages/core/src/services/usageHistoryService.test.ts b/packages/core/src/services/usageHistoryService.test.ts
index 3dd13e491c..01f22a9876 100644
--- a/packages/core/src/services/usageHistoryService.test.ts
+++ b/packages/core/src/services/usageHistoryService.test.ts
@@ -12,6 +12,7 @@ import {
metricsToUsageRecord,
aggregateUsage,
loadUsageHistory,
+ loadUsageHistoryWithLive,
persistSessionUsage,
} from './usageHistoryService.js';
import { ToolCallDecision } from '../telemetry/tool-call-decision.js';
@@ -626,6 +627,154 @@ describe('loadUsageHistory + persistSessionUsage (issue #4994 regression)', () =
for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
expect(totalTokens).toBe(1600);
});
+
+ // loadUsageHistoryWithLive: the daemon usage-dashboard loader. Unlike
+ // loadUsageHistory (persisted file verbatim when non-empty), it unions the
+ // persisted history with a replay of recent transcripts so daemon / Web Shell
+ // and in-progress sessions — which only the TUI /clear path ever persists —
+ // are counted. See issue: Web Shell "today" undercounted ~20x.
+ function planted(sessionId: string): string {
+ return path.join(
+ process.env['QWEN_HOME']!,
+ 'projects',
+ 'repro-project',
+ 'chats',
+ `${sessionId}.jsonl`,
+ );
+ }
+ function seedPersisted(records: UsageSummaryRecord[]) {
+ fs.writeFileSync(
+ path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
+ records.map((r) => JSON.stringify(r)).join('\n') + '\n',
+ );
+ }
+ function persistedRec(sessionId: string, totalTokens: number) {
+ return {
+ version: 1 as const,
+ sessionId,
+ timestamp: Date.now(),
+ startTime: Date.now() - 60000,
+ project: '/p',
+ durationMs: 60000,
+ totalLatencyMs: 1200,
+ models: {
+ 'qwen-max': {
+ requests: 1,
+ inputTokens: totalTokens,
+ outputTokens: 0,
+ cachedTokens: 0,
+ thoughtsTokens: 0,
+ totalTokens,
+ },
+ },
+ tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, byName: {} },
+ files: { linesAdded: 0, linesRemoved: 0 },
+ };
+ }
+
+ it('withLive: unions a never-persisted (daemon) session with the persisted history', async () => {
+ // Persisted: a finalized TUI session. Transcript-only: a daemon / Web Shell
+ // session that /clear never wrote to usage_record.jsonl.
+ seedPersisted([persistedRec('sess-persisted', 1000)]);
+ plantChatJsonl('sess-daemon', 1600);
+
+ const merged = await loadUsageHistoryWithLive();
+ const ids = merged.map((r) => r.sessionId).sort();
+ expect(ids).toEqual(['sess-daemon', 'sess-persisted']);
+
+ const report = aggregateUsage(merged, 'all');
+ let totalTokens = 0;
+ for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
+ // 1000 persisted + 1600 replayed from the daemon transcript.
+ expect(totalTokens).toBe(2600);
+
+ // Read-only: the persisted file must still hold only the original record.
+ const lines = fs
+ .readFileSync(
+ path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
+ 'utf8',
+ )
+ .trim()
+ .split('\n');
+ expect(lines).toHaveLength(1);
+ });
+
+ it('withLive: a persisted session with a live transcript is not double-counted (persisted wins)', async () => {
+ // Same sessionId in both the persisted file (authoritative 1000) and the
+ // transcript (1600). Must appear exactly once, keeping the persisted value.
+ seedPersisted([persistedRec('sess-both', 1000)]);
+ plantChatJsonl('sess-both', 1600);
+
+ const merged = await loadUsageHistoryWithLive();
+ expect(merged).toHaveLength(1);
+ expect(merged[0]!.sessionId).toBe('sess-both');
+ expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(1000);
+ });
+
+ it('withLive: with a persisted base, the trailing window excludes stale transcripts (sinceMs:0 includes them)', async () => {
+ // A persisted base means the window engages (old days come from the file).
+ seedPersisted([persistedRec('sess-persisted', 500)]);
+ plantChatJsonl('sess-old', 1600);
+ // Age the never-persisted transcript well past the default trailing window.
+ const stale = Date.now() - 100 * 24 * 60 * 60 * 1000;
+ fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000);
+
+ // Default window: the stale, never-persisted transcript is not replayed.
+ const windowed = await loadUsageHistoryWithLive();
+ expect(windowed.map((r) => r.sessionId)).toEqual(['sess-persisted']);
+ // An unbounded window picks it back up alongside the persisted record.
+ const all = await loadUsageHistoryWithLive({ sinceMs: 0 });
+ expect(all.map((r) => r.sessionId).sort()).toEqual([
+ 'sess-old',
+ 'sess-persisted',
+ ]);
+ });
+
+ it('withLive: with no persisted base, replays full history (no silent trailing-window truncation)', async () => {
+ // No usage_record.jsonl: nothing else covers old history, so an old
+ // transcript must still be replayed rather than truncated by the window.
+ plantChatJsonl('sess-old', 1600);
+ const stale = Date.now() - 100 * 24 * 60 * 60 * 1000;
+ fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000);
+
+ const merged = await loadUsageHistoryWithLive();
+ expect(merged.map((r) => r.sessionId)).toEqual(['sess-old']);
+ });
+
+ it('withLive: read-only rebuild — never writes usage_record.jsonl', async () => {
+ plantChatJsonl('sess-daemon-only', 1600);
+ const usagePath = path.join(
+ process.env['QWEN_HOME']!,
+ 'usage_record.jsonl',
+ );
+
+ const merged = await loadUsageHistoryWithLive();
+ expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon-only']);
+ expect(fs.existsSync(usagePath)).toBe(false);
+ });
+
+ it('withLive: all sessions persisted (empty rebuild) returns the persisted records as-is', async () => {
+ // Common case: no live transcripts at all, only the persisted file.
+ seedPersisted([persistedRec('sess-only', 500)]);
+
+ const merged = await loadUsageHistoryWithLive();
+ expect(merged).toHaveLength(1);
+ expect(merged[0]!.sessionId).toBe('sess-only');
+ expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(500);
+ });
+
+ it('withLive: a corrupt usage_record.jsonl falls back to a full transcript replay', async () => {
+ // No usable persisted base (garbage file) — the loader must still surface
+ // the daemon transcript rather than returning nothing.
+ fs.writeFileSync(
+ path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
+ '{ this is not valid json\nalso broken}\n',
+ );
+ plantChatJsonl('sess-daemon', 1600);
+
+ const merged = await loadUsageHistoryWithLive();
+ expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon']);
+ });
});
describe('aggregateUsage — skills', () => {
diff --git a/packages/core/src/services/usageHistoryService.ts b/packages/core/src/services/usageHistoryService.ts
index dab0a096a1..183d1da8f6 100644
--- a/packages/core/src/services/usageHistoryService.ts
+++ b/packages/core/src/services/usageHistoryService.ts
@@ -16,6 +16,24 @@ import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('USAGE_HISTORY');
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+/**
+ * Trailing window used by {@link loadUsageHistoryWithLive} when merging
+ * non-persisted (daemon / Web Shell / in-progress) sessions into the history.
+ *
+ * Sized to cover the largest summary/daily range the usage-dashboard exposes
+ * (month = 30 days) plus margin, so the hero totals, breakdown tiles, and the
+ * per-day line/bar charts are exact. Crucially it is NOT sized for the full
+ * heatmap span (~12 months): persisted `usage_record.jsonl` records of any age
+ * are always unioned in, so the heatmap keeps its full history, but a *never-
+ * persisted* daemon session older than this window is not replayed — that cell
+ * undercounts slightly. That cosmetic gap is a deliberate trade for load
+ * latency: replaying a full year of transcripts costs ~13s here vs. ~1.7s for
+ * this window (heavy Web Shell users accumulate thousands of unpersisted
+ * transcripts). Widen only alongside a cheaper scan.
+ */
+const LIVE_REBUILD_WINDOW_DAYS = 35;
+
export interface UsageSummaryRecord {
version: 1;
sessionId: string;
@@ -204,10 +222,38 @@ export function metricsToUsageRecord(
};
}
+interface RebuildFromSessionJsonlOptions {
+ /**
+ * Session to exclude from the one-time persistence migration (the caller's
+ * in-progress session — {@link persistSessionUsage} writes its authoritative
+ * record on `/clear` or exit). It is still returned in the rebuilt records.
+ */
+ skipSessionInRebuild?: string;
+ /** Persist rebuilt records as a migration. Read-only callers pass `false`. */
+ persist?: boolean;
+ /**
+ * Only replay transcripts whose file mtime is at/after this epoch-ms. Bounds
+ * the scan when merging recent live sessions into an already-persisted
+ * history (see {@link loadUsageHistoryWithLive}); undefined replays all.
+ */
+ sinceMs?: number;
+ /**
+ * Session ids already covered by the persisted history. Their transcripts are
+ * skipped by filename (`{sessionId}.jsonl`) with no file read, avoiding a full
+ * replay of sessions the persisted file already records authoritatively.
+ */
+ skipSessionIds?: ReadonlySet;
+}
+
async function rebuildFromSessionJsonl(
- skipSessionInRebuild?: string,
- persist = true,
+ options: RebuildFromSessionJsonlOptions = {},
): Promise {
+ const {
+ skipSessionInRebuild,
+ persist = true,
+ sinceMs,
+ skipSessionIds,
+ } = options;
const projectsDir = path.join(Storage.getGlobalQwenDir(), 'projects');
try {
if (!fs.existsSync(projectsDir)) return [];
@@ -243,6 +289,31 @@ async function rebuildFromSessionJsonl(
for (const file of files) {
try {
const filePath = path.join(chatsDir, file);
+
+ // Bound the scan when merging live sessions into a persisted history:
+ // skip transcripts untouched before `sinceMs`.
+ if (sinceMs !== undefined) {
+ let mtimeMs: number;
+ try {
+ mtimeMs = fs.statSync(filePath).mtimeMs;
+ } catch (e) {
+ debugLogger.debug(
+ `rebuildFromSessionJsonl: cannot stat ${filePath}: ${e}`,
+ );
+ continue;
+ }
+ if (mtimeMs < sinceMs) continue;
+ }
+
+ // Skip sessions the persisted history already records, before any file
+ // read: the transcript filename is `{sessionId}.jsonl`
+ // (chatRecordingService.ts), so the sessionId — the same value the
+ // full-read path below derives from the first record — needs no I/O.
+ if (skipSessionIds && skipSessionIds.size > 0) {
+ const fileSessionId = path.basename(file, '.jsonl');
+ if (skipSessionIds.has(fileSessionId)) continue;
+ }
+
const records = await jsonl.read(filePath);
if (records.length === 0) continue;
@@ -337,13 +408,73 @@ export async function loadUsageHistory(
}
return dedupBySessionId(
- await rebuildFromSessionJsonl(
+ await rebuildFromSessionJsonl({
skipSessionInRebuild,
- options?.persistRebuild ?? true,
- ),
+ persist: options?.persistRebuild ?? true,
+ }),
);
}
+/**
+ * Load the durable usage history **and** merge in sessions that were never
+ * written to `usage_record.jsonl` — notably daemon / Web Shell sessions (only
+ * the TUI `/clear` path persists usage) and any still-in-progress session.
+ *
+ * Unlike {@link loadUsageHistory}, which returns the persisted file verbatim
+ * whenever it is non-empty (and so silently omits everything not yet
+ * persisted), this replays recent transcripts for sessions the persisted file
+ * does not already cover and unions the two. Persisted records win on any
+ * sessionId conflict — they are the authoritative final snapshot. This is what
+ * the daemon usage-dashboard reads so its totals reflect live Web Shell
+ * activity. Read-only: never writes `usage_record.jsonl`.
+ *
+ * The transcript scan is bounded to a trailing window (mtime-based) so an
+ * established history does not pay a full cross-project replay on every load.
+ */
+export async function loadUsageHistoryWithLive(options?: {
+ /**
+ * Only replay transcripts touched at/after this epoch-ms. Defaults to a
+ * {@link LIVE_REBUILD_WINDOW_DAYS}-day trailing window (covers the dashboard's
+ * summary + daily charts; see the constant for the heatmap trade-off).
+ */
+ sinceMs?: number;
+}): Promise {
+ let persisted: UsageSummaryRecord[] = [];
+ try {
+ const records = await jsonl.read(getUsageHistoryPath());
+ persisted = records.filter((r) => r.version === 1);
+ } catch (e) {
+ debugLogger.debug(
+ `loadUsageHistoryWithLive: failed to read usage file: ${e}`,
+ );
+ }
+
+ const persistedIds = new Set(persisted.map((r) => r.sessionId));
+
+ // The trailing window bounds an *incremental* live merge on top of persisted
+ // history: old days come from the persisted file, so only recent transcripts
+ // need replaying. When there is no persisted base (fresh machine, or a user
+ // who only ever ran Web Shell so `/clear` never persisted), nothing else
+ // covers older history — replay it all (unbounded) rather than silently
+ // truncating the dashboard, matching the pre-existing empty-file behavior.
+ const sinceMs =
+ options?.sinceMs ??
+ (persistedIds.size > 0
+ ? Date.now() - LIVE_REBUILD_WINDOW_DAYS * MS_PER_DAY
+ : undefined);
+
+ const rebuilt = await rebuildFromSessionJsonl({
+ persist: false,
+ sinceMs,
+ skipSessionIds: persistedIds,
+ });
+
+ // Persisted records are the authoritative final snapshot, so they win on any
+ // sessionId conflict — place them last (dedupBySessionId is last-wins). The
+ // rebuilt set only adds sessions the persisted file never captured.
+ return dedupBySessionId([...rebuilt, ...persisted]);
+}
+
export function getTimeRangeBounds(range: TimeRange): {
start: Date;
end: Date;
diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css
index 4ffd4827dc..1380930c7d 100644
--- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css
+++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css
@@ -945,6 +945,13 @@
.footerButton {
flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.footerButtonLabel {
+ white-space: nowrap;
+ overflow: hidden;
}
.collapseButton {
diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
index d3b2a83ed2..f3c93d5162 100644
--- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
+++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
@@ -2517,7 +2517,11 @@ export function WebShellSidebar({
- {!collapsed && {t('sidebar.settings')}}
+ {!collapsed && (
+
+ {t('sidebar.settings')}
+
+ )}
{!collapsed && versionLabel && (