diff --git a/apps/desktop/src/main/__tests__/sessionsListIncludePinned.test.ts b/apps/desktop/src/main/__tests__/sessionsListIncludePinned.test.ts index b7c49463db9..54ed9cd6840 100644 --- a/apps/desktop/src/main/__tests__/sessionsListIncludePinned.test.ts +++ b/apps/desktop/src/main/__tests__/sessionsListIncludePinned.test.ts @@ -52,6 +52,7 @@ const h = vi.hoisted(() => { return { ipcHandle: vi.fn(), + assertTrustedAppRendererEvent: vi.fn(), logDebug: vi.fn(), logInfo: vi.fn(), queryResults, @@ -89,6 +90,9 @@ vi.mock('../agent-island/service.js', () => ({ vi.mock('../imageCacheStore', () => ({ removeSession: vi.fn() })); vi.mock('../messagePersistBroadcaster', () => ({ noteSessionClearBoundary: vi.fn() })); vi.mock('../sessionTaskSummary.js', () => ({ backfillPinnedSessionSummaries: vi.fn() })); +vi.mock('../security/trustedAppRenderer.js', () => ({ + assertTrustedAppRendererEvent: h.assertTrustedAppRendererEvent, +})); import { registerSessionIpc } from '../localDb/ipc/sessions.js'; @@ -195,6 +199,59 @@ describe('local-db:sessions:list includePinned', () => { expect(h.queryResults).toHaveLength(1); }); + it('returns the full sessions set for the usage-history query without message projections', async () => { + const handler = sessionsListHandler(); + h.queryResults.push([ + sessionRow('recent', { totalTokenUsage: 20 }), + sessionRow('old', { totalTokenUsage: 200 }), + ]); + + const result = await handler({}, 20, 'all', { usageHistory: true }); + + expect(result.map((s) => s.id)).toEqual(['recent', 'old']); + expect(result[0]).toEqual({ + id: 'recent', + title: 'recent', + model: 'sonnet', + providerId: null, + totalTokenUsage: 20, + contextTokens: 0, + contextWindow: 0, + userSendAt: null, + updatedAt: new Date(1_700_000_000_000).toISOString(), + }); + expect(result[0]).not.toHaveProperty('workingDir'); + const selectCalls = h.fakeDb.select.mock.calls as unknown as Array; + const projection = selectCalls[0]?.[0] as Record | undefined; + expect(Object.keys(projection ?? {})).toEqual([ + 'id', + 'title', + 'model', + 'providerId', + 'totalTokenUsage', + 'contextTokens', + 'contextWindow', + 'userSendAt', + 'updatedAt', + ]); + expect(h.fakeDb.select).toHaveBeenCalledTimes(1); + expect(h.listQuery).not.toHaveBeenCalled(); + expect(h.queryResults).toHaveLength(0); + expect(h.assertTrustedAppRendererEvent).toHaveBeenCalledWith({}); + }); + + it('rejects an untrusted renderer before running the unbounded usage-history query', async () => { + const handler = sessionsListHandler(); + h.assertTrustedAppRendererEvent.mockImplementationOnce(() => { + throw new Error('[PERMISSION_DENIED]'); + }); + + await expect(handler({}, 20, 'all', { usageHistory: true })).rejects.toThrow( + '[PERMISSION_DENIED]', + ); + expect(h.fakeDb.select).not.toHaveBeenCalled(); + }); + it('also includes pinned rows for the all-status bucket used by mobile detail filters', async () => { const handler = sessionsListHandler(); h.queryResults.push( diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 9b1245ed7c2..325a1e4fceb 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -22,7 +22,7 @@ import { DEFAULT_DRAFT_SESSION_TITLE, normalizeAutoTitle } from '@cindy/maker-sh import { getDbClient } from '../client/current'; import * as currentDb from '../client/current'; import type { DbClient } from '../client/DbClient'; -import { sessions, messages } from '../schema'; +import { sessions } from '../schema'; import { LIST_PREVIEW_EXTRACT_SQL, LATEST_VISIBLE_PREVIEW_FILTER_SQL, @@ -36,6 +36,7 @@ import { resolveBusinessSessionId } from '../../sessionIds'; import { normalizeDbAgentKind } from '../../../shared/agentKindConversion'; import { sessionToCamel, + sessionUsageToCamel, sessionCreateToRow, sessionPatchToRow, persistableSessionEffort, @@ -1070,8 +1071,14 @@ export function registerSessionIpc( ); ipcMain.handle( 'local-db:sessions:list', - async (_e, limit: unknown, status: unknown, options: unknown) => { + async (event, limit: unknown, status: unknown, options: unknown) => { const startedAt = performance.now(); + const usageHistory = shouldUseUsageHistoryQuery(options); + // The usage-history branch is an unbounded privileged read. Keep the + // legacy capped list available to device-link's synthetic event, but do + // not let an untrusted renderer turn the new branch into a full-table + // session disclosure. + if (usageHistory) assertTrustedAppRendererEvent(event); const snapshot = readCurrentDbClientSnapshot(); const db = snapshot?.client.drizzle ?? getDbClient().drizzle; const userId = snapshot?.userId ?? readCurrentDbClientUserId(); @@ -1118,10 +1125,21 @@ export function registerSessionIpc( }), ); }; + const loadUsageHistoryRows = async () => { + // 用量历史的“最耗任务”必须覆盖整个会话表,再由 renderer 按所选日历范围 + // 精确筛选;不能复用侧栏按 updatedAt 截断的 1000 行列表。这里刻意不算 + // messageCount / preview,避免为统计页引入整库 messages 扫描。 + const sourceFilter = inArray(sessions.source, DESKTOP_VISIBLE_SESSION_SOURCES); + const statusWhere = () => + statusFilter ? eq(sessions.status, statusFilter) : ne(sessions.status, 'deleted'); + const rows = await selectSessionUsageRows(db, and(sourceFilter, statusWhere())); + return rows.map(sessionUsageToCamel); + }; // key 用同一快照上的 userId + clientEpoch + 归一化参数。 // forceRefresh / status 重拉带 fresh,不并入写前那次查询。 - const result = - userId && !fresh + const result = usageHistory + ? await loadUsageHistoryRows() + : userId && !fresh ? await runSessionListSingleFlight( buildSessionListFlightKey({ userId, @@ -1139,7 +1157,8 @@ export function registerSessionIpc( const fields = JSON.stringify({ event: 'localDb.sessions.list.done', filter, - cap, + cap: usageHistory ? 'all' : cap, + usageHistory, includePinned, rows: result.length, queryElapsedMs: elapsedMs, @@ -1503,247 +1522,247 @@ export function registerSessionIpc( // 工作目录切换必须和发送/懒启动共用同一把路由锁。否则发送可能在 // 读取旧目录后、写入新目录前重建 runtime,随后仍在旧目录执行。 const update = async () => { - if (p.workspaceKind !== undefined) { - const value = p.workspaceKind; - if (value !== 'project' && value !== 'dialogue') { - throwIpcError('INVALID_PARAMS', `invalid workspaceKind: ${String(value)}`); + if (p.workspaceKind !== undefined) { + const value = p.workspaceKind; + if (value !== 'project' && value !== 'dialogue') { + throwIpcError('INVALID_PARAMS', `invalid workspaceKind: ${String(value)}`); + } } - } - const ALLOWED_UPDATE_ORCA_ROLES = new Set(['lead', 'worker']); - if ( - p.orcaRole !== undefined && - p.orcaRole !== null && - !ALLOWED_UPDATE_ORCA_ROLES.has(p.orcaRole as string) - ) { - throwIpcError('INVALID_PARAMS', `invalid orcaRole: ${String(p.orcaRole)}`); - } - if (typeof p.workingDir === 'string') { - p.workingDir = normalizeWorkingDirForStorage(p.workingDir) ?? null; - } - const REVIEW_IMMUTABLE_FIELDS = new Set([ - 'workingDir', - 'workspaceKind', - 'model', - 'providerId', - 'effort', - 'permissionMode', - 'fastMode', - 'planModeEnabled', - 'orcaRole', - 'extraDirs', - 'writableDirs', - ]); - if (Object.keys(p).some((key) => REVIEW_IMMUTABLE_FIELDS.has(key))) { - const [target] = await db - .select({ source: sessions.source }) - .from(sessions) - .where(eq(sessions.id, sid)) - .limit(1); - if (target?.source === 'review') { - throwIpcError( - 'UNSUPPORTED_CAPABILITY', - 'Review task settings are fixed to the source task', - ); + const ALLOWED_UPDATE_ORCA_ROLES = new Set(['lead', 'worker']); + if ( + p.orcaRole !== undefined && + p.orcaRole !== null && + !ALLOWED_UPDATE_ORCA_ROLES.has(p.orcaRole as string) + ) { + throwIpcError('INVALID_PARAMS', `invalid orcaRole: ${String(p.orcaRole)}`); } - } - // 会话移动转录迁移:patch 带 workingDir 时先留存旧值,update 后对比实际变化。 - // CLI 转录按 cwd 转码目录存放,workingDir 变了必须跟着搬,否则 resume 报 - // "No conversation found with session ID"(见 claude-transcript-relocation.ts)。 - const beforeMove = - p.workingDir !== undefined - ? ( - await db - .select({ - workingDir: sessions.workingDir, - agentKind: sessions.agentKind, - remoteHostId: sessions.remoteHostId, - }) - .from(sessions) - .where(eq(sessions.id, sid)) - )[0] - : undefined; - const movingLocalNonClaudeSession = - beforeMove && - beforeMove.agentKind !== 'cc' && - !beforeMove.remoteHostId && - beforeMove.workingDir && - typeof p.workingDir === 'string' && - p.workingDir && - normalizeWorkingDirForStorage(beforeMove.workingDir) !== p.workingDir; - // Pi/Codex keep a live Maker handle whose cwd is fixed at bootstrap. Close it - // before persisting the new directory so the next send lazily recreates the - // runtime with the moved session's cwd instead of continuing in the old one. - if (movingLocalNonClaudeSession) { - if (!opts.closeIdleSessionForMove) { - throwIpcError('INTERNAL', '会话移动 runtime 操作未配置'); + if (typeof p.workingDir === 'string') { + p.workingDir = normalizeWorkingDirForStorage(p.workingDir) ?? null; } - const idle = await opts.closeIdleSessionForMove(sid); - if (idle === false) { - throwIpcError('PRECONDITION_FAILED', '运行中的任务不能移动'); + const REVIEW_IMMUTABLE_FIELDS = new Set([ + 'workingDir', + 'workspaceKind', + 'model', + 'providerId', + 'effort', + 'permissionMode', + 'fastMode', + 'planModeEnabled', + 'orcaRole', + 'extraDirs', + 'writableDirs', + ]); + if (Object.keys(p).some((key) => REVIEW_IMMUTABLE_FIELDS.has(key))) { + const [target] = await db + .select({ source: sessions.source }) + .from(sessions) + .where(eq(sessions.id, sid)) + .limit(1); + if (target?.source === 'review') { + throwIpcError( + 'UNSUPPORTED_CAPABILITY', + 'Review task settings are fixed to the source task', + ); + } } - } - // 只有纯设置字段(model/effort 等)才跳过 bump;凡带 activity 字段 - // (clearedAt / sdkSessionId / status / token 用量等)仍需更新 updatedAt, - // 否则本地 /clear 后重启侧栏时间回退旧值。 - const SETTINGS_ONLY_FIELDS = new Set([ - 'model', - 'effort', - 'permissionMode', - 'fastMode', - 'planModeEnabled', - 'providerId', - 'orcaRole', - 'extraDirs', - 'writableDirs', - 'pinnedAt', - 'workingDir', - 'workspaceKind', - 'title', - ]); - const isSettingsOnly = Object.keys(p).every((k) => SETTINGS_ONLY_FIELDS.has(k)); - const setObj = sessionPatchToRow(p as Parameters[0], { - bumpUpdatedAt: !isSettingsOnly, - }); - if (p.clearedAt !== undefined) { - setObj.summary = null; - setObj.listPreview = null; - setObj.listPreviewRole = null; - } - // 用户手动改名(重命名框 / 侧边栏)走这条:告诉自动起名收手。同值改名不会让 - // 条件写落空,不显式说一声的话智能标题会把他刚保存的名字盖掉(review P1)。 - // **必须先于 UPDATE**:写库是一次 worker RPC 往返,改名提交与这里拿到回执之间 - // 有真实时间差,在那期间智能标题仍能满足 `WHERE title = 期望值` 把名字盖掉。 - // 先记号后写库,代价只是写库失败时该会话本进程内不再自动起名 —— 用户毕竟确实 - // 按下过保存,这个方向的偏差是安全的。 - if (typeof p.title === 'string') noteUserTitleWritten(sid); - await withStatusWriteLock( - sid, - p.status, - async () => { - await writeSessionPatch(db, sid, setObj, p.status); - cleanupSessionRuntimeForTerminalStatus(sid, p.status); - }, - p.workingDir !== undefined, - ); - // session-git-pr-context:/clear 经此处写 clearedAt——边界之前的消息对用户 - // 不可见,PR 引用同步重算(fire-and-forget,内部按 clearedAt/rewindAt 过滤)。 - if (p.clearedAt !== undefined) { - noteSessionClearBoundary(sid, p.clearedAt as string | null); - // sidebar-card-mode(codex review):summary 是基于 clear 前内容生成的,clear 后 - // 已过时;置顶卡片优先用 summary 而非 preview,不清就会继续显示旧任务摘要。 - // 与 clearedAt 同一句 UPDATE 置空,避免崩溃后非 NULL 缓存绕过 clear 边界。 - if (isOwnerScopeCurrent(ownerScope)) { - broadcastSessionPatched(sid, { summary: null, preview: null }, ownerScope); + // 会话移动转录迁移:patch 带 workingDir 时先留存旧值,update 后对比实际变化。 + // CLI 转录按 cwd 转码目录存放,workingDir 变了必须跟着搬,否则 resume 报 + // "No conversation found with session ID"(见 claude-transcript-relocation.ts)。 + const beforeMove = + p.workingDir !== undefined + ? ( + await db + .select({ + workingDir: sessions.workingDir, + agentKind: sessions.agentKind, + remoteHostId: sessions.remoteHostId, + }) + .from(sessions) + .where(eq(sessions.id, sid)) + )[0] + : undefined; + const movingLocalNonClaudeSession = + beforeMove && + beforeMove.agentKind !== 'cc' && + !beforeMove.remoteHostId && + beforeMove.workingDir && + typeof p.workingDir === 'string' && + p.workingDir && + normalizeWorkingDirForStorage(beforeMove.workingDir) !== p.workingDir; + // Pi/Codex keep a live Maker handle whose cwd is fixed at bootstrap. Close it + // before persisting the new directory so the next send lazily recreates the + // runtime with the moved session's cwd instead of continuing in the old one. + if (movingLocalNonClaudeSession) { + if (!opts.closeIdleSessionForMove) { + throwIpcError('INTERNAL', '会话移动 runtime 操作未配置'); + } + const idle = await opts.closeIdleSessionForMove(sid); + if (idle === false) { + throwIpcError('PRECONDITION_FAILED', '运行中的任务不能移动'); + } } - void recomputePrRefsForSession(sid).catch(() => undefined); - } - // workingDir 实际变化的本机 cc 会话:迁移 CLI 转录后再查询返回行/广播,保证 - // renderer 拿到更新结果时转录已就位(用户可立即续聊),且迁移中持久化的最新 - // sdkSessionId 能进返回行与广播 patch——否则 renderer 留着旧 resume id,下一次 - // lazy-create 仍会 resume 到 pre-fork 会话。内部 best-effort 不抛错。 - // 动态 import 避免 localDb → maker-host 的静态模块环(同下方 sessionTaskSummary)。 - if ( - beforeMove && - beforeMove.agentKind === 'cc' && - !beforeMove.remoteHostId && - beforeMove.workingDir && - typeof p.workingDir === 'string' && - p.workingDir && - normalizeWorkingDirForStorage(beforeMove.workingDir) !== p.workingDir - ) { - const m = await import('../../maker-host/claude-transcript-relocation.js'); - const reloc = await m.relocateClaudeTranscriptsForSessionMove( + // 只有纯设置字段(model/effort 等)才跳过 bump;凡带 activity 字段 + // (clearedAt / sdkSessionId / status / token 用量等)仍需更新 updatedAt, + // 否则本地 /clear 后重启侧栏时间回退旧值。 + const SETTINGS_ONLY_FIELDS = new Set([ + 'model', + 'effort', + 'permissionMode', + 'fastMode', + 'planModeEnabled', + 'providerId', + 'orcaRole', + 'extraDirs', + 'writableDirs', + 'pinnedAt', + 'workingDir', + 'workspaceKind', + 'title', + ]); + const isSettingsOnly = Object.keys(p).every((k) => SETTINGS_ONLY_FIELDS.has(k)); + const setObj = sessionPatchToRow(p as Parameters[0], { + bumpUpdatedAt: !isSettingsOnly, + }); + if (p.clearedAt !== undefined) { + setObj.summary = null; + setObj.listPreview = null; + setObj.listPreviewRole = null; + } + // 用户手动改名(重命名框 / 侧边栏)走这条:告诉自动起名收手。同值改名不会让 + // 条件写落空,不显式说一声的话智能标题会把他刚保存的名字盖掉(review P1)。 + // **必须先于 UPDATE**:写库是一次 worker RPC 往返,改名提交与这里拿到回执之间 + // 有真实时间差,在那期间智能标题仍能满足 `WHERE title = 期望值` 把名字盖掉。 + // 先记号后写库,代价只是写库失败时该会话本进程内不再自动起名 —— 用户毕竟确实 + // 按下过保存,这个方向的偏差是安全的。 + if (typeof p.title === 'string') noteUserTitleWritten(sid); + await withStatusWriteLock( sid, - beforeMove.workingDir, - p.workingDir, + p.status, + async () => { + await writeSessionPatch(db, sid, setObj, p.status); + cleanupSessionRuntimeForTerminalStatus(sid, p.status); + }, + p.workingDir !== undefined, ); - if (reloc.persistedSdkSessionId) { - (p as Record).sdkSessionId = reloc.persistedSdkSessionId; + // session-git-pr-context:/clear 经此处写 clearedAt——边界之前的消息对用户 + // 不可见,PR 引用同步重算(fire-and-forget,内部按 clearedAt/rewindAt 过滤)。 + if (p.clearedAt !== undefined) { + noteSessionClearBoundary(sid, p.clearedAt as string | null); + // sidebar-card-mode(codex review):summary 是基于 clear 前内容生成的,clear 后 + // 已过时;置顶卡片优先用 summary 而非 preview,不清就会继续显示旧任务摘要。 + // 与 clearedAt 同一句 UPDATE 置空,避免崩溃后非 NULL 缓存绕过 clear 边界。 + if (isOwnerScopeCurrent(ownerScope)) { + broadcastSessionPatched(sid, { summary: null, preview: null }, ownerScope); + } + void recomputePrRefsForSession(sid).catch(() => undefined); } - } - const row = await selectSessionWithCount(db, sid); - if (!row) throwIpcError('NOT_FOUND', 'Session 不存在'); - // 取消置顶后摘要不再有展示面,立刻清掉,避免列表/再次置顶前继续吃旧句。 - if (p.pinnedAt !== undefined && row.pinnedAt == null) { - await db.update(sessions).set({ summary: null }).where(eq(sessions.id, sid)); - row.summary = null; - } - const updated = sessionToCamel(row); - const projectTargetChanged = p.workspaceKind !== undefined || p.workingDir !== undefined; - const settingsChanged = Object.keys(p).some((key) => REMOTE_PERSIST_FIELDS.has(key)); - const titleChanged = p.title !== undefined; - // 归档/删除这类纯 status 变化也要广播:本机多窗口收敛靠 sessions:patched, - // 否则「在新窗口打开」的副窗口无从得知会话已被移除,仍停留在旧视图(#3175)。 - const statusChanged = p.status !== undefined; - if ( - projectTargetChanged && - row.workspaceKind === 'project' && - row.workingDir && - !row.remoteHostId - ) { - await upsertRecentWorkdir(row.workingDir, Date.now()); - } - // status 广播必须用**广播时刻的持久化真值**,不能带请求值 p.status,也不能用 - // 上方读行的快照:写入(withStatusWriteLock)与广播不在同一串行区间,且读行 - // 之后、广播之前还有 await(摘要清理 / recent-workdir / 转录迁移),两个窗口 - // 对同一任务并发操作时,本请求可能在此期间被另一窗口推进到更晚的终态(如 - // 归档写入后被删除)。用过期值广播会把镜像回滚成旧 UI 状态(已删除任务在 - // 副窗/控制端复活),且若本广播是最后一条,镜像不会自愈。 - // - // 因此含 status 的 patch 在广播前(所有 await 之后)**重读一次**:重读与广播 - // 之间无 await,同进程单事件循环下不可能再插入并发写;即便并发删除的广播 - // 晚于本广播到达,镜像最终也收敛到 deleted。 - let broadcastStatus = updated.status; - if (p.status !== undefined) { - const [currentRow] = await db - .select({ status: sessions.status }) - .from(sessions) - .where(eq(sessions.id, sid)) - .limit(1); - if (currentRow) broadcastStatus = currentRow.status; - } - const broadcastPatch = - p.pinnedAt === undefined && p.status === undefined - ? p - : { - ...p, - ...(p.pinnedAt !== undefined ? { pinnedAt: updated.pinnedAt } : {}), - ...(p.status !== undefined ? { status: broadcastStatus } : {}), - ...(p.pinnedAt !== undefined && updated.pinnedAt === null ? { summary: null } : {}), - ...(p.pinnedAt !== undefined && updated.pinnedAt !== null - ? { status: broadcastStatus } - : {}), - }; - if ( - projectTargetChanged || - settingsChanged || - titleChanged || - statusChanged || - p.pinnedAt !== undefined - ) { - if (isOwnerScopeCurrent(ownerScope)) { - broadcastSessionPatched(sid, broadcastPatch, ownerScope); + // workingDir 实际变化的本机 cc 会话:迁移 CLI 转录后再查询返回行/广播,保证 + // renderer 拿到更新结果时转录已就位(用户可立即续聊),且迁移中持久化的最新 + // sdkSessionId 能进返回行与广播 patch——否则 renderer 留着旧 resume id,下一次 + // lazy-create 仍会 resume 到 pre-fork 会话。内部 best-effort 不抛错。 + // 动态 import 避免 localDb → maker-host 的静态模块环(同下方 sessionTaskSummary)。 + if ( + beforeMove && + beforeMove.agentKind === 'cc' && + !beforeMove.remoteHostId && + beforeMove.workingDir && + typeof p.workingDir === 'string' && + p.workingDir && + normalizeWorkingDirForStorage(beforeMove.workingDir) !== p.workingDir + ) { + const m = await import('../../maker-host/claude-transcript-relocation.js'); + const reloc = await m.relocateClaudeTranscriptsForSessionMove( + sid, + beforeMove.workingDir, + p.workingDir, + ); + if (reloc.persistedSdkSessionId) { + (p as Record).sdkSessionId = reloc.persistedSdkSessionId; + } } - } - // sidebar-card-mode: 会话被置顶那一刻补生成任务摘要(turn-done 路径只覆盖 - // "置顶后又跑过 turn"的会话)。动态 import 避免 localDb → maker-host 的静态 - // 模块环;fire-and-forget,模块内部自带置顶/节流守卫。 - if (p.pinnedAt !== undefined && updated.pinnedAt !== null) { - void import('../../sessionTaskSummary.js').then((m) => - m.maybeGenerateSessionTaskSummary(sid, { force: true }), - ); - } - notifyAgentIslandSessionPatch(updated.id, { - status: updated.status, - title: updated.title, - workingDir: updated.workingDir, - workspaceKind: updated.workspaceKind, - }); - scheduleWorktreeRecycleForStatusChange(sid, p.status, { ownerScope, mediaDb: db }); - notifyGhostSessionStatusChange(sid, p.status, updated.workingDir); - cleanupSessionTerminalArtifacts(sid, p.status); - compactTerminalSessionToolResults(dbClient, sid, p.status); - return updated; + const row = await selectSessionWithCount(db, sid); + if (!row) throwIpcError('NOT_FOUND', 'Session 不存在'); + // 取消置顶后摘要不再有展示面,立刻清掉,避免列表/再次置顶前继续吃旧句。 + if (p.pinnedAt !== undefined && row.pinnedAt == null) { + await db.update(sessions).set({ summary: null }).where(eq(sessions.id, sid)); + row.summary = null; + } + const updated = sessionToCamel(row); + const projectTargetChanged = p.workspaceKind !== undefined || p.workingDir !== undefined; + const settingsChanged = Object.keys(p).some((key) => REMOTE_PERSIST_FIELDS.has(key)); + const titleChanged = p.title !== undefined; + // 归档/删除这类纯 status 变化也要广播:本机多窗口收敛靠 sessions:patched, + // 否则「在新窗口打开」的副窗口无从得知会话已被移除,仍停留在旧视图(#3175)。 + const statusChanged = p.status !== undefined; + if ( + projectTargetChanged && + row.workspaceKind === 'project' && + row.workingDir && + !row.remoteHostId + ) { + await upsertRecentWorkdir(row.workingDir, Date.now()); + } + // status 广播必须用**广播时刻的持久化真值**,不能带请求值 p.status,也不能用 + // 上方读行的快照:写入(withStatusWriteLock)与广播不在同一串行区间,且读行 + // 之后、广播之前还有 await(摘要清理 / recent-workdir / 转录迁移),两个窗口 + // 对同一任务并发操作时,本请求可能在此期间被另一窗口推进到更晚的终态(如 + // 归档写入后被删除)。用过期值广播会把镜像回滚成旧 UI 状态(已删除任务在 + // 副窗/控制端复活),且若本广播是最后一条,镜像不会自愈。 + // + // 因此含 status 的 patch 在广播前(所有 await 之后)**重读一次**:重读与广播 + // 之间无 await,同进程单事件循环下不可能再插入并发写;即便并发删除的广播 + // 晚于本广播到达,镜像最终也收敛到 deleted。 + let broadcastStatus = updated.status; + if (p.status !== undefined) { + const [currentRow] = await db + .select({ status: sessions.status }) + .from(sessions) + .where(eq(sessions.id, sid)) + .limit(1); + if (currentRow) broadcastStatus = currentRow.status; + } + const broadcastPatch = + p.pinnedAt === undefined && p.status === undefined + ? p + : { + ...p, + ...(p.pinnedAt !== undefined ? { pinnedAt: updated.pinnedAt } : {}), + ...(p.status !== undefined ? { status: broadcastStatus } : {}), + ...(p.pinnedAt !== undefined && updated.pinnedAt === null ? { summary: null } : {}), + ...(p.pinnedAt !== undefined && updated.pinnedAt !== null + ? { status: broadcastStatus } + : {}), + }; + if ( + projectTargetChanged || + settingsChanged || + titleChanged || + statusChanged || + p.pinnedAt !== undefined + ) { + if (isOwnerScopeCurrent(ownerScope)) { + broadcastSessionPatched(sid, broadcastPatch, ownerScope); + } + } + // sidebar-card-mode: 会话被置顶那一刻补生成任务摘要(turn-done 路径只覆盖 + // "置顶后又跑过 turn"的会话)。动态 import 避免 localDb → maker-host 的静态 + // 模块环;fire-and-forget,模块内部自带置顶/节流守卫。 + if (p.pinnedAt !== undefined && updated.pinnedAt !== null) { + void import('../../sessionTaskSummary.js').then((m) => + m.maybeGenerateSessionTaskSummary(sid, { force: true }), + ); + } + notifyAgentIslandSessionPatch(updated.id, { + status: updated.status, + title: updated.title, + workingDir: updated.workingDir, + workspaceKind: updated.workspaceKind, + }); + scheduleWorktreeRecycleForStatusChange(sid, p.status, { ownerScope, mediaDb: db }); + notifyGhostSessionStatusChange(sid, p.status, updated.workingDir); + cleanupSessionTerminalArtifacts(sid, p.status); + compactTerminalSessionToolResults(dbClient, sid, p.status); + return updated; }; return p.workingDir === undefined ? update() : withSessionRouteLock(sid, update); }); @@ -2216,11 +2235,11 @@ export async function resumeDeletedPiSubagentCleanup(): Promise { .where(and(eq(sessions.status, 'deleted'), eq(sessions.agentKind, 'pi'))); const diskDeleted = idsFromDisk.length === 0 - ? [] - : await db - .select({ id: sessions.id }) - .from(sessions) - .where(and(inArray(sessions.id, idsFromDisk), eq(sessions.status, 'deleted'))); + ? [] + : await db + .select({ id: sessions.id }) + .from(sessions) + .where(and(inArray(sessions.id, idsFromDisk), eq(sessions.status, 'deleted'))); const ids = new Set(); for (const row of deletedPi) ids.add(row.id); @@ -2317,6 +2336,44 @@ interface SessionListRow { latestMessageRole: string | null; } +/** 用量历史专用行查询:全量读取 sessions,但跳过 sidebar 的消息预览子查询。 */ +function selectSessionUsageRows( + db: DbClient['drizzle'], + where: SQL | undefined, +): Promise< + Array< + Pick< + typeof sessions.$inferSelect, + | 'id' + | 'title' + | 'model' + | 'providerId' + | 'totalTokenUsage' + | 'contextTokens' + | 'contextWindow' + | 'userSendAt' + | 'updatedAt' + > + > +> { + return db + .select({ + id: sessions.id, + title: sessions.title, + model: sessions.model, + providerId: sessions.providerId, + totalTokenUsage: sessions.totalTokenUsage, + contextTokens: sessions.contextTokens, + contextWindow: sessions.contextWindow, + userSendAt: sessions.userSendAt, + updatedAt: sessions.updatedAt, + }) + .from(sessions) + .where(where) + .orderBy(desc(sessions.updatedAt)) + .then((rows) => rows); +} + /** * sessions:list 的行查询——**两段式**:CTE 先按排序取够 `cap` 个 id,主查询只对这批行算 * messageCount 与 preview。 @@ -2412,6 +2469,14 @@ function shouldBypassSessionListSingleFlight(options: unknown): boolean { ); } +function shouldUseUsageHistoryQuery(options: unknown): boolean { + return !!( + options && + typeof options === 'object' && + (options as { usageHistory?: unknown }).usageHistory === true + ); +} + function mergeSessionListRows( recentRows: readonly T[], pinnedRows: readonly T[], diff --git a/apps/desktop/src/main/localDb/mapper.ts b/apps/desktop/src/main/localDb/mapper.ts index b4ddc3e0995..053a099524e 100644 --- a/apps/desktop/src/main/localDb/mapper.ts +++ b/apps/desktop/src/main/localDb/mapper.ts @@ -20,6 +20,7 @@ import type { // 类型从 renderer 共享(仅 type-only import,运行时无 import 副作用) import type { Session, + UsageHistorySession, SessionStatus, Message, MessageRole, @@ -54,6 +55,18 @@ import { type SessionRow = typeof sessions.$inferSelect; type SessionInsert = typeof sessions.$inferInsert; +type SessionUsageRow = Pick< + SessionRow, + | 'id' + | 'title' + | 'model' + | 'providerId' + | 'totalTokenUsage' + | 'contextTokens' + | 'contextWindow' + | 'userSendAt' + | 'updatedAt' +>; /** * 运行时固定 effort 模型用 `null`;`sessions.effort` 是 NOT NULL 枚举。 @@ -480,6 +493,26 @@ function msToIso(ms: number | null | undefined): string | null { return new Date(ms).toISOString(); } +/** + * 用量历史榜单的最小行映射。 + * + * 与 sessionToCamel 分开,避免全量 usageHistory 查询把 workingDir、SDK + * 标识、远程主机等只属于会话管理的字段泄露到 Renderer。 + */ +export function sessionUsageToCamel(row: SessionUsageRow): UsageHistorySession { + return { + id: row.id, + title: row.title, + model: row.model, + providerId: row.providerId, + totalTokenUsage: row.totalTokenUsage, + contextTokens: row.contextTokens, + contextWindow: row.contextWindow, + userSendAt: msToIso(row.userSendAt), + updatedAt: new Date(row.updatedAt).toISOString(), + }; +} + function isoToMs(iso: string | null): number | null { if (iso === null || iso === undefined) return null; const t = new Date(iso).getTime(); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts index be8fb61d5ef..ca7a6e6a657 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/authStatusUsageHandlers.test.ts @@ -1198,6 +1198,7 @@ describe('maker usage IPC handlers', () => { triggerClaudeAccountUsageRefresh: vi.fn(), readModelPricing: vi.fn(), readReferenceModelPricing: vi.fn(() => ({})), + assertTrustedSender: vi.fn(), readUsageHistory: vi.fn().mockResolvedValue(emptyHistory), emptyUsageHistory: vi.fn(() => emptyHistory), ...over, @@ -1219,14 +1220,19 @@ describe('maker usage IPC handlers', () => { it('rejects SuperGrok usage reads from untrusted senders', async () => { const harness = new IpcHarness(); - const readXaiSubscriptionUsageSnapshot = vi.fn().mockResolvedValue({ planLabel: 'SuperGrok Heavy' }); + const readXaiSubscriptionUsageSnapshot = vi + .fn() + .mockResolvedValue({ planLabel: 'SuperGrok Heavy' }); const assertTrustedSender = vi.fn(() => { throw new Error('[PERMISSION_DENIED] untrusted'); }); - registerMakerUsageHandlers(harness, makeUsageDeps({ - readXaiSubscriptionUsageSnapshot, - assertTrustedSender, - })); + registerMakerUsageHandlers( + harness, + makeUsageDeps({ + readXaiSubscriptionUsageSnapshot, + assertTrustedSender, + }), + ); await expect(harness.invoke(MAKER_INVOKE.USAGE_XAI_SUBSCRIPTION)).rejects.toThrow( /PERMISSION_DENIED/, @@ -1239,10 +1245,13 @@ describe('maker usage IPC handlers', () => { const snapshot = { planLabel: 'SuperGrok Heavy', creditUsagePercent: 2 }; const readXaiSubscriptionUsageSnapshot = vi.fn().mockResolvedValue(snapshot); const assertTrustedSender = vi.fn(); - registerMakerUsageHandlers(harness, makeUsageDeps({ - readXaiSubscriptionUsageSnapshot, - assertTrustedSender, - })); + registerMakerUsageHandlers( + harness, + makeUsageDeps({ + readXaiSubscriptionUsageSnapshot, + assertTrustedSender, + }), + ); await expect(harness.invoke(MAKER_INVOKE.USAGE_XAI_SUBSCRIPTION)).resolves.toEqual(snapshot); expect(assertTrustedSender).toHaveBeenCalledOnce(); @@ -1486,6 +1495,26 @@ describe('maker usage IPC handlers', () => { expect(readUsageHistory).toHaveBeenLastCalledWith(undefined); }); + it('rejects usage-history reads from an untrusted sender before parsing or querying', async () => { + const harness = new IpcHarness(); + const readUsageHistory = vi.fn().mockResolvedValue(emptyHistory); + const assertTrustedSender = vi.fn(() => { + throw new Error('[PERMISSION_DENIED] untrusted'); + }); + + registerMakerUsageHandlers(harness, makeUsageDeps({ readUsageHistory, assertTrustedSender })); + + await expect( + harness.invoke(MAKER_INVOKE.USAGE_HISTORY, { + days: 'all', + modelDays: 'all', + forceRefresh: true, + }), + ).rejects.toThrow(/PERMISSION_DENIED/); + expect(assertTrustedSender).toHaveBeenCalledOnce(); + expect(readUsageHistory).not.toHaveBeenCalled(); + }); + it('falls back to empty history payload when the read throws', async () => { const harness = new IpcHarness(); const readUsageHistory = vi.fn().mockRejectedValue(new Error('db gone')); diff --git a/apps/desktop/src/main/maker-ipc/usageHandlers.ts b/apps/desktop/src/main/maker-ipc/usageHandlers.ts index 98c631e819c..d8688726a84 100644 --- a/apps/desktop/src/main/maker-ipc/usageHandlers.ts +++ b/apps/desktop/src/main/maker-ipc/usageHandlers.ts @@ -73,7 +73,11 @@ export interface MakerUsageHandlerDeps { consumeCodexRateLimitReset(idempotencyKey: string): Promise; readClaudeSubscriptionUsageSnapshot(): Promise; readXaiSubscriptionUsageSnapshot(): Promise; - assertTrustedSender?(event: unknown): void; + /** + * 用量历史会读取完整的 sessions 表,必须在任何参数解析或 DB 查询前确认 + * 请求来自受信任的主页面 renderer。 + */ + assertTrustedSender(event: unknown): void; readClaudeAccountUsageSnapshot(): ClaudeAccountUsageSnapshot | null; triggerClaudeAccountUsageRefresh(force: boolean): Promise; readModelPricing(): Promise; @@ -158,12 +162,25 @@ export function registerMakerUsageHandlers( // 用量历史聚合 (首页仪表盘) — 查询型 handler, DB 出错回退空 payload 让 // renderer 正常渲染空态 (与同文件其它 usage 读取的 fallback-data 口径一致)。 - registry.handle(MAKER_INVOKE.USAGE_HISTORY, async (_e, opts: unknown) => { - const raw = (opts ?? {}) as { days?: unknown; forceRefresh?: unknown }; - const days = typeof raw.days === 'number' && Number.isFinite(raw.days) ? raw.days : undefined; + registry.handle(MAKER_INVOKE.USAGE_HISTORY, async (event, opts: unknown) => { + deps.assertTrustedSender(event); + const raw = (opts ?? {}) as { days?: unknown; modelDays?: unknown; forceRefresh?: unknown }; + const days = + raw.days === 'all' + ? ('all' as const) + : typeof raw.days === 'number' && Number.isFinite(raw.days) + ? raw.days + : undefined; + const modelDays = + raw.modelDays === 'all' + ? ('all' as const) + : typeof raw.modelDays === 'number' && Number.isFinite(raw.modelDays) + ? raw.modelDays + : undefined; const forceRefresh = raw.forceRefresh === true; const readOpts = { ...(days === undefined ? {} : { days }), + ...(modelDays === undefined ? {} : { modelDays }), ...(forceRefresh ? { forceRefresh: true } : {}), }; try { diff --git a/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts b/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts index 84e6525c7b5..11ae95429cf 100644 --- a/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts +++ b/apps/desktop/src/main/usage/__tests__/usageHistory.test.ts @@ -320,6 +320,82 @@ describe('getSubscriptionValuePriceFor', () => { }); describe('readUsageHistoryWith', () => { + it('supports an all-time request without changing the default 30-day model window', async () => { + const oldRow = modelRow('2026-04-01', 'codex', 'gpt-5.5', actual(0), { + inputTokens: 123, + }); + const recentRow = modelRow(TODAY, 'codex', 'gpt-5.5', actual(0), { + inputTokens: 456, + }); + let requestedSince = ''; + const deps = makeDeps({ + getModelUsageSince: async (sinceDayKey) => { + requestedSince = sinceDayKey; + return [oldRow, recentRow]; + }, + }); + + const defaultResult = await readUsageHistoryWith(deps); + expect(defaultResult.modelDaily.map((row) => row.day)).toEqual([TODAY]); + + const allResult = await readUsageHistoryWith(deps, { days: 'all', modelDays: 'all' }); + expect(requestedSince).toBe('0000-01-01'); + expect(allResult.modelDaily.map((row) => row.day)).toEqual(['2026-04-01', TODAY]); + expect(allResult.totals.last30DaysTokens).toBe(579); + expect(allResult.modelDaily[0]).toMatchObject({ + inputTokens: 123, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + }); + }); + + it('keeps all-time heatmap token rows when the model window is finite', async () => { + const oldRow = modelRow('2026-04-01', 'codex', 'gpt-5.5', actual(0), { + inputTokens: 123, + }); + const recentRow = modelRow(TODAY, 'codex', 'gpt-5.5', actual(0), { + inputTokens: 456, + }); + let requestedSince = ''; + const result = await readUsageHistoryWith( + makeDeps({ + getModelUsageSince: async (sinceDayKey) => { + requestedSince = sinceDayKey; + return [oldRow, recentRow]; + }, + }), + { days: 'all', modelDays: 30 }, + ); + + expect(requestedSince).toBe('0000-01-01'); + expect(result.days.map((row) => row.day)).toEqual(['2026-04-01', TODAY]); + expect(result.modelDaily.map((row) => row.day)).toEqual([TODAY]); + }); + + it('keeps all-time model rows when the heatmap window is finite', async () => { + const oldRow = modelRow('2026-04-01', 'codex', 'gpt-5.5', actual(0), { + inputTokens: 123, + }); + const recentRow = modelRow(TODAY, 'codex', 'gpt-5.5', actual(0), { + inputTokens: 456, + }); + let requestedSince = ''; + const result = await readUsageHistoryWith( + makeDeps({ + getModelUsageSince: async (sinceDayKey) => { + requestedSince = sinceDayKey; + return [oldRow, recentRow]; + }, + }), + { days: 30, modelDays: 'all' }, + ); + + expect(requestedSince).toBe('0000-01-01'); + expect(result.days.map((row) => row.day)).toEqual([TODAY]); + expect(result.modelDaily.map((row) => row.day)).toEqual(['2026-04-01', TODAY]); + }); + it('aggregates actual money and subscription value without double counting', async () => { const estimateAmount = regionalUsdAmount(2); const result = await readUsageHistoryWith(makeDeps({ diff --git a/apps/desktop/src/main/usage/usageHistory.ts b/apps/desktop/src/main/usage/usageHistory.ts index 459c27ff080..d46a5710157 100644 --- a/apps/desktop/src/main/usage/usageHistory.ts +++ b/apps/desktop/src/main/usage/usageHistory.ts @@ -101,6 +101,10 @@ export interface UsageHistoryModelDay { /** 订阅 token 价值估算金额。 */ subscriptionEstimateMoney: RegionalMoney; tokens: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreateTokens: number; } export interface UsageHistoryPayload { @@ -119,9 +123,9 @@ export interface UsageHistoryPayload { estimatesPending: boolean; /** day >= today-windowDays 的日总额 (稀疏, 无消费日无行; renderer 补格)。 */ days: UsageHistoryDay[]; - /** 近 30 天每日 × 模型明细 (堆叠柱状图分段用; 表上线前的历史日无行)。 */ + /** 按 modelDays 窗口返回的每日 × 模型明细 (堆叠柱状图分段用)。 */ modelDaily: UsageHistoryModelDay[]; - /** 近 30 天按 (agentKind, model) 聚合, 按可比金额降序。 */ + /** 按 modelDays 窗口聚合的 (agentKind, model), 按可比金额降序。 */ models: UsageHistoryModel[]; streak: { current: number; longest: number }; totals: { @@ -265,10 +269,19 @@ function diskCachePath(): string { return path.join(app.getPath('userData'), 'cache', DISK_CACHE_FILE); } -function optsKey(opts?: { days?: number }): string { - const days = Math.min(366, Math.max(1, Math.floor(opts?.days ?? 140))); +function normalizeWindowDays(value: number | 'all' | undefined, fallback: number): number | 'all' { + if (value === 'all') return 'all'; + return Math.min(366, Math.max(1, Math.floor(value ?? fallback))); +} + +function optsKey(opts?: UsageHistoryReadOptions): string { + const days = normalizeWindowDays(opts?.days, 140); + const modelDays = normalizeWindowDays(opts?.modelDays, MODEL_WINDOW_DAYS); const userId = getCurrentDbClientUserId() ?? 'anonymous'; - return `user=${encodeURIComponent(userId)}|days=${days}`; + // Keep the pre-window-split key for the default request so existing disk + // snapshots remain readable. Non-default model windows get their own key. + const modelSuffix = modelDays === MODEL_WINDOW_DAYS ? '' : `|modelDays=${modelDays}`; + return `user=${encodeURIComponent(userId)}|days=${days}${modelSuffix}`; } function isFiniteNumber(value: unknown): value is number { @@ -459,7 +472,9 @@ function isLatestRefreshGeneration(expectedOptsKey: string, generation: number): } export interface UsageHistoryReadOptions { - days?: number; + days?: number | 'all'; + /** Model/table aggregation window. `all` is used by Settings → Usage History. */ + modelDays?: number | 'all'; /** * true = 事件触发的刷新, 需要绕过 10s 内存快返, 立即重新聚合 DB。 * mount / 展开仍使用 stale-while-refresh 快路径保证首帧速度。 @@ -515,7 +530,8 @@ export async function readUsageHistoryWith( deps: UsageHistoryDeps, opts?: UsageHistoryReadOptions, ): Promise { - const windowDays = Math.min(366, Math.max(1, Math.floor(opts?.days ?? 140))); + const windowDays = normalizeWindowDays(opts?.days, 140); + const modelWindowDays = normalizeWindowDays(opts?.modelDays, MODEL_WINDOW_DAYS); const todayKey = deps.todayKey(); // 历史聚合只有一个金额口径:兼容当前账本币种的金额保留,无法确定换算语义的 @@ -573,28 +589,36 @@ export async function readUsageHistoryWith( allDays.map((row) => [row.day, row.money.amount]), ); - const heatmapCutoff = shiftDayKey(todayKey, -(windowDays - 1)); - const modelCutoff = shiftDayKey(todayKey, -(MODEL_WINDOW_DAYS - 1)); + const heatmapCutoff = windowDays === 'all' ? null : shiftDayKey(todayKey, -(windowDays - 1)); + const modelCutoff = modelWindowDays === 'all' + ? null + : shiftDayKey(todayKey, -(modelWindowDays - 1)); // 一次查询同时服务两个窗口: 热力图 tooltip 的每日 token (heatmap 窗口) 与 // 模型拆分聚合 (30 天窗口)。取更早的 cutoff (ISO day key 字符串可直接比较)。 - const usageRowsSince = heatmapCutoff < modelCutoff ? heatmapCutoff : modelCutoff; + const usageRowsSince = heatmapCutoff === null || modelCutoff === null + ? '0000-01-01' + : heatmapCutoff < modelCutoff + ? heatmapCutoff + : modelCutoff; const allModelRows = (await deps.getModelUsageSince(usageRowsSince)).map((row) => ({ ...row, money: keepCompatibleMoney(row.money), })); - const modelRows = allModelRows.filter((r) => r.day >= modelCutoff); + const modelRows = modelCutoff === null + ? allModelRows + : allModelRows.filter((r) => r.day >= modelCutoff); // 每日 token 合计 → days 的 tooltip 数据。codex-only 日 daily_spend 无行 ($ 只有 // Claude 记), 也要并进 days, 否则热力图那天 hover 不到 token。 const tokensByDay = new Map(); for (const row of allModelRows) { - if (row.day < heatmapCutoff) continue; + if (heatmapCutoff !== null && row.day < heatmapCutoff) continue; const rowTokens = row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheCreateTokens; tokensByDay.set(row.day, (tokensByDay.get(row.day) ?? 0) + rowTokens); } const daysMap = new Map(); for (const r of allDays) { - if (r.day >= heatmapCutoff && r.money.amount > 0) { + if ((heatmapCutoff === null || r.day >= heatmapCutoff) && r.money.amount > 0) { daysMap.set(r.day, { day: r.day, money: r.money, tokens: 0 }); } } @@ -721,6 +745,10 @@ export async function readUsageHistoryWith( apiMoney, subscriptionEstimateMoney, tokens: row.inputTokens + row.outputTokens + row.cacheReadTokens + row.cacheCreateTokens, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + cacheReadTokens: row.cacheReadTokens, + cacheCreateTokens: row.cacheCreateTokens, }; }); // 可比金额 (实报或估算) 降序; 无金额的 token-only 行排最后 (按 token 量降序) @@ -750,10 +778,9 @@ export async function readUsageHistoryWith( heuristicThreshold(ACTIVE_DAY_MIN_USD, row.money.currency), ) .map((row) => row.day); - const last30Cutoff = shiftDayKey(todayKey, -(MODEL_WINDOW_DAYS - 1)); const last30ActualByDay = new Map(); for (const row of allDays) { - if (row.day >= last30Cutoff) { + if (modelCutoff === null || row.day >= modelCutoff) { last30ActualByDay.set(row.day, row.money); } } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 432c5e98ce0..18c1eee7a8a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -386,6 +386,50 @@ type DiscordBotSessionAuthCheckWire = { providerId: string | null; providerLabel: string | null; }; + +/** Public shape of the local session-list bridge options. */ +type LocalDbSessionListOptions = { + includePinned?: boolean; + fresh?: boolean; + usageHistory?: boolean; +}; +type LocalDbSessionListRegularOptions = Omit & { + usageHistory?: false | undefined; +}; +type LocalDbSessionListUsageOptions = Omit & { + usageHistory: true; +}; +type LocalDbSessionListResult = + | import('../renderer/lib/ccAgent.types').Session[] + | import('../renderer/lib/ccAgent.types').UsageHistorySession[]; + +function listLocalDbSessions( + limit?: number, + status?: string, + options?: LocalDbSessionListRegularOptions, +): Promise; +function listLocalDbSessions( + limit?: number, + status?: string, + options?: LocalDbSessionListUsageOptions, +): Promise; +function listLocalDbSessions( + limit?: number, + status?: string, + options?: LocalDbSessionListOptions, +): Promise; +function listLocalDbSessions( + limit?: number, + status?: string, + options?: LocalDbSessionListOptions, +): Promise { + return ipcRenderer.invoke( + 'local-db:sessions:list', + limit, + status, + options, + ) as Promise; +} /** * 个人 Telegram bot 的传输状态(与 @cindy/im 的 IMStatus 同形; preload 不引包, * 就地声明)。offline = 凭证保留但用户主动下线, 与 idle(未配置)严格区分。 @@ -5011,8 +5055,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }), }, sessions: { - list: (limit?: number, status?: string, options?: unknown): Promise => - ipcRenderer.invoke('local-db:sessions:list', limit, status, options), + list: listLocalDbSessions, create: (body?: unknown): Promise => ipcRenderer.invoke('local-db:sessions:create', body), get: (id: string): Promise => ipcRenderer.invoke('local-db:sessions:get', id), @@ -6870,7 +6913,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('maker:usage:reference-model-pricing'), onReferenceModelPricingChanged: fanOutMakerUsageReferenceModelPricing, /** 用量历史聚合 (首页仪表盘: 热力图 + streak + 按模型拆分, main 侧算好)。 */ - getHistory: (opts?: { days?: number; forceRefresh?: boolean }): Promise => + getHistory: (opts?: { + days?: number | 'all'; + modelDays?: number | 'all'; + forceRefresh?: boolean; + }): Promise => ipcRenderer.invoke('maker:usage:history', opts), /** Claude USD 推送 (per-turn, agentKind=claude-code 时订阅它)。 */ onTodaySpendChanged: fanOutMakerUsageTodaySpend, diff --git a/apps/desktop/src/renderer/__tests__/homeUsageDashboardSource.test.ts b/apps/desktop/src/renderer/__tests__/homeUsageDashboardSource.test.ts index 7923be9ef59..e1c926405ca 100644 --- a/apps/desktop/src/renderer/__tests__/homeUsageDashboardSource.test.ts +++ b/apps/desktop/src/renderer/__tests__/homeUsageDashboardSource.test.ts @@ -15,6 +15,18 @@ const heatmapSource = readFileSync( resolve(__dirname, '../components/new-chat/UsageHeatmap.tsx'), 'utf8', ); +const tokenBarsSource = readFileSync( + resolve(__dirname, '../components/settings/usage/UsageTokenBars.tsx'), + 'utf8', +); +const breakdownTablesSource = readFileSync( + resolve(__dirname, '../components/settings/usage/UsageBreakdownTables.tsx'), + 'utf8', +); +const usageHistorySectionSource = readFileSync( + resolve(__dirname, '../components/settings/usage/UsageHistorySection.tsx'), + 'utf8', +); describe('HomeUsageDashboard source contract', () => { it('uses the Claude account daily spend for the visible today amount when available', () => { @@ -67,6 +79,35 @@ describe('HomeUsageDashboard source contract', () => { expect(dailyBarsSource).not.toContain("money.currency ?? 'USD'"); }); + it('keeps selected usage chart days flat without ad-hoc shadows', () => { + expect(heatmapSource).not.toContain('boxShadow'); + expect(tokenBarsSource).not.toContain('boxShadow'); + expect(heatmapSource).toContain("'2px solid var(--focus-ring-soft)'"); + expect(tokenBarsSource).toContain("'2px solid var(--focus-ring-soft)'"); + }); + + it('keeps chart visuals small while giving interactive controls a stable hit target', () => { + expect(heatmapSource).toContain('const INTERACTIVE_CELL_PX = 24;'); + expect(heatmapSource).toContain('const cellSize = onDayClick ? INTERACTIVE_CELL_PX : CELL_PX;'); + expect(heatmapSource).toContain('style={{ width: cellSize, height: cellSize }}'); + expect(tokenBarsSource).toContain('const hitHeight = Math.max(24, visualHeight);'); + expect(tokenBarsSource).toContain('height: visualHeight'); + expect(tokenBarsSource).toContain('minWidth: bars.list.length * 24'); + expect(tokenBarsSource).toContain('minWidth: 24'); + expect(tokenBarsSource).toContain('overflow-x-auto'); + }); + + it('keeps the home heatmap non-interactive when no day callback is supplied', () => { + expect(heatmapSource).not.toContain('disabled={!onDayClick}'); + expect(heatmapSource).toContain('return onDayClick ? ('); + }); + + it('uses the shared model key rank for chart and filtered model-table colors', () => { + expect(usageHistorySectionSource).toContain('colorOrder={colorOrder}'); + expect(breakdownTablesSource).toContain('usageRankOf(colorOrder, row.key)'); + expect(breakdownTablesSource).toContain('colorOrder: string[];'); + }); + it('shows cached usage immediately while marking background refresh', () => { expect(source).toContain( 'const { history, refreshing: usageRefreshing } = useUsageHistory({ paused: collapsed, userId: user?.id });', @@ -116,9 +157,9 @@ describe('HomeUsageDashboard source contract', () => { expect(hookSource).toContain( 'const [historyState, setHistoryState] = useState<{ scopeKey: string; value: UsageHistoryPayload | null }>', ); - expect(hookSource).toContain('setHistoryState({ scopeKey, value });'); + expect(hookSource).toContain('setHistoryState({ scopeKey: scopedKey, value });'); expect(hookSource).toContain( - 'history: historyState.scopeKey === scopeKey ? historyState.value : scope.cache,', + 'const history = historyState.scopeKey === scopedKey ? historyState.value : scope.cache;', ); }); @@ -158,7 +199,7 @@ describe('HomeUsageDashboard source contract', () => { expect(hookSource).toContain('if (opts?.resetPricingRetry) scope.pricingRetryDone = false;'); expect(hookSource).toContain('scope.pricingRetryDone = false;'); expect(hookSource).toContain( - 'void load(scopeKey, { forceRefresh: true, resetPricingRetry: true });', + 'void load(scopedKey, { forceRefresh: true, resetPricingRetry: true });', ); }); @@ -169,4 +210,49 @@ describe('HomeUsageDashboard source contract', () => { "next.models.some((m) => m.agentKind === 'codex' && m.estimatedCostUsd === null)", ); }); + + it('lets the token-only settings page consume pending estimates without changing the home gate', () => { + const hookSource = readFileSync(resolve(__dirname, '../hooks/useUsageHistory.ts'), 'utf8'); + const settingsSource = readFileSync( + resolve(__dirname, '../components/settings/usage/UsageHistorySection.tsx'), + 'utf8', + ); + expect(settingsSource).toContain('allowPendingEstimates: true'); + expect(hookSource).toContain('allowPendingEstimates?: boolean;'); + expect(hookSource).toContain('if (!scope.request.allowPendingEstimates) return scope.cache;'); + }); + + it('keeps the settings usage loading state separate from a loaded empty payload', () => { + const hookSource = readFileSync(resolve(__dirname, '../hooks/useUsageHistory.ts'), 'utf8'); + expect(usageHistorySectionSource).toContain('const loading = history === null && refreshing;'); + expect(usageHistorySectionSource).toContain( + 'const loadFailed = history === null && !refreshing;', + ); + expect(usageHistorySectionSource).toContain( + 'const empty = history !== null && isUsageHistoryEmpty(history);', + ); + expect(usageHistorySectionSource).toContain("t('usageHistory.loadFailed')"); + expect(hookSource).toContain('useState(scope.cache === null || scope.refreshing)'); + expect(hookSource.indexOf('void load(scopedKey);')).toBeLessThan( + hookSource.indexOf('setIsRefreshing(activeScope.refreshing);'), + ); + expect(hookSource).toContain( + 'historyState.scopeKey === scopedKey ? isRefreshing : scope.cache === null || scope.refreshing;', + ); + }); + + it('keeps interactive usage controls discoverable and semantically selected', () => { + expect(usageHistorySectionSource).toContain( + 'data-[state=checked]:bg-[var(--settings-menu-bg-selected)]', + ); + expect(usageHistorySectionSource).toContain( + 'data-[state=checked]:text-[var(--settings-menu-text-selected)]', + ); + expect(heatmapSource).toContain('const accessibleLabel ='); + expect(heatmapSource).toContain('aria-label={accessibleLabel}'); + expect(tokenBarsSource).toContain('function parseDayKeyLocal(dayKey: string): Date'); + expect(tokenBarsSource).toContain( + 'aria-label={`${dateFormatter.format(parseDayKeyLocal(b.day))}', + ); + }); }); diff --git a/apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx b/apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx index 43c1545b3d0..db5a224625a 100644 --- a/apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx +++ b/apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx @@ -373,11 +373,13 @@ export function HomeUsageDashboard(): React.JSX.Element { {/* 展开区: 热力图 + 右栏 (按模型拆分; 拆分数据未积累出来前退化为每日总额柱状图) */} {!collapsed && (
- +
+ +
{/* 右栏: 每日堆叠柱状图 (高=日总额, 分段=模型构成)。不放图例 — 每日模型 明细全在柱子 hover tooltip 里 (省空间, 用户确认的取舍)。 diff --git a/apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx b/apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx index ced17bc05b5..da3352d3fec 100644 --- a/apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx +++ b/apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx @@ -7,22 +7,21 @@ * 强度口径由 `metric` 决定: 'money' (默认, 首页仪表盘) 或 'tokens' (设置 → 用量历史, * 那个页面不出现任何金额, 见 issue #2785)。两种口径共用同一套分位分桶与色阶。 * - * 视觉: 7 行 (周日起) × ~20 列周网格, 单色阶 — 非零值按 4 分位分桶, + * 视觉: 7 行 (周日起) × 至少 20 列周网格, 单色阶 — 非零值按 4 分位分桶, * 用 color-mix 在 --accent-emphasis 上做透明度阶梯 (黑白反色设计, 不引入彩色)。 - * 140 个格子用原生 title 做 tooltip (Radix per-cell 实例太重)。 + * 格子用原生 title 做 tooltip (Radix per-cell 实例太重)。 */ -import React, { useMemo } from 'react'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { formatCompactTokens, formatMoney } from '@/lib/usageFormat'; -import { - DEFAULT_USAGE_CURRENCY, - type RegionalMoney, -} from '../../../shared/regionalMoney'; +import { DEFAULT_USAGE_CURRENCY, type RegionalMoney } from '../../../shared/regionalMoney'; const CELL_PX = 12; +const INTERACTIVE_CELL_PX = 24; const GAP_PX = 3; +const MIN_HEATMAP_WEEKS = 20; const EMPTY_MONEY_CURRENCY = DEFAULT_USAGE_CURRENCY; /** 非零值分桶的 color-mix 浓度阶梯 (level 1..4)。 */ const LEVEL_MIX = [0.22, 0.42, 0.68, 1]; @@ -49,6 +48,80 @@ function toDayKey(date: Date): string { return `${date.getFullYear()}-${mm}-${dd}`; } +function startOfWeek(date: Date): Date { + const result = new Date(date); + result.setHours(12, 0, 0, 0); + result.setDate(result.getDate() - result.getDay()); + return result; +} + +function calendarDayDistance(from: Date, to: Date): number { + const fromUtc = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); + const toUtc = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.round((toUtc - fromUtc) / 86_400_000); +} + +function fitWeeksForWidth(width: number, cellSize = CELL_PX): number { + if (!(width > 0)) return 0; + return Math.max(1, Math.floor((width + GAP_PX) / (cellSize + GAP_PX))); +} + +/** + * 周网格按周日对齐时,当前周的未来占位也会占用格子。返回覆盖完整 + * `windowDays` 历史的最小周数,避免周日等锚点把窗口前端截掉。 + */ +export function heatmapWeeksForWindow(todayKey: string, windowDays: number): number { + const today = parseDayKey(todayKey); + if (!todayKey || Number.isNaN(today.getTime()) || !(windowDays > 0)) return MIN_HEATMAP_WEEKS; + const windowStart = new Date(today); + windowStart.setDate(windowStart.getDate() - Math.ceil(windowDays) + 1); + return Math.floor(calendarDayDistance(startOfWeek(windowStart), startOfWeek(today)) / 7) + 1; +} + +/** 至少保留 20 周;有更早数据且容器放得下时,尽量展示更多历史。 */ +export function resolveHeatmapWeeks({ + days, + todayKey, + availableWidth, + minimumWeeks = MIN_HEATMAP_WEEKS, + windowDays, + metric, + cellSize = CELL_PX, +}: { + days: Array<{ day: string; money?: RegionalMoney; tokens?: number }>; + todayKey: string; + availableWidth: number; + minimumWeeks?: number; + windowDays?: number; + metric?: 'money' | 'tokens'; + cellSize?: number; +}): number { + const minWeeks = Math.max( + MIN_HEATMAP_WEEKS, + Math.ceil(minimumWeeks), + windowDays === undefined ? 0 : heatmapWeeksForWindow(todayKey, windowDays), + ); + const today = parseDayKey(todayKey); + if (!todayKey || Number.isNaN(today.getTime())) return minWeeks; + + const intensityOf = (day: { money?: RegionalMoney; tokens?: number }): number => + metric === 'tokens' ? (day.tokens ?? 0) : (day.money?.amount ?? 0); + const earliestDay = days + .filter( + (day) => day.day && day.day <= todayKey && (metric === undefined || intensityOf(day) > 0), + ) + .map((day) => day.day) + .sort()[0]; + const dataWeeks = earliestDay + ? Math.floor( + calendarDayDistance(startOfWeek(parseDayKey(earliestDay)), startOfWeek(today)) / 7, + ) + 1 + : 0; + const fitWeeks = fitWeeksForWidth(availableWidth, cellSize); + const widthLimit = fitWeeks > 0 ? fitWeeks : minWeeks; + return Math.max(minWeeks, Math.min(Math.max(minWeeks, dataWeeks), widthLimit)); +} + /** 非零花费的 4 分位阈值 → level 1..4。 */ function levelFor(cost: number, thresholds: [number, number, number]): number { if (cost <= 0) return 0; @@ -63,33 +136,91 @@ export function UsageHeatmap({ todayKey, windowDays, metric = 'money', + selectedDay, + onDayClick, + onVisibleWeeksChange, }: { days: Array<{ day: string; money: RegionalMoney; tokens?: number }>; todayKey: string; windowDays: number; /** 格子深浅按哪一维分桶。'tokens' 下 tooltip 也只显示 token, 不出现金额。 */ metric?: 'money' | 'tokens'; + selectedDay?: string | null; + onDayClick?: (day: string) => void; + onVisibleWeeksChange?: (weeks: number) => void; }): React.JSX.Element { const { t, i18n } = useTranslation(); + const plotRef = useRef(null); + const [availableWidth, setAvailableWidth] = useState(0); + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(i18n.language, { year: 'numeric', month: 'short', day: 'numeric' }), + [i18n.language], + ); + + useLayoutEffect(() => { + const element = plotRef.current; + if (!element) return undefined; + + const updateWidth = (width: number): void => { + const nextWidth = Math.max(0, Math.round(width)); + setAvailableWidth((previous) => (previous === nextWidth ? previous : nextWidth)); + }; + updateWidth(element.clientWidth); + if (typeof ResizeObserver === 'undefined') return undefined; + + const observer = new ResizeObserver((entries) => { + updateWidth(entries[0]?.contentRect.width ?? element.clientWidth); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const minimumWeeks = Math.max(MIN_HEATMAP_WEEKS, Math.ceil(windowDays / 7)); + const cellSize = onDayClick ? INTERACTIVE_CELL_PX : CELL_PX; + const visibleWeeks = resolveHeatmapWeeks({ + days, + todayKey, + availableWidth, + minimumWeeks, + windowDays, + metric, + cellSize, + }); + + useLayoutEffect(() => { + onVisibleWeeksChange?.(visibleWeeks); + }, [onVisibleWeeksChange, visibleWeeks]); const { columns, monthLabels } = useMemo(() => { const spendByDay = new Map(days.map((d) => [d.day, d.money])); const tokensByDay = new Map(days.map((d) => [d.day, d.tokens ?? 0])); const intensityOf = (row: { money: RegionalMoney; tokens?: number }): number => metric === 'tokens' ? (row.tokens ?? 0) : row.money.amount; - const nonZero = days.map(intensityOf).filter((v) => v > 0).sort((a, b) => a - b); - const q = (p: number): number => (nonZero.length ? nonZero[Math.min(nonZero.length - 1, Math.floor(p * nonZero.length))] : 0); - const thresholds: [number, number, number] = [q(0.25), q(0.5), q(0.75)]; - const today = parseDayKey(todayKey); - const start = new Date(today); - start.setDate(start.getDate() - (windowDays - 1)); - start.setDate(start.getDate() - start.getDay()); // 对齐到周日, 行号 = weekday + const start = startOfWeek(today); + start.setDate(start.getDate() - (visibleWeeks - 1) * 7); + const startKey = toDayKey(start); + + // 分桶只反映当前屏幕可见的日期。全量历史中较早的极端值不应改变 + // 用户正在看的这些格子的相对深浅。 + const nonZero = days + .filter((day) => day.day >= startKey && day.day <= todayKey) + .map(intensityOf) + .filter((v) => v > 0) + .sort((a, b) => a - b); + const q = (p: number): number => + nonZero.length ? nonZero[Math.min(nonZero.length - 1, Math.floor(p * nonZero.length))] : 0; + const thresholds: [number, number, number] = [q(0.25), q(0.5), q(0.75)]; const cells: HeatCell[] = []; const cursor = new Date(start); - while (cursor <= today) { + for (let index = 0; index < visibleWeeks * 7; index += 1) { const key = toDayKey(cursor); + // `cursor` stays at noon so DST transitions cannot skip a calendar day; + // compare the rendered day keys instead of Date instants so today's cell + // is not mistaken for a future placeholder. + const placeholder = key > todayKey; const money = spendByDay.get(key) ?? { amount: 0, currency: days[0]?.money.currency ?? EMPTY_MONEY_CURRENCY, @@ -101,25 +232,10 @@ export function UsageHeatmap({ money, tokens: tokensByDay.get(key) ?? 0, level: levelFor(intensityOf({ money, tokens: tokensByDay.get(key) ?? 0 }), thresholds), - placeholder: false, + placeholder, }); cursor.setDate(cursor.getDate() + 1); } - // 末列补满 7 行 (未来日占位, 保持网格矩形) - while (cells.length % 7 !== 0) { - cells.push({ - day: '', - money: { - amount: 0, - currency: days[0]?.money.currency ?? EMPTY_MONEY_CURRENCY, - approximate: false, - kind: 'actual-cost', - }, - tokens: 0, - level: 0, - placeholder: true, - }); - } const cols: HeatCell[][] = []; for (let i = 0; i < cells.length; i += 7) cols.push(cells.slice(i, i + 7)); @@ -135,63 +251,83 @@ export function UsageHeatmap({ }); return { columns: cols, monthLabels: labels }; - }, [days, todayKey, windowDays, i18n.language, metric]); + }, [days, todayKey, visibleWeeks, i18n.language, metric]); - const colPitch = CELL_PX + GAP_PX; + const colPitch = cellSize + GAP_PX; return ( -
- {/* 月份标签行 */} -
- {monthLabels.map((m) => ( - - {m.text} - - ))} -
- {/* 网格 */} -
- {columns.map((col, ci) => ( -
- {col.map((cell, ri) => - cell.placeholder ? ( -
- ) : ( -
0 - ? t('usageDashboard.tokensOnly', { - tokens: formatCompactTokens(cell.tokens), - }) - : t('usageHistory.heatmap.emptyCell') - }` - : `${cell.day} · ${formatMoney(cell.money)}${ - cell.tokens > 0 - ? ` · ${t('usageDashboard.tokensOnly', { tokens: formatCompactTokens(cell.tokens) })}` - : '' - }` - } - className="rounded-[3px]" - style={{ - width: CELL_PX, - height: CELL_PX, - backgroundColor: - cell.level === 0 - ? 'var(--surface-chip)' - : `color-mix(in srgb, var(--accent-emphasis) ${LEVEL_MIX[cell.level - 1] * 100}%, var(--surface-chip))`, - }} - /> - ), - )} -
- ))} +
+
+ {/* 月份标签行。nowrap 防止最右侧月份被挤成上下两行。 */} +
+ {monthLabels.map((m) => ( + + {m.text} + + ))} +
+ {/* 网格 */} +
+ {columns.map((col, ci) => ( +
+ {col.map((cell, ri) => { + if (cell.placeholder) { + return
; + } + + const usageSummary = + metric === 'tokens' + ? cell.tokens > 0 + ? t('usageDashboard.tokensOnly', { + tokens: formatCompactTokens(cell.tokens), + }) + : t('usageHistory.heatmap.emptyCell') + : `${formatMoney(cell.money)}${ + cell.tokens > 0 + ? ` · ${t('usageDashboard.tokensOnly', { tokens: formatCompactTokens(cell.tokens) })}` + : '' + }`; + const title = `${cell.day} · ${usageSummary}`; + const accessibleLabel = `${dateFormatter.format(parseDayKey(cell.day))} · ${usageSummary}`; + const visualClassName = onDayClick ? 'rounded-full' : 'rounded-[3px]'; + const visualStyle = { + width: CELL_PX, + height: CELL_PX, + backgroundColor: + cell.level === 0 + ? 'var(--surface-chip)' + : `color-mix(in srgb, var(--accent-emphasis) ${LEVEL_MIX[cell.level - 1] * 100}%, var(--surface-chip))`, + outline: + selectedDay === cell.day ? '2px solid var(--focus-ring-soft)' : undefined, + outlineOffset: selectedDay === cell.day ? '1px' : undefined, + }; + const visual = ( +
+ ); + + return onDayClick ? ( + + ) : ( +
{visual}
+ ); + })} +
+ ))} +
); diff --git a/apps/desktop/src/renderer/components/new-chat/__tests__/usageHeatmapMetric.test.tsx b/apps/desktop/src/renderer/components/new-chat/__tests__/usageHeatmapMetric.test.tsx index 5101e71e397..e6d3c5fa145 100644 --- a/apps/desktop/src/renderer/components/new-chat/__tests__/usageHeatmapMetric.test.tsx +++ b/apps/desktop/src/renderer/components/new-chat/__tests__/usageHeatmapMetric.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { UsageHeatmap } from '../UsageHeatmap'; +import { heatmapWeeksForWindow, resolveHeatmapWeeks, UsageHeatmap } from '../UsageHeatmap'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -39,10 +39,87 @@ function cellTitles(container: HTMLElement): string[] { } describe('UsageHeatmap metric', () => { - it('分桶取值随 metric 切换 (金额口径与 token 口径给出不同深浅)', () => { - const asMoney = render( - , + it('至少显示 20 周,且宽容器会展示数据覆盖的更多周数', () => { + expect( + resolveHeatmapWeeks({ + days: [{ day: '2026-08-22' }], + todayKey: '2026-08-22', + availableWidth: 100, + }), + ).toBe(20); + + expect( + resolveHeatmapWeeks({ + days: [{ day: '2026-01-01' }], + todayKey: '2026-08-22', + availableWidth: 500, + }), + ).toBe(33); + + expect( + resolveHeatmapWeeks({ + days: [{ day: '2025-01-01' }], + todayKey: '2026-08-22', + availableWidth: 500, + }), + ).toBe(33); + + expect( + resolveHeatmapWeeks({ + days: [{ day: '2025-01-01' }], + todayKey: '2026-08-22', + availableWidth: 900, + }), + ).toBe(60); + }); + + it('按当前 metric 过滤历史起点,不让零值日期扩展热力图', () => { + const history = [ + { day: '2025-01-01', money: money(100), tokens: 0 }, + { day: '2026-08-20', money: money(0), tokens: 1_000 }, + ]; + + expect( + resolveHeatmapWeeks({ + days: history, + todayKey: '2026-08-22', + availableWidth: 900, + metric: 'money', + }), + ).toBe(60); + expect( + resolveHeatmapWeeks({ + days: history, + todayKey: '2026-08-22', + availableWidth: 900, + metric: 'tokens', + }), + ).toBe(20); + }); + + it('周日锚点也覆盖完整的 windowDays 历史,不把未来占位挤掉最早日期', () => { + expect(heatmapWeeksForWindow('2026-08-22', 140)).toBe(20); + expect(heatmapWeeksForWindow('2026-08-23', 140)).toBe(21); + expect( + resolveHeatmapWeeks({ + days: [{ day: '2026-08-23' }], + todayKey: '2026-08-23', + availableWidth: 100, + windowDays: 140, + }), + ).toBe(21); + }); + + it('月份标签保持单行,避免最右侧月份换行', () => { + const { container } = render(); + const monthLabel = [...container.querySelectorAll('span')].find((node) => + node.className.includes('whitespace-nowrap'), ); + expect(monthLabel).toBeTruthy(); + }); + + it('分桶取值随 metric 切换 (金额口径与 token 口径给出不同深浅)', () => { + const asMoney = render(); const moneyStyles = cellStyles(asMoney.container); asMoney.unmount(); @@ -55,6 +132,35 @@ describe('UsageHeatmap metric', () => { expect(moneyStyles).not.toEqual(tokenStyles); }); + it('只用当前可见日期计算分桶阈值', () => { + const visibleDays = [ + { day: '2026-08-20', money: money(10), tokens: 10 }, + { day: '2026-08-21', money: money(20), tokens: 20 }, + { day: '2026-08-22', money: money(30), tokens: 30 }, + ]; + const withOlderOutlier = [ + { day: '2025-01-01', money: money(1_000_000), tokens: 1_000_000 }, + ...visibleDays, + ]; + + const baseline = render( + , + ); + const baselineCell = baseline.container.querySelector( + 'div[title^="2026-08-21"]', + ); + const baselineColor = (baselineCell as HTMLElement).style.backgroundColor; + baseline.unmount(); + + const withOutlier = render( + , + ); + const outlierCell = withOutlier.container.querySelector( + 'div[title^="2026-08-21"]', + ); + expect((outlierCell as HTMLElement).style.backgroundColor).toBe(baselineColor); + }); + it('token 口径的 tooltip 不出现金额', () => { const { container } = render( , @@ -65,9 +171,116 @@ describe('UsageHeatmap metric', () => { }); it('默认仍是金额口径 (首页仪表盘行为不变)', () => { - const { container } = render( - , - ); + const { container } = render(); expect(cellTitles(container).some((title) => title.includes('$'))).toBe(true); }); + + it('日期格可点击并上报所选日期', () => { + const onDayClick = vi.fn(); + const { getByRole } = render( + , + ); + + fireEvent.click(getByRole('button', { name: /Aug 21, 2026/ })); + expect(onDayClick).toHaveBeenCalledWith('2026-08-21'); + }); + + it('可点击日期格保留小视觉标记但提供 24px 命中区域', () => { + const { getByRole } = render( + , + ); + + const button = getByRole('button', { name: /Aug 21, 2026/ }); + const visual = button.firstElementChild as HTMLElement; + expect(button.style.width).toBe('24px'); + expect(button.style.height).toBe('24px'); + expect(visual.style.width).toBe('12px'); + expect(visual.style.height).toBe('12px'); + }); + + it('今天的日期格不是未来占位,并且可以点击', () => { + const onDayClick = vi.fn(); + const { getByRole } = render( + , + ); + + fireEvent.click(getByRole('button', { name: /Aug 22, 2026/ })); + expect(onDayClick).toHaveBeenCalledWith('2026-08-22'); + }); + + it('首页非交互日期格不渲染为禁用按钮', () => { + const { container, queryByRole } = render( + , + ); + + expect(queryByRole('button', { name: /Aug 21, 2026/ })).toBeNull(); + expect(container.querySelector('div[title^="2026-08-21"]')).toBeTruthy(); + }); + + it('可点击日期格的可见彩色表面使用 pill 圆角', () => { + const onDayClick = vi.fn(); + const { getByRole } = render( + , + ); + + expect(getByRole('button', { name: /Aug 21, 2026/ }).firstElementChild?.className).toContain( + 'rounded-full', + ); + }); + + it('可访问名称包含本地化日期和用量摘要', () => { + const { getByRole } = render( + , + ); + + expect(getByRole('button', { name: /Aug 21, 2026.*tokens/ })).toBeTruthy(); + }); + + it('选中日期使用 outline 而不是页面内阴影', () => { + const { getByRole } = render( + , + ); + + const selectedCell = getByRole('button', { name: /Aug 21, 2026/ }) + .firstElementChild as HTMLElement; + expect(selectedCell.style.outline).toBe('2px solid var(--focus-ring-soft)'); + expect(selectedCell.style.boxShadow).toBe(''); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx index f56dc8b4cca..8b1485f68d8 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx @@ -14,12 +14,8 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { formatCompactTokens, formatModelShort } from '@/lib/usageFormat'; -import { usageRankColor } from '@/components/new-chat/usagePalette'; -import { - type AgentTokenRow, - type ModelTokenRow, - type UsageAgentKind, -} from './usageHistoryStats'; +import { usageRankColor, usageRankOf } from '@/components/new-chat/usagePalette'; +import { type AgentTokenRow, type ModelTokenRow, type UsageAgentKind } from './usageHistoryStats'; import { formatUsagePercent } from './formatUsagePercent'; const UNKNOWN_VALUE = '—'; @@ -53,9 +49,7 @@ function Swatch({ rank }: { rank: number }): React.JSX.Element { } function HitRateCell({ value }: { value: number | null }): React.JSX.Element { - return ( - {value === null ? UNKNOWN_VALUE : formatUsagePercent(value)} - ); + return {value === null ? UNKNOWN_VALUE : formatUsagePercent(value)}; } function ShareCell({ share, rank }: { share: number; rank: number }): React.JSX.Element { @@ -70,7 +64,17 @@ function ShareCell({ share, rank }: { share: number; rank: number }): React.JSX. ); } -export function UsageAgentTable({ rows }: { rows: AgentTokenRow[] }): React.JSX.Element { +export function UsageAgentTable({ + rows, + rangeLabel, + todayLabel, + hideToday = false, +}: { + rows: AgentTokenRow[]; + rangeLabel: string; + todayLabel?: string; + hideToday?: boolean; +}): React.JSX.Element { const { t } = useTranslation(); const total = rows.reduce((sum, row) => sum + row.tokens, 0); @@ -98,9 +102,13 @@ export function UsageAgentTable({ rows }: { rows: AgentTokenRow[] }): React.JSX. {t('usageHistory.byAgent.col.agent')} - {t('usageHistory.byAgent.col.total')} + + {t('usageHistory.byAgent.col.totalInRange', { range: rangeLabel })} + {t('usageHistory.byAgent.col.share')} - {t('usageHistory.byAgent.col.today')} + {!hideToday ? ( + {todayLabel ?? t('usageHistory.byAgent.col.today')} + ) : null} {t('usageHistory.byAgent.col.hitRate')} @@ -122,9 +130,11 @@ export function UsageAgentTable({ rows }: { rows: AgentTokenRow[] }): React.JSX. {formatCompactTokens(row.tokens)} - - {row.todayTokens > 0 ? formatCompactTokens(row.todayTokens) : UNKNOWN_VALUE} - + {!hideToday ? ( + + {row.todayTokens > 0 ? formatCompactTokens(row.todayTokens) : UNKNOWN_VALUE} + + ) : null} {row.modelCount} @@ -136,7 +146,15 @@ export function UsageAgentTable({ rows }: { rows: AgentTokenRow[] }): React.JSX. ); } -export function UsageModelTable({ rows }: { rows: ModelTokenRow[] }): React.JSX.Element { +export function UsageModelTable({ + rows, + rangeLabel, + colorOrder, +}: { + rows: ModelTokenRow[]; + rangeLabel: string; + colorOrder: string[]; +}): React.JSX.Element { const { t } = useTranslation(); return ( @@ -146,7 +164,9 @@ export function UsageModelTable({ rows }: { rows: ModelTokenRow[] }): React.JSX. {t('usageHistory.byModel.col.model')} - {t('usageHistory.byModel.col.total')} + + {t('usageHistory.byModel.col.totalInRange', { range: rangeLabel })} + {t('usageHistory.byModel.col.share')} {t('usageHistory.byModel.col.input')} {t('usageHistory.byModel.col.output')} @@ -158,29 +178,32 @@ export function UsageModelTable({ rows }: { rows: ModelTokenRow[] }): React.JSX. - {rows.map((row, index) => ( - - - - - - {formatModelShort(row.model)} - - {/* 同一模型 id 可能跨 agent 撞名, 标签让两行区分得开 */} - - {row.agentKind} + {rows.map((row) => { + const rank = usageRankOf(colorOrder, row.key); + return ( + + + + + + {formatModelShort(row.model)} + + {/* 同一模型 id 可能跨 agent 撞名, 标签让两行区分得开 */} + + {row.agentKind} + - - - {formatCompactTokens(row.tokens)} - - {formatCompactTokens(row.inputTokens)} - {formatCompactTokens(row.outputTokens)} - {formatCompactTokens(row.cacheReadTokens)} - {formatCompactTokens(row.cacheCreateTokens)} - - - ))} + + {formatCompactTokens(row.tokens)} + + {formatCompactTokens(row.inputTokens)} + {formatCompactTokens(row.outputTokens)} + {formatCompactTokens(row.cacheReadTokens)} + {formatCompactTokens(row.cacheCreateTokens)} + + + ); + })} ); diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx index e03acfd5d19..0c20285b665 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx @@ -9,16 +9,18 @@ * 不读任何账号快照 —— local / cloud personal / cloud org 三种身份看到的是同一个页面。 * * 两个窗口不同, 标题里分别写明: - * - 热力图与连续活跃天数走 days[] (140 天 = 20 周) - * - 按模型 / 按 agent / 每日柱图走 models[] 与 modelDaily (30 天) + * - 热力图走完整 days[],按卡片实际宽度显示至少 20 周;连续活跃天数也走同一份历史 + * - 按模型 / 按 agent 走按筛选范围重聚合的 models[] 与 modelDaily + * - 每日柱图固定展示近 30 天, 与筛选范围无关 * * 首页的 HomeUsageDashboard 是金额口径的姊妹实现, 本页不复用它的外壳组件 * (见 UsageTokenBars 的注释), 但共享同一条聚合链路与配色。 */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { RefreshCw } from 'lucide-react'; +import * as Select from '@radix-ui/react-select'; +import { Check, ChevronDown, RefreshCw } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { useAuth } from '@/contexts/AuthContext'; @@ -33,7 +35,12 @@ import { buildAgentRows, buildModelRows, buildSummary, + chartUsageHistoryPayload, + filterUsageHistoryPayload, + isUsageHistorySingleDay, isUsageHistoryEmpty, + usageRangeDay, + type UsageHistoryRange, } from './usageHistoryStats'; /** 与 useUsageHistory 的拉取窗口一致 (20 周)。 */ @@ -55,9 +62,7 @@ function Card({
{title} - {subtitle ? ( - {subtitle} - ) : null} + {subtitle ? {subtitle} : null} {refreshing ? ( @@ -71,25 +76,69 @@ function Card({ } export function UsageHistorySection(): React.JSX.Element { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { user } = useAuth(); - const { history, refreshing } = useUsageHistory({ userId: user?.id }); + const { history, refreshing } = useUsageHistory({ + userId: user?.id, + days: 'all', + modelDays: 'all', + allowPendingEstimates: true, + }); + const [range, setRange] = useState('30d'); + const [heatmapWeeks, setHeatmapWeeks] = useState(20); + + // The Today option is an exact-day range too: use the payload's main-side + // date anchor so both dropdown and chart entry points show the same selection. + const selectedDay = usageRangeDay(range, history?.todayKey); + const rangeLabel = useMemo(() => { + if (selectedDay) { + const [year, month, day] = selectedDay.split('-').map(Number); + return new Intl.DateTimeFormat(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(new Date(year, (month ?? 1) - 1, day ?? 1)); + } + return t(`usageHistory.range.${range}`); + }, [i18n.language, range, selectedDay, t]); + const todayLabel = selectedDay + ? t('usageHistory.stats.todayTokensInRange', { range: rangeLabel }) + : t('usageHistory.stats.todayTokens'); + const hideToday = isUsageHistorySingleDay(range); - const summary = useMemo(() => buildSummary(history), [history]); - const modelRows = useMemo(() => buildModelRows(history), [history]); - const agentRows = useMemo(() => buildAgentRows(history), [history]); + const filteredHistory = useMemo( + () => filterUsageHistoryPayload(history, range), + [history, range], + ); + const chartHistory = useMemo(() => chartUsageHistoryPayload(history), [history]); + const summary = useMemo(() => buildSummary(filteredHistory), [filteredHistory]); + const modelRows = useMemo(() => buildModelRows(filteredHistory), [filteredHistory]); + const agentRows = useMemo(() => buildAgentRows(filteredHistory), [filteredHistory]); // 配色顺序必须来自 modelRows 而不是 history.models: 后者由 main 按**可比金额**降序排 // (usageHistory.ts 的 comparable), 本页只讲 token —— 直接用它会让同一个模型在柱图与 // 模型表里配到不同颜色, 还会让"金额高但 token 很少"的模型挤掉真正的 token 前 N 名。 const colorOrder = useMemo( - () => modelRows.slice(0, USAGE_TOP_MODELS).map((m) => usageModelKey(m.agentKind, m.model)), - [modelRows], + () => + buildModelRows(chartHistory) + .slice(0, USAGE_TOP_MODELS) + .map((m) => usageModelKey(m.agentKind, m.model)), + [chartHistory], ); // 任务行与用量聚合是两条数据源: 聚合里有 token, 本地任务却可能一条都没有 // (用户删光了会话, 或列表还没加载完)。空时整张卡片不渲染, 不留空壳。 - const taskRows = useTopTokenSessions(); - const empty = isUsageHistoryEmpty(history); + const taskRows = useTopTokenSessions(range, history?.todayKey, user?.id); + const loading = history === null && refreshing; + const loadFailed = history === null && !refreshing; + const empty = history !== null && isUsageHistoryEmpty(history); + + const handleRangeChange = (value: string): void => { + setRange(value as UsageHistoryRange); + }; + + const handleDayClick = (day: string): void => { + setRange(`day:${day}` as UsageHistoryRange); + }; return (
@@ -100,57 +149,130 @@ export function UsageHistorySection(): React.JSX.Element { {t('usageHistory.description')}

- {empty ? ( +
+ + {t('usageHistory.range.label')} + + + + + + + + + + + + {(['all', '30d', '7d', 'today'] as const).map((option) => ( + + {t(`usageHistory.range.${option}`)} + + + + + ))} + {range.startsWith('day:') && selectedDay ? ( + + {rangeLabel} + + + + + ) : null} + + + + +
+ + {loading ? ( +
+ +
+ ) : loadFailed ? ( +
+ {t('usageHistory.loadFailed')} +
+ ) : empty ? (
{t('usageHistory.empty')}
) : ( <> - + - + {agentRows.length > 0 && ( - +
- +
)} {modelRows.length > 0 && ( - +
- +
)} @@ -158,10 +280,10 @@ export function UsageHistorySection(): React.JSX.Element { {taskRows.length > 0 && (
- +
)} diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx index 2538ddfd032..b555bbb59a1 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx @@ -38,24 +38,35 @@ function StatCell({ return tip ? {cell} : cell; } -export function UsageStatRow({ summary }: { summary: UsageSummary }): React.JSX.Element { +export function UsageStatRow({ + summary, + rangeLabel, + todayLabel, + hideToday = false, +}: { + summary: UsageSummary; + rangeLabel: string; + todayLabel?: string; + /** Exact-day drilldown already has the same value in the range card. */ + hideToday?: boolean; +}): React.JSX.Element { const { t } = useTranslation(); return (
- 0 ? formatCompactTokens(summary.todayTokens) : UNKNOWN_VALUE - } - label={t('usageHistory.stats.todayTokens')} - /> + {!hideToday ? ( + 0 ? formatCompactTokens(summary.todayTokens) : UNKNOWN_VALUE} + label={todayLabel ?? t('usageHistory.stats.todayTokens')} + /> + ) : null} 0 ? formatCompactTokens(summary.last30DaysTokens) : UNKNOWN_VALUE } - label={t('usageHistory.stats.totalTokens')} + label={t('usageHistory.stats.totalTokensInRange', { range: rangeLabel })} /> - +
); } diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx index ae9fd4d3feb..2b6ff61c490 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx @@ -1,7 +1,7 @@ /** * UsageTaskTable — 「最耗 token 的任务」。 * - * 数据来自会话列表 (useCCSessions → sessionsStore), 不新增 IPC: + * 数据来自本地会话表的用量历史查询 (并与 useCCSessions 的实时快照合并): * - tokens: Session.totalTokenUsage * - 上下文: contextTokens / contextWindow * - 供应商: Session.providerId, 经 providerDisplayNameById 映射成展示名 @@ -11,7 +11,7 @@ * * 两处口径必须在 UI 上讲清楚, 否则会被误读: * 1. totalTokenUsage 是该任务的**生命周期累计**, 不是窗口内增量。本表筛的是 - * "近 30 天内活跃过的任务", 所以一个三个月前开始、昨天还在跑的任务会带着 + * "所选时间范围内活跃过的任务", 所以一个三个月前开始、昨天还在跑的任务会带着 * 它的全部累计出现 —— 表头 tooltip 写明这一点。 * 2. providerId 是会话**当前值**而非每轮事实, 且可为 null (跟随默认路由)。 * null 留空 (照 SessionInfoMeta 既有的"无数据不显示"), 不臆造默认供应商; @@ -20,28 +20,33 @@ * * 远程会话 (device-link) 的 token 字段可能缺失或为 0 —— 同样按"无数据不显示"过滤掉。 * - * 已知取舍: 候选集来自 sessionsStore 的列表桶 (DEFAULT_LIMIT = 1000, 按 updatedAt 降序), - * 因此"近 30 天内更新过的会话超过 1000 条"时, 被截断的高用量会话进不了 Top N。 - * 修它需要一次按 total_token_usage 排序的专用查询 —— 那是新增 IPC, 不进本版。 + * 用量历史查询跳过侧栏按 updatedAt 截断的 1000 行列表,返回完整 sessions 候选集; + * 因此 all / 较早日期范围仍能从全量历史中筛出 Top N。查询只读取 sessions 表, + * 不计算消息预览,避免把统计页变成整库 messages 扫描。 */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { formatCompactTokens, formatModelShort } from '@/lib/usageFormat'; import { useCCSessions } from '@/hooks/useCCSessions'; -import type { Session } from '@/lib/ccAgent.types'; +import * as sessionService from '@/lib/sessionService'; +import type { Session, UsageHistorySession } from '@/lib/ccAgent.types'; import { formatSidebarTime } from '@/features/cc-agent/lib/formatSidebarTime'; import { useProviders } from '@/hooks/useProviders'; +import { isDataOwnerPushCurrent } from '@/contexts/dataOwnerGeneration'; import { usageRankColor } from '@/components/new-chat/usagePalette'; import { providerDisplayNameById } from '@/lib/providerDisplayName'; import { formatUsagePercent } from './formatUsagePercent'; +import { + isUsageDayInRange, + isUsageHistorySingleDay, + type UsageHistoryRange, +} from './usageHistoryStats'; /** 展示条数 —— 与"最耗"的语义匹配, 不做成完整列表 (那是任务侧栏的事)。 */ const TOP_TASKS = 8; -/** 与本页其它区块一致的窗口。 */ -const WINDOW_DAYS = 30; const UNKNOWN_VALUE = '—'; /** @@ -72,35 +77,187 @@ function lastActiveIso(session: { updatedAt: string; userSendAt: string | null } : session.updatedAt; } -function activeWithinWindow(iso: string | null | undefined, cutoffMs: number): boolean { +function localDayKeyFromIso(iso: string): string | null { + const date = new Date(iso); + if (!Number.isFinite(date.getTime())) return null; + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${month}-${day}`; +} + +function activeWithinRange( + iso: string | null | undefined, + range: UsageHistoryRange, + todayKey: string, +): boolean { if (!iso) return false; - const ts = Date.parse(iso); - return Number.isFinite(ts) && ts >= cutoffMs; + const day = localDayKeyFromIso(iso); + return day !== null && isUsageDayInRange(day, todayKey, range); +} + +/** + * 用量范围只应按用户真正发送消息的时间筛选。updatedAt 还会被重命名、归档、 + * 置顶等元数据更新推进;只有存量行没有 userSendAt 时才回退到 updatedAt。 + */ +export function usageActivityIso(session: Pick): string { + return session.userSendAt ?? session.updatedAt; +} + +/** + * 任务表只按会话的最后活跃时间筛选,无法证明某个任务在单日范围内实际活跃过。 + * 因此 today 与 day:* 都隐藏这张聚合表,避免把累计 token 误读成当天用量。 + */ +export function shouldHideUsageTaskTable(range: UsageHistoryRange): boolean { + return isUsageHistorySingleDay(range); +} + +export type UsageSessionsState = + | { scopeKey: string | null; status: 'loading' } + | { scopeKey: string | null; status: 'ready'; sessions: UsageHistorySession[] } + | { scopeKey: string | null; status: 'error' }; + +/** Only a snapshot belonging to the current data owner may feed the table. */ +export function usageSessionsForScope( + state: UsageSessionsState, + scopeKey: string | null, +): UsageHistorySession[] { + return state.scopeKey === scopeKey && state.status === 'ready' ? state.sessions : []; +} + +/** Consume an existing deleted-session push without weakening the full-query scope. */ +export function removeUsageSessionForScope( + state: UsageSessionsState, + scopeKey: string | null, + sessionId: string, +): UsageSessionsState { + if (state.scopeKey !== scopeKey || state.status !== 'ready') return state; + return { + ...state, + sessions: state.sessions.filter((session) => session.id !== sessionId), + }; +} + +/** + * Merge live sidebar metadata into the full usage-history row without letting + * the sidebar's potentially stale token snapshot replace the database total. + */ +export function mergeUsageSessionSnapshots( + fullSession: UsageHistorySession, + liveSession: Session, +): UsageHistorySession { + return { + ...fullSession, + title: liveSession.title, + model: liveSession.model, + providerId: liveSession.providerId, + contextTokens: liveSession.contextTokens, + contextWindow: liveSession.contextWindow, + userSendAt: liveSession.userSendAt, + updatedAt: liveSession.updatedAt, + totalTokenUsage: fullSession.totalTokenUsage, + }; } /** * 候选行 —— 单独暴露成 hook, 让调用方能在**渲染卡片之前**知道有没有行。 * 组件内部返回 null 会留下一张只有标题、正文全空的卡片 (会话被删光 / 列表首次加载中)。 */ -export function useTopTokenSessions(): Session[] { +export function useTopTokenSessions( + range: UsageHistoryRange = '30d', + todayKeyOverride?: string, + accountScopeKey?: string, +): UsageHistorySession[] { // 归档的任务同样消耗过 token, 统计口径不该因为用户归档而变。 - const { sessions } = useCCSessions({ includeArchived: 'all' }); + const { sessions: recentSessions } = useCCSessions({ includeArchived: 'all' }); + const scopeKey = accountScopeKey ?? null; + const deletedSessionIdsRef = useRef>(new Set()); + const [usageSessions, setUsageSessions] = useState(() => ({ + scopeKey, + status: 'loading', + })); + + useEffect(() => { + let cancelled = false; + deletedSessionIdsRef.current = new Set(); + setUsageSessions({ scopeKey, status: 'loading' }); + const sessionsPush = window.electronAPI?.localDb?.sessionsPush; + const unsubscribe = sessionsPush?.onPatched(({ sessionId, patch }, ownerStamp) => { + if (!isDataOwnerPushCurrent(ownerStamp) || patch.status !== 'deleted') return; + deletedSessionIdsRef.current.add(sessionId); + setUsageSessions((state) => removeUsageSessionForScope(state, scopeKey, sessionId)); + }); + // The task table intentionally uses the full-query snapshot as its + // lifecycle-total source. It does not maintain a second partial live-update + // path: the usage charts own dated live refresh, while this aggregate is + // refreshed when its account scope changes or the page remounts. + void sessionService + .list(20, 'all', { usageHistory: true }) + .then((rows) => { + if (cancelled) return; + const deletedSessionIds = deletedSessionIdsRef.current; + setUsageSessions({ + scopeKey, + status: 'ready', + sessions: rows.filter((session) => !deletedSessionIds.has(session.id)), + }); + }) + .catch(() => { + if (!cancelled) setUsageSessions({ scopeKey, status: 'error' }); + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [scopeKey]); + + const sessions = useMemo(() => { + // The sidebar snapshot is capped at 1000 rows and cannot stand in for the + // full usage-history query. Hide the aggregate until that query succeeds; + // showing the truncated or failed fallback would mislabel the ranking. + if (usageSessions.scopeKey !== scopeKey || usageSessions.status !== 'ready') return []; + const scopedSessions = usageSessionsForScope(usageSessions, scopeKey); + const byId = new Map(scopedSessions.map((session) => [session.id, session])); + // 侧栏快照只补充实时元数据;其 totalTokenUsage 可能滞后,保留全量查询的累计值。 + for (const session of recentSessions) { + if (deletedSessionIdsRef.current.has(session.id)) continue; + const usageSession = byId.get(session.id); + byId.set( + session.id, + usageSession ? mergeUsageSessionSnapshots(usageSession, session) : session, + ); + } + return [...byId.values()]; + }, [recentSessions, scopeKey, usageSessions]); return useMemo(() => { - const cutoff = Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000; + // Sessions retain only their latest activity timestamp, so they cannot + // prove historical membership for an exact calendar day. Hide this + // aggregate rather than presenting a task as active on a day it may not + // have used. The day-level charts and agent/model tables use dated usage + // records and remain exact. + if (shouldHideUsageTaskTable(range)) return []; + const now = new Date(); + const todayKey = + todayKeyOverride ?? + `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return sessions .filter( (session) => session.totalTokenUsage > 0 && - (activeWithinWindow(session.updatedAt, cutoff) || - activeWithinWindow(session.userSendAt, cutoff)), + activeWithinRange(usageActivityIso(session), range, todayKey), ) .sort((a, b) => b.totalTokenUsage - a.totalTokenUsage) .slice(0, TOP_TASKS); - }, [sessions]); + }, [range, sessions, todayKeyOverride]); } -export function UsageTaskTable({ rows }: { rows: Session[] }): React.JSX.Element { +export function UsageTaskTable({ + rows, + rangeLabel, + }: { + rows: UsageHistorySession[]; + rangeLabel: string; +}): React.JSX.Element { const { t } = useTranslation(); const { providers } = useProviders(); @@ -115,7 +272,10 @@ export function UsageTaskTable({ rows }: { rows: Session[] }): React.JSX.Element {t('usageHistory.tasks.col.provider')} - + {t('usageHistory.tasks.col.tokens')} {t('usageHistory.tasks.col.context')} diff --git a/apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx b/apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx index 9fb3d0d6815..a2888426e34 100644 --- a/apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx +++ b/apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx @@ -45,6 +45,11 @@ function shiftDayKeyLocal(dayKey: string, deltaDays: number): string { return `${date.getFullYear()}-${mm}-${dd}`; } +function parseDayKeyLocal(dayKey: string): Date { + const [year, month, day] = dayKey.split('-').map(Number); + return new Date(year, (month ?? 1) - 1, day ?? 1); +} + /** 与 UsageDailyBars 同一套刻度算法, 保证两张图的刻度密度一致。 */ function niceTicks(max: number): number[] { if (!(max > 0)) return []; @@ -60,13 +65,26 @@ export function UsageTokenBars({ modelDaily, colorOrder, todayKey, + selectedDay, + onDayClick, }: { modelDaily: UsageHistoryModelDay[]; /** 前 N 名模型 key (payload.models 排序), 决定分段与图例配色。 */ colorOrder: string[]; todayKey: string; + selectedDay?: string | null; + onDayClick?: (day: string) => void; }): React.JSX.Element { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + }), + [i18n.language], + ); const bars = useMemo(() => { const segsByDay = new Map>(); @@ -133,46 +151,69 @@ export function UsageTokenBars({ style={{ bottom: (v / bars.max) * CHART_HEIGHT_PX }} /> ))} -
- {bars.list.map((b) => { - const ratio = bars.max > 0 ? b.tokens / bars.max : 0; - const height = b.tokens > 0 ? Math.max(3, Math.round(ratio * CHART_HEIGHT_PX)) : 2; - const titleLines = [ - `${b.day} · ${ +
+
+ {bars.list.map((b) => { + const ratio = bars.max > 0 ? b.tokens / bars.max : 0; + const visualHeight = + b.tokens > 0 ? Math.max(3, Math.round(ratio * CHART_HEIGHT_PX)) : 2; + const hitHeight = Math.max(24, visualHeight); + const usageSummary = b.tokens > 0 ? t('usageDashboard.tokensOnly', { tokens: formatCompactTokens(b.tokens) }) - : t('usageHistory.heatmap.emptyCell') - }`, - ...b.segments.map( - (s) => - `${s.label}: ${t('usageDashboard.tokensOnly', { - tokens: formatCompactTokens(s.tokens), - })}`, - ), - ]; - return ( -
- {[...b.segments].reverse().map((s) => ( -
+ `${s.label}: ${t('usageDashboard.tokensOnly', { + tokens: formatCompactTokens(s.tokens), + })}`, + ), + ]; + return ( +
- ); - })} + > + {[...b.segments].reverse().map((s) => ( + + ))} + + + ); + })} +
diff --git a/apps/desktop/src/renderer/components/settings/usage/__tests__/usageHistoryStats.test.ts b/apps/desktop/src/renderer/components/settings/usage/__tests__/usageHistoryStats.test.ts index bb136dd7d5e..57a36f0a0b8 100644 --- a/apps/desktop/src/renderer/components/settings/usage/__tests__/usageHistoryStats.test.ts +++ b/apps/desktop/src/renderer/components/settings/usage/__tests__/usageHistoryStats.test.ts @@ -1,15 +1,28 @@ import { describe, expect, it } from 'vitest'; +import type { Session } from '@/lib/ccAgent.types'; import type { UsageHistoryPayload } from '@/hooks/useUsageHistory'; import { buildAgentRows, buildModelRows, buildSummary, cacheHitRate, + chartUsageHistoryPayload, computeTokenStreak, + filterUsageHistoryPayload, + isUsageHistorySingleDay, isUsageHistoryEmpty, + usageRangeDay, toUsageDays, } from '../usageHistoryStats'; +import { + removeUsageSessionForScope, + mergeUsageSessionSnapshots, + shouldHideUsageTaskTable, + usageActivityIso, + usageSessionsForScope, + type UsageSessionsState, +} from '../UsageTaskTable'; const zeroMoney = { amount: 0, @@ -53,6 +66,72 @@ function payload(over: Partial = {}): UsageHistoryPayload { }; } +describe('shouldHideUsageTaskTable', () => { + it('单日范围(包括 today)隐藏无法精确归因的任务表', () => { + expect(shouldHideUsageTaskTable('today')).toBe(true); + expect(shouldHideUsageTaskTable('day:2026-08-20')).toBe(true); + expect(shouldHideUsageTaskTable('7d')).toBe(false); + }); +}); + +describe('isUsageHistorySingleDay', () => { + it('识别 today 与日期钻取范围', () => { + expect(isUsageHistorySingleDay('today')).toBe(true); + expect(isUsageHistorySingleDay('day:2026-08-20')).toBe(true); + expect(isUsageHistorySingleDay('7d')).toBe(false); + }); +}); + +describe('usageRangeDay', () => { + it('Today 使用 payload 的日期锚点,和图表点击当天保持同一 selectedDay', () => { + expect(usageRangeDay('today', '2026-08-22')).toBe('2026-08-22'); + expect(usageRangeDay('today')).toBeNull(); + expect(usageRangeDay('day:2026-08-20', '2026-08-22')).toBe('2026-08-20'); + }); +}); + +describe('usage task session scope', () => { + const readyState = (scopeKey: string): UsageSessionsState => ({ + scopeKey, + status: 'ready', + sessions: [{ id: 'session-a' } as Session], + }); + + it('账号切换的过渡帧不暴露旧账号的全量快照', () => { + expect(usageSessionsForScope(readyState('owner-a'), 'owner-b')).toEqual([]); + }); + + it('只从同一账号的快照消费 deleted 事件', () => { + const state = readyState('owner-a'); + expect(removeUsageSessionForScope(state, 'owner-a', 'session-a')).toMatchObject({ + scopeKey: 'owner-a', + status: 'ready', + sessions: [], + }); + expect(removeUsageSessionForScope(state, 'owner-b', 'session-a')).toBe(state); + }); + + it('合并实时元数据时保留全量查询的累计 token', () => { + const fullSession = { + id: 'session-a', + title: '历史标题', + totalTokenUsage: 1000, + } as Session; + const liveSession = { + id: 'session-a', + title: '最新标题', + totalTokenUsage: 100, + } as Session; + + expect(mergeUsageSessionSnapshots(fullSession, liveSession)).toMatchObject({ + id: 'session-a', + title: '最新标题', + totalTokenUsage: 1000, + }); + }); + +}); + describe('cacheHitRate', () => { it('输出 token 不进分母 (与逐轮卡片同一公式)', () => { expect( @@ -61,9 +140,7 @@ describe('cacheHitRate', () => { }); it('分母为 0 时返回 null 而不是 0 —— 没有上下文可复用与命中率为零是两回事', () => { - expect( - cacheHitRate({ inputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 }), - ).toBeNull(); + expect(cacheHitRate({ inputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 })).toBeNull(); }); }); @@ -71,10 +148,7 @@ describe('computeTokenStreak', () => { const days = (...keys: string[]) => keys.map((day) => ({ day, tokens: 1000 })); it('今日有记录时从今日起算', () => { - const streak = computeTokenStreak( - days('2026-08-20', '2026-08-21', '2026-08-22'), - '2026-08-22', - ); + const streak = computeTokenStreak(days('2026-08-20', '2026-08-21', '2026-08-22'), '2026-08-22'); expect(streak.current).toBe(3); }); @@ -93,10 +167,7 @@ describe('computeTokenStreak', () => { }); it('跨月连续不断档', () => { - const streak = computeTokenStreak( - days('2026-07-30', '2026-07-31', '2026-08-01'), - '2026-08-01', - ); + const streak = computeTokenStreak(days('2026-07-30', '2026-07-31', '2026-08-01'), '2026-08-01'); expect(streak).toEqual({ current: 3, longest: 3 }); }); @@ -119,9 +190,7 @@ describe('toUsageDays', () => { }); it('旧快照缺 tokens 字段时按 0 兜底, 不会 NaN', () => { - const rows = toUsageDays( - payload({ days: [{ day: '2026-08-22', money: zeroMoney }] }), - ); + const rows = toUsageDays(payload({ days: [{ day: '2026-08-22', money: zeroMoney }] })); expect(rows).toEqual([]); }); }); @@ -267,8 +336,85 @@ describe('buildSummary / isUsageHistoryEmpty', () => { }); it('有 token 记录时不算空', () => { + expect(isUsageHistoryEmpty(payload({ models: [model({ inputTokens: 10 })] }))).toBe(false); + }); +}); + +describe('filterUsageHistoryPayload', () => { + const daily = (over: Partial) => ({ + day: '2026-08-22', + agentKind: 'codex' as const, + model: 'gpt-5.5', + money: zeroMoney, + apiMoney: zeroMoney, + subscriptionEstimateMoney: zeroMoney, + tokens: 0, + ...over, + }); + + const source = payload({ + days: [ + { day: '2026-08-10', money: zeroMoney, tokens: 300 }, + { day: '2026-08-20', money: zeroMoney, tokens: 60 }, + { day: '2026-08-22', money: zeroMoney, tokens: 120 }, + ], + modelDaily: [ + daily({ + day: '2026-08-10', + agentKind: 'claude-code', + model: 'opus', + tokens: 300, + inputTokens: 300, + }), + daily({ day: '2026-08-20', tokens: 60, inputTokens: 50, outputTokens: 10 }), + daily({ day: '2026-08-22', tokens: 120, inputTokens: 100, outputTokens: 20 }), + ], + }); + + it('按选择的窗口过滤并重新聚合模型与统计值', () => { + const result = filterUsageHistoryPayload(source, '7d'); + expect(result?.days.map((row) => row.day)).toEqual(['2026-08-20', '2026-08-22']); + expect(result?.models).toHaveLength(1); + expect(result?.models[0]).toMatchObject({ + agentKind: 'codex', + model: 'gpt-5.5', + inputTokens: 150, + outputTokens: 30, + }); + expect(result?.totals).toMatchObject({ todayTokens: 120, last30DaysTokens: 180 }); + }); + + it('选中热力图中的历史日时只保留该日并把它作为统计锚点', () => { + const result = filterUsageHistoryPayload(source, 'day:2026-08-20'); + expect(result?.todayKey).toBe('2026-08-20'); + expect(result?.days.map((row) => row.day)).toEqual(['2026-08-20']); + expect(result?.modelDaily.map((row) => row.day)).toEqual(['2026-08-20']); + expect(result?.totals.todayTokens).toBe(60); + expect(result?.totals.last30DaysTokens).toBe(60); + expect(result?.streak).toEqual({ current: 1, longest: 1 }); + }); + + it('图表保留固定窗口,不随表格筛选范围变化', () => { + const result = chartUsageHistoryPayload(source); + expect(result?.modelDaily.map((row) => row.day)).toEqual([ + '2026-08-10', + '2026-08-20', + '2026-08-22', + ]); + expect(result?.days.map((row) => row.day)).toEqual(source.days.map((row) => row.day)); + }); +}); + +describe('usageActivityIso', () => { + it('忽略元数据更新时间,优先使用用户发送时间;存量行才回退 updatedAt', () => { expect( - isUsageHistoryEmpty(payload({ models: [model({ inputTokens: 10 })] })), - ).toBe(false); + usageActivityIso({ + userSendAt: '2026-08-20T10:00:00.000Z', + updatedAt: '2026-08-22T10:00:00.000Z', + }), + ).toBe('2026-08-20T10:00:00.000Z'); + expect(usageActivityIso({ userSendAt: null, updatedAt: '2026-08-22T10:00:00.000Z' })).toBe( + '2026-08-22T10:00:00.000Z', + ); }); }); diff --git a/apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts b/apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts index 5e4534b7d5e..b050b685cf3 100644 --- a/apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts +++ b/apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts @@ -10,10 +10,7 @@ * - `models[]` / `modelDaily` 是 30 天 → 按模型、按 agent、每日柱图 */ -import type { - UsageHistoryModel, - UsageHistoryPayload, -} from '@/hooks/useUsageHistory'; +import type { UsageHistoryModel, UsageHistoryPayload } from '@/hooks/useUsageHistory'; export type UsageAgentKind = UsageHistoryModel['agentKind']; @@ -22,6 +19,8 @@ export interface UsageDay { tokens: number; } +export type UsageHistoryRange = 'all' | '30d' | '7d' | 'today' | `day:${string}`; + export interface ModelTokenRow { key: string; agentKind: UsageAgentKind; @@ -78,6 +77,123 @@ function shiftDayKey(dayKey: string, deltaDays: number): string { return `${date.getFullYear()}-${mm}-${dd}`; } +export function usageRangeDay(range: UsageHistoryRange, todayKey?: string): string | null { + if (range === 'today') return todayKey ?? null; + return range.startsWith('day:') ? range.slice(4) : null; +} + +/** Exact-day ranges cannot provide a distinct "today" comparison column. */ +export function isUsageHistorySingleDay(range: UsageHistoryRange): boolean { + return range === 'today' || usageRangeDay(range) !== null; +} + +export function isUsageDayInRange( + day: string, + todayKey: string, + range: UsageHistoryRange, +): boolean { + const selectedDay = usageRangeDay(range); + if (selectedDay) return day === selectedDay; + if (range === 'all') return true; + if (range === 'today') return day === todayKey; + const days = range === '7d' ? 7 : 30; + return day >= shiftDayKey(todayKey, -(days - 1)) && day <= todayKey; +} + +/** + * Derive the table/statistics payload for a selected range. Charts intentionally + * receive their own fixed-window slices so changing this range never changes + * the heatmap or daily-token chart. + */ +export function filterUsageHistoryPayload( + payload: UsageHistoryPayload | null, + range: UsageHistoryRange, +): UsageHistoryPayload | null { + if (!payload) return null; + + const selectedDay = usageRangeDay(range, payload.todayKey); + const rangeTodayKey = selectedDay ?? payload.todayKey; + const modelByKey = new Map(); + for (const row of payload.modelDaily) { + if (!isUsageDayInRange(row.day, payload.todayKey, range) || row.tokens <= 0) continue; + const key = `${row.agentKind}\u0000${row.model}`; + const existing = modelByKey.get(key); + const componentTotal = + (row.inputTokens ?? 0) + + (row.outputTokens ?? 0) + + (row.cacheReadTokens ?? 0) + + (row.cacheCreateTokens ?? 0); + // Old renderer snapshots only carried `tokens`; preserve their totals until + // the next main-process refresh supplies the component fields. + const inputTokens = row.inputTokens ?? (componentTotal === 0 ? row.tokens : 0); + const outputTokens = row.outputTokens ?? 0; + const cacheReadTokens = row.cacheReadTokens ?? 0; + const cacheCreateTokens = row.cacheCreateTokens ?? 0; + if (existing) { + existing.inputTokens += inputTokens; + existing.outputTokens += outputTokens; + existing.cacheReadTokens += cacheReadTokens; + existing.cacheCreateTokens += cacheCreateTokens; + continue; + } + modelByKey.set(key, { + agentKind: row.agentKind, + model: row.model, + money: row.money, + estimatedMoney: + row.subscriptionEstimateMoney.amount > 0 ? row.subscriptionEstimateMoney : null, + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreateTokens, + }); + } + + const modelDaily = payload.modelDaily.filter((row) => + isUsageDayInRange(row.day, payload.todayKey, range), + ); + const days = payload.days.filter((row) => isUsageDayInRange(row.day, payload.todayKey, range)); + const models = [...modelByKey.values()]; + const lastRangeTokens = models.reduce((sum, model) => sum + modelTokenTotal(model), 0); + const todayTokens = modelDaily + .filter((row) => row.day === rangeTodayKey) + .reduce((sum, row) => sum + row.tokens, 0); + + return { + ...payload, + todayKey: rangeTodayKey, + days, + modelDaily, + models, + totals: { + ...payload.totals, + todayTokens, + last30DaysTokens: lastRangeTokens, + }, + streak: computeTokenStreak( + days + .map((row) => ({ day: row.day, tokens: row.tokens ?? 0 })) + .filter((row) => row.tokens > 0), + rangeTodayKey, + ), + }; +} + +export function chartUsageHistoryPayload( + payload: UsageHistoryPayload | null, +): UsageHistoryPayload | null { + if (!payload) return null; + const charts = filterUsageHistoryPayload(payload, '30d'); + return { + ...(charts ?? payload), + // The heatmap decides its own visible window from the measured card width. + // Keep the full `days` history here so a wide settings card can expose more + // than the old fixed 20-week slice; the daily bar chart still uses modelDaily + // from the fixed 30-day chart payload above. + days: payload.days, + }; +} + /** payload.days → 只保留 token 维度 (热力图与 streak 的共同输入)。 */ export function toUsageDays(payload: UsageHistoryPayload | null): UsageDay[] { if (!payload) return []; diff --git a/apps/desktop/src/renderer/hooks/useUsageHistory.ts b/apps/desktop/src/renderer/hooks/useUsageHistory.ts index de6bfbcefcd..85abf9953d6 100644 --- a/apps/desktop/src/renderer/hooks/useUsageHistory.ts +++ b/apps/desktop/src/renderer/hooks/useUsageHistory.ts @@ -50,6 +50,10 @@ export interface UsageHistoryModelDay { apiMoney: RegionalMoney; subscriptionEstimateMoney: RegionalMoney; tokens: number; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreateTokens?: number; } export interface UsageHistoryPayload { @@ -59,7 +63,7 @@ export interface UsageHistoryPayload { estimatesPending?: boolean; /** tokens: 当日合计 (旧快照可能缺该字段, 消费端用 ?? 0 兜底)。 */ days: Array<{ day: string; money: RegionalMoney; tokens?: number }>; - /** 近 30 天每日 × 模型明细 (旧快照缺该字段时补 [])。 */ + /** 按请求的 modelDays 窗口返回每日 × 模型明细 (旧快照缺该字段时补 [])。 */ modelDaily: UsageHistoryModelDay[]; models: UsageHistoryModel[]; streak: { current: number; longest: number }; @@ -76,6 +80,7 @@ export interface UsageHistoryPayload { /** 热力图窗口: 20 周 = 140 天。 */ const HISTORY_WINDOW_DAYS = 140; +const MODEL_WINDOW_DAYS = 30; const REFRESH_DEBOUNCE_MS = 2000; /** 价格表冷启动未就绪时的补拉延迟 (对齐 main 侧 5s fetch 超时 + 余量)。 */ const PRICING_RETRY_DELAY_MS = 6000; @@ -109,6 +114,10 @@ interface StoredUsageHistoryModelDay { subscriptionEstimateMoney?: unknown; subscriptionEstimateUsd?: unknown; tokens?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; + cacheReadTokens?: unknown; + cacheCreateTokens?: unknown; } interface StoredUsageHistoryModel { @@ -209,6 +218,18 @@ function readSnapshot(scopeKey: string): UsageHistoryPayload | null { normalizeRegionalMoney(row.subscriptionEstimateMoney) ?? legacyEstimate(row.subscriptionEstimateUsd), tokens: finiteNumber(row.tokens), + ...(typeof row.inputTokens === 'number' && Number.isFinite(row.inputTokens) + ? { inputTokens: row.inputTokens } + : {}), + ...(typeof row.outputTokens === 'number' && Number.isFinite(row.outputTokens) + ? { outputTokens: row.outputTokens } + : {}), + ...(typeof row.cacheReadTokens === 'number' && Number.isFinite(row.cacheReadTokens) + ? { cacheReadTokens: row.cacheReadTokens } + : {}), + ...(typeof row.cacheCreateTokens === 'number' && Number.isFinite(row.cacheCreateTokens) + ? { cacheCreateTokens: row.cacheCreateTokens } + : {}), })); const models = (parsed.models as StoredUsageHistoryModel[]) .filter( @@ -303,6 +324,11 @@ interface UsageHistoryScopeState { listeners: Set<(p: UsageHistoryPayload | null) => void>; statusListeners: Set<(refreshing: boolean) => void>; refreshing: boolean; + request: { + days: number | 'all'; + modelDays: number | 'all'; + allowPendingEstimates: boolean; + }; } const scopes = new Map(); @@ -311,7 +337,18 @@ function normalizeScopeKey(userId?: string | null): string { return userId || DEFAULT_SCOPE_KEY; } -function getScope(scopeKey: string): UsageHistoryScopeState { +function getScope( + scopeKey: string, + request: { + days: number | 'all'; + modelDays: number | 'all'; + allowPendingEstimates: boolean; + } = { + days: HISTORY_WINDOW_DAYS, + modelDays: MODEL_WINDOW_DAYS, + allowPendingEstimates: false, + }, +): UsageHistoryScopeState { let state = scopes.get(scopeKey); if (!state) { const snapshot = readSnapshot(scopeKey); @@ -326,6 +363,7 @@ function getScope(scopeKey: string): UsageHistoryScopeState { listeners: new Set<(p: UsageHistoryPayload | null) => void>(), statusListeners: new Set<(refreshing: boolean) => void>(), refreshing: false, + request, }; scopes.set(scopeKey, state); } @@ -365,7 +403,11 @@ async function load(scopeKey: string, opts?: { forceRefresh?: boolean; resetPric const seq = ++scope.loadSeq; setRefreshing(scope, true); scope.inflight = window.electronAPI.maker.usage - .getHistory({ days: HISTORY_WINDOW_DAYS, ...(opts?.forceRefresh ? { forceRefresh: true } : {}) }) + .getHistory({ + days: scope.request.days, + modelDays: scope.request.modelDays, + ...(opts?.forceRefresh ? { forceRefresh: true } : {}), + }) .then((res) => { if (seq === scope.loadSeq) scope.inflight = null; if (!res || typeof res !== 'object') return null; @@ -398,9 +440,14 @@ async function load(scopeKey: string, opts?: { forceRefresh?: boolean; resetPric void load(scopeKey, { forceRefresh: true }); }, PRICING_RETRY_DELAY_MS); } - return scope.cache; + // Amount-bearing consumers wait for pricing so they never present a + // misleading value. Token-only consumers can render this payload now; + // the delayed force-refresh above still replaces it once estimates are + // available. + if (!scope.request.allowPendingEstimates) return scope.cache; + } else { + scope.pricingRetryDone = false; } - scope.pricingRetryDone = false; // dirty-check: 数据未变 (仅 generatedAt 不同) → 不换 cache 引用、不写快照、 // 不通知监听 — 等价数据不触发重渲染与 JSON 落盘 const nextSerialized = serializeForCompare(nextForCache); @@ -422,23 +469,64 @@ async function load(scopeKey: string, opts?: { forceRefresh?: boolean; resetPric return scope.inflight; } -export function useUsageHistory(opts?: { paused?: boolean; userId?: string | null }): { +export type UsageHistoryWindow = number | 'all'; + +function normalizeWindow(value: UsageHistoryWindow | undefined, fallback: number): number | 'all' { + if (value === 'all') return 'all'; + return Math.min(366, Math.max(1, Math.floor(value ?? fallback))); +} + +function scopeKeyForRequest( + scopeKey: string, + request: { + days: number | 'all'; + modelDays: number | 'all'; + allowPendingEstimates: boolean; + }, +): string { + if ( + request.days === HISTORY_WINDOW_DAYS && + request.modelDays === MODEL_WINDOW_DAYS && + !request.allowPendingEstimates + ) { + return scopeKey; + } + return `${scopeKey}|days=${request.days}|modelDays=${request.modelDays}|allowPendingEstimates=${request.allowPendingEstimates ? 1 : 0}`; +} + +export function useUsageHistory(opts?: { + paused?: boolean; + userId?: string | null; + days?: UsageHistoryWindow; + modelDays?: UsageHistoryWindow; + /** Token-only consumers may render the payload while prices finish loading. */ + allowPendingEstimates?: boolean; +}): { history: UsageHistoryPayload | null; refreshing: boolean; } { const paused = opts?.paused ?? false; const scopeKey = normalizeScopeKey(opts?.userId); - const scope = getScope(scopeKey); + const request = { + days: normalizeWindow(opts?.days, HISTORY_WINDOW_DAYS), + modelDays: normalizeWindow(opts?.modelDays, MODEL_WINDOW_DAYS), + allowPendingEstimates: opts?.allowPendingEstimates ?? false, + }; + const scopedKey = scopeKeyForRequest(scopeKey, request); + const scope = getScope(scopedKey, request); const [historyState, setHistoryState] = useState<{ scopeKey: string; value: UsageHistoryPayload | null }>(() => ({ - scopeKey, + scopeKey: scopedKey, value: scope.cache, })); - const [isRefreshing, setIsRefreshing] = useState(scope.refreshing); + // A null payload is pending until the first request settles. Keeping that + // invariant in the hook lets consumers distinguish initial loading from a + // settled load failure without adding a second lifecycle state owner. + const [isRefreshing, setIsRefreshing] = useState(scope.cache === null || scope.refreshing); useEffect(() => { - const activeScope = getScope(scopeKey); + const activeScope = getScope(scopedKey, request); const setScopedHistory = (value: UsageHistoryPayload | null) => { - setHistoryState({ scopeKey, value }); + setHistoryState({ scopeKey: scopedKey, value }); }; activeScope.listeners.add(setScopedHistory); activeScope.statusListeners.add(setIsRefreshing); @@ -446,10 +534,10 @@ export function useUsageHistory(opts?: { paused?: boolean; userId?: string | nul // load() 在 render 与本 effect 之间更新了 cache, 且后续 load 都命中 dirty-check // (不再广播), 本实例会停在旧值。引用相同时 React 自动 bail out, 无重渲染成本。 setScopedHistory(activeScope.cache); - setIsRefreshing(activeScope.refreshing); // mount 与 paused→false (展开瞬间) 都拉一次 — 折叠摘要 / 图表数据都保持新鲜。 // 收起时跳过后续 push 订阅, 避免每个 turn 触发后台重拉。 - void load(scopeKey); + void load(scopedKey); + setIsRefreshing(activeScope.refreshing); // paused (卡片收起): 不订阅消费推送, 收起用户的 per-turn 重拉成本归零 if (paused) { @@ -466,7 +554,7 @@ export function useUsageHistory(opts?: { paused?: boolean; userId?: string | nul if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; - void load(scopeKey, { forceRefresh: true, resetPricingRetry: true }); + void load(scopedKey, { forceRefresh: true, resetPricingRetry: true }); }, REFRESH_DEBOUNCE_MS); }; const offSpend = window.electronAPI.maker.usage.onTodaySpendChanged(scheduleRefresh); @@ -482,10 +570,10 @@ export function useUsageHistory(opts?: { paused?: boolean; userId?: string | nul offTokens(); offPricing(); }; - }, [paused, scopeKey]); + }, [paused, scopedKey]); - return { - history: historyState.scopeKey === scopeKey ? historyState.value : scope.cache, - refreshing: isRefreshing, - }; + const history = historyState.scopeKey === scopedKey ? historyState.value : scope.cache; + const refreshing = + historyState.scopeKey === scopedKey ? isRefreshing : scope.cache === null || scope.refreshing; + return { history, refreshing }; } diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 035db0d019a..90d96785363 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -5795,20 +5795,32 @@ "usageHistory": { "description": "Token usage produced inside Cindy on this device. Records start accumulating when the feature ships; earlier history is not backfilled.", "empty": "No usage recorded yet. This fills in once you start a chat.", + "loadFailed": "Unable to load usage history. Reopen Settings to try again.", + "range": { + "label": "Time range", + "ariaLabel": "Filter usage time range", + "all": "All time", + "30d": "Last 30 days", + "7d": "Last 7 days", + "today": "Today" + }, "cacheHitTooltip": "Cache read / (input + cache read + cache write) — the same formula as the message card.", "summary": { "title": "Overview" }, "stats": { "todayTokens": "Tokens today", + "todayTokensInRange": "Tokens ({{range}})", "totalTokens": "Tokens (30d)", + "totalTokensInRange": "Tokens ({{range}})", "streak": "Active streak", "cacheHitRate": "Cache hit rate (30d)", + "cacheHitRateInRange": "Cache hit rate ({{range}})", "models": "Models used" }, "heatmap": { "title": "Activity heatmap", - "subtitle": "Last 20 weeks", + "subtitle": "Last {{count}} weeks", "emptyCell": "No usage recorded" }, "daily": { @@ -5821,6 +5833,7 @@ "col": { "agent": "Agent / harness", "total": "Tokens (30d)", + "totalInRange": "Tokens ({{range}})", "share": "Share", "today": "Today", "hitRate": "Cache hit rate", @@ -5833,6 +5846,7 @@ "col": { "model": "Model", "total": "Total tokens", + "totalInRange": "Tokens ({{range}})", "share": "Share", "input": "Input", "output": "Output", @@ -5843,7 +5857,7 @@ }, "tasks": { "title": "Top sessions by tokens", - "subtitle": "Sessions active in the last 30 days, ranked by lifetime tokens", + "subtitle": "Sessions active in {{range}}, ranked by lifetime tokens", "col": { "task": "Session", "model": "Model", @@ -5853,7 +5867,7 @@ "lastActive": "Last active" }, "providerTooltip": "The provider currently selected for the session; blank when none is pinned.", - "tokensTooltip": "The session's lifetime total, not the tokens spent within the last 30 days." + "tokensTooltip": "The session's lifetime total; {{range}} only filters active sessions and is not an incremental total." } }, "usageDashboard": { diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 12ff28c9a7b..d410a9c8dbb 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -5793,20 +5793,32 @@ "usageHistory": { "description": "このデバイスの Cindy 内で発生したトークン使用量です。記録は機能の提供開始時から蓄積され、それ以前の履歴は補完されません。", "empty": "まだ使用記録がありません。会話を実行すると蓄積が始まります。", + "loadFailed": "使用履歴を読み込めませんでした。設定を開き直して、もう一度お試しください。", + "range": { + "label": "期間", + "ariaLabel": "使用量の期間を絞り込む", + "all": "全期間", + "30d": "直近 30 日", + "7d": "直近 7 日", + "today": "今日" + }, "cacheHitTooltip": "キャッシュ読取 ÷(入力 + キャッシュ読取 + キャッシュ書込)。メッセージカードと同じ基準です。", "summary": { "title": "概要" }, "stats": { "todayTokens": "今日のトークン", + "todayTokensInRange": "{{range}}のトークン", "totalTokens": "直近 30 日のトークン", + "totalTokensInRange": "{{range}}のトークン", "streak": "連続稼働日数", "cacheHitRate": "キャッシュヒット率(30 日)", + "cacheHitRateInRange": "キャッシュヒット率({{range}})", "models": "使用モデル数" }, "heatmap": { "title": "アクティビティヒートマップ", - "subtitle": "直近 20 週", + "subtitle": "直近 {{count}} 週", "emptyCell": "この日は使用記録なし" }, "daily": { @@ -5819,6 +5831,7 @@ "col": { "agent": "Agent / harness", "total": "直近 30 日のトークン", + "totalInRange": "{{range}}のトークン", "share": "割合", "today": "今日", "hitRate": "キャッシュヒット率", @@ -5831,6 +5844,7 @@ "col": { "model": "モデル", "total": "合計トークン", + "totalInRange": "{{range}}のトークン", "share": "割合", "input": "入力", "output": "出力", @@ -5841,7 +5855,7 @@ }, "tasks": { "title": "トークン消費の多いセッション", - "subtitle": "直近 30 日に稼働したセッションを累計トークン順に表示", + "subtitle": "{{range}}に稼働したセッションを累計トークン順に表示", "col": { "task": "セッション", "model": "モデル", @@ -5851,7 +5865,7 @@ "lastActive": "最終アクティブ" }, "providerTooltip": "セッションで現在選択されているプロバイダー。未指定の場合は空欄です。", - "tokensTooltip": "セッションの累計値であり、直近 30 日の増分ではありません。" + "tokensTooltip": "セッションの累計値です。{{range}}はアクティブなセッションの絞り込みにのみ使われ、期間内の増分ではありません。" } }, "usageDashboard": { diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index e60d8a5a08c..af2be802968 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -5793,20 +5793,32 @@ "usageHistory": { "description": "이 기기의 Cindy 내부에서 발생한 토큰 사용량입니다. 기능 출시 시점부터 기록이 쌓이며 이전 기록은 채워지지 않습니다.", "empty": "아직 사용 기록이 없습니다. 대화를 실행하면 쌓이기 시작합니다.", + "loadFailed": "사용량 기록을 불러오지 못했습니다. 설정을 다시 열어 재시도해 주세요.", + "range": { + "label": "기간", + "ariaLabel": "사용량 기간 필터", + "all": "전체 기간", + "30d": "최근 30일", + "7d": "최근 7일", + "today": "오늘" + }, "cacheHitTooltip": "캐시 읽기 ÷ (입력 + 캐시 읽기 + 캐시 쓰기) — 메시지 카드와 동일한 기준입니다.", "summary": { "title": "개요" }, "stats": { "todayTokens": "오늘 토큰", + "todayTokensInRange": "{{range}} 토큰", "totalTokens": "최근 30일 토큰", + "totalTokensInRange": "{{range}} 토큰", "streak": "연속 활동일", "cacheHitRate": "캐시 적중률(30일)", + "cacheHitRateInRange": "캐시 적중률({{range}})", "models": "사용한 모델" }, "heatmap": { "title": "활동 히트맵", - "subtitle": "최근 20주", + "subtitle": "최근 {{count}}주", "emptyCell": "해당 날짜에 사용 기록 없음" }, "daily": { @@ -5819,6 +5831,7 @@ "col": { "agent": "Agent / harness", "total": "최근 30일 토큰", + "totalInRange": "{{range}} 토큰", "share": "비중", "today": "오늘", "hitRate": "캐시 적중률", @@ -5831,6 +5844,7 @@ "col": { "model": "모델", "total": "총 토큰", + "totalInRange": "{{range}} 토큰", "share": "비중", "input": "입력", "output": "출력", @@ -5841,7 +5855,7 @@ }, "tasks": { "title": "토큰 사용이 많은 세션", - "subtitle": "최근 30일 동안 활동한 세션을 누적 토큰 순으로 정렬", + "subtitle": "{{range}} 동안 활동한 세션을 누적 토큰 순으로 정렬", "col": { "task": "세션", "model": "모델", @@ -5851,7 +5865,7 @@ "lastActive": "마지막 활동" }, "providerTooltip": "세션에 현재 선택된 제공자입니다. 지정하지 않은 경우 비워 둡니다.", - "tokensTooltip": "해당 세션의 누적 값이며 최근 30일 증가분이 아닙니다." + "tokensTooltip": "해당 세션의 누적 값입니다. {{range}}은(는) 활성 세션을 필터링하는 데만 사용되며 해당 기간의 증가분이 아닙니다." } }, "usageDashboard": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 4d3adf4ae93..e1d060e7bc5 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -5793,20 +5793,32 @@ "usageHistory": { "description": "这台设备上 Cindy 内部产生的 token 用量。记录从该功能上线起积累,不回填更早的历史。", "empty": "还没有用量记录。跑过对话之后,这里会开始积累。", + "loadFailed": "无法加载用量历史。请重新打开设置后重试。", + "range": { + "label": "时间范围", + "ariaLabel": "筛选用量时间范围", + "all": "所有时间", + "30d": "最近 30 天", + "7d": "最近 7 天", + "today": "今天" + }, "cacheHitTooltip": "缓存读取 ÷(输入 + 缓存读取 + 缓存写入),与消息卡片同口径。", "summary": { "title": "概览" }, "stats": { "todayTokens": "今日 token", + "todayTokensInRange": "{{range}} token", "totalTokens": "近 30 天 token", + "totalTokensInRange": "{{range}} token", "streak": "连续活跃天数", "cacheHitRate": "缓存命中率(近 30 天)", + "cacheHitRateInRange": "缓存命中率({{range}})", "models": "用到的模型" }, "heatmap": { "title": "活跃热力图", - "subtitle": "近 20 周", + "subtitle": "近 {{count}} 周", "emptyCell": "当天没有用量记录" }, "daily": { @@ -5819,6 +5831,7 @@ "col": { "agent": "Agent / harness", "total": "近 30 天 token", + "totalInRange": "{{range}} token", "share": "占比", "today": "今日", "hitRate": "缓存命中率", @@ -5831,6 +5844,7 @@ "col": { "model": "模型", "total": "总 token", + "totalInRange": "{{range}} token", "share": "占比", "input": "输入", "output": "输出", @@ -5841,7 +5855,7 @@ }, "tasks": { "title": "最耗 token 的任务", - "subtitle": "近 30 天内活跃过的任务,按累计 token 排序", + "subtitle": "{{range}}内活跃过的任务,按累计 token 排序", "col": { "task": "任务", "model": "模型", @@ -5851,7 +5865,7 @@ "lastActive": "最后活跃" }, "providerTooltip": "取该任务当前选定的供应商;未指定时留空。", - "tokensTooltip": "该任务的生命周期累计,不是近 30 天内的增量。" + "tokensTooltip": "该任务的生命周期累计;{{range}}仅用于筛选活跃任务,不表示该范围内的增量。" } }, "usageDashboard": { diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 91f511e8428..0d845e3df82 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -5793,20 +5793,32 @@ "usageHistory": { "description": "這台裝置上 Cindy 內部產生的 token 用量。紀錄從該功能上線起累積,不回填更早的歷史。", "empty": "還沒有用量紀錄。跑過對話之後,這裡會開始累積。", + "loadFailed": "無法載入用量歷史。請重新開啟設定後再試一次。", + "range": { + "label": "時間範圍", + "ariaLabel": "篩選用量時間範圍", + "all": "所有時間", + "30d": "最近 30 天", + "7d": "最近 7 天", + "today": "今天" + }, "cacheHitTooltip": "快取讀取 ÷(輸入 + 快取讀取 + 快取寫入),與訊息卡片同口徑。", "summary": { "title": "概覽" }, "stats": { "todayTokens": "今日 token", + "todayTokensInRange": "{{range}} token", "totalTokens": "近 30 天 token", + "totalTokensInRange": "{{range}} token", "streak": "連續活躍天數", "cacheHitRate": "快取命中率(近 30 天)", + "cacheHitRateInRange": "快取命中率({{range}})", "models": "用到的模型" }, "heatmap": { "title": "活躍熱力圖", - "subtitle": "近 20 週", + "subtitle": "近 {{count}} 週", "emptyCell": "當天沒有用量紀錄" }, "daily": { @@ -5819,6 +5831,7 @@ "col": { "agent": "Agent / harness", "total": "近 30 天 token", + "totalInRange": "{{range}} token", "share": "佔比", "today": "今日", "hitRate": "快取命中率", @@ -5831,6 +5844,7 @@ "col": { "model": "模型", "total": "總 token", + "totalInRange": "{{range}} token", "share": "佔比", "input": "輸入", "output": "輸出", @@ -5841,7 +5855,7 @@ }, "tasks": { "title": "最耗 token 的任務", - "subtitle": "近 30 天內活躍過的任務,按累計 token 排序", + "subtitle": "{{range}}內活躍過的任務,按累計 token 排序", "col": { "task": "任務", "model": "模型", @@ -5851,7 +5865,7 @@ "lastActive": "最後活躍" }, "providerTooltip": "取該任務目前選定的供應商;未指定時留空。", - "tokensTooltip": "該任務的生命週期累計,不是近 30 天內的增量。" + "tokensTooltip": "該任務的生命週期累計;{{range}}僅用於篩選活躍任務,不代表該範圍內的增量。" } }, "usageDashboard": { diff --git a/apps/desktop/src/renderer/lib/ccAgent.types.ts b/apps/desktop/src/renderer/lib/ccAgent.types.ts index 88cf05337b6..057ffd0c087 100644 --- a/apps/desktop/src/renderer/lib/ccAgent.types.ts +++ b/apps/desktop/src/renderer/lib/ccAgent.types.ts @@ -325,6 +325,25 @@ export interface Session { summary?: string | null; } +/** + * 用量历史任务榜单的最小 wire DTO。 + * + * 该查询会覆盖整个本地 sessions 表,因此不能把完整 Session 行送进 + * Renderer;榜单只需要这些统计与展示字段。 + */ +export type UsageHistorySession = Pick< + Session, + | 'id' + | 'title' + | 'model' + | 'providerId' + | 'totalTokenUsage' + | 'contextTokens' + | 'contextWindow' + | 'userSendAt' + | 'updatedAt' +>; + export interface SessionRuntimeProfileProjection { agentKind: 'claude-code' | 'codex' | 'pi'; model: string; diff --git a/apps/desktop/src/renderer/lib/sessionService.ts b/apps/desktop/src/renderer/lib/sessionService.ts index 0352cd24c50..8d7093f9cb1 100644 --- a/apps/desktop/src/renderer/lib/sessionService.ts +++ b/apps/desktop/src/renderer/lib/sessionService.ts @@ -6,7 +6,13 @@ * 与现有 httpClient 错误对齐。 */ -import type { OrcaRole, Session, SessionStatus, WorkspaceKind } from '@/lib/ccAgent.types'; +import type { + OrcaRole, + Session, + SessionStatus, + UsageHistorySession, + WorkspaceKind, +} from '@/lib/ccAgent.types'; import { ApiError } from '@/lib/httpClient'; import { extractIpcError } from '@/utils/ipcError'; // device-link 透明对等:fork / rewind 按 sessionId 来源路由(本机 → 本地 maker, @@ -52,13 +58,37 @@ export type SessionListOptions = { includePinned?: boolean; /** forceRefresh / status 重拉:绕开 main 侧 in-flight 合并。 */ fresh?: boolean; + /** 用量历史页读取完整会话候选集,跳过侧栏的 1000 行上限与消息预览。 */ + usageHistory?: boolean; }; +type SessionListRegularOptions = Omit & { + usageHistory?: false | undefined; +}; +type SessionListUsageOptions = Omit & { + usageHistory: true; +}; + +export function list( + limit?: number, + status?: ListStatusFilter, + options?: SessionListRegularOptions, +): Promise; +export function list( + limit?: number, + status?: ListStatusFilter, + options?: SessionListUsageOptions, +): Promise; +export function list( + limit?: number, + status?: ListStatusFilter, + options?: SessionListOptions, +): Promise; export async function list( limit: number = 20, status?: ListStatusFilter, options?: SessionListOptions, -): Promise { +): Promise { return wrap(window.electronAPI.localDb.sessions.list(limit, status, options)); } diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 00741fbe94c..6fdebc86087 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -1144,6 +1144,24 @@ type CindyMediaPreferenceKind = { defaultModel: CindyMediaPreferenceOption | null; }; +type ElectronLocalDbSessionListOptions = { + includePinned?: boolean; + fresh?: boolean; + usageHistory?: boolean; +}; +type ElectronLocalDbSessionListRegularOptions = Omit< + ElectronLocalDbSessionListOptions, + 'usageHistory' +> & { + usageHistory?: false | undefined; +}; +type ElectronLocalDbSessionListUsageOptions = Omit< + ElectronLocalDbSessionListOptions, + 'usageHistory' +> & { + usageHistory: true; +}; + interface ElectronAPI { platform: string; /** 当前 Desktop 构建是否具备 Beta 更新渠道。 */ @@ -4372,11 +4390,26 @@ interface ElectronAPI { ) => () => void; }; sessions: { - list: ( - limit?: number, - status?: 'active' | 'archived' | 'all', - options?: { includePinned?: boolean; fresh?: boolean }, - ) => Promise; + list: { + ( + limit?: number, + status?: 'active' | 'archived' | 'all', + options?: ElectronLocalDbSessionListRegularOptions, + ): Promise; + ( + limit?: number, + status?: 'active' | 'archived' | 'all', + options?: ElectronLocalDbSessionListUsageOptions, + ): Promise; + ( + limit?: number, + status?: 'active' | 'archived' | 'all', + options?: ElectronLocalDbSessionListOptions, + ): Promise< + | import('@/lib/ccAgent.types').Session[] + | import('@/lib/ccAgent.types').UsageHistorySession[] + >; + }; create: (body?: { id?: string; workingDir?: string; @@ -6134,7 +6167,8 @@ interface ElectronAPI { ) => () => void; /** 用量历史聚合 (首页仪表盘)。wire 形态与 main/usage/usageHistory.ts 的 UsageHistoryPayload 同形。 */ getHistory: (opts?: { - days?: number; + days?: number | 'all'; + modelDays?: number | 'all'; forceRefresh?: boolean; }) => Promise; onTodaySpendChanged: ( diff --git a/docs/design-rules/design-inventory.md b/docs/design-rules/design-inventory.md index 90f7220a93c..1d1f039f48a 100644 --- a/docs/design-rules/design-inventory.md +++ b/docs/design-rules/design-inventory.md @@ -20,9 +20,9 @@ | `desktop.auth.login` | desktop | 登录页 | hash `/login`(GuestRoute → LoginPage) | LoginBrandStage, LoginCaptchaOverlay, LoginControls, LoginPage | apps/desktop/src/renderer/components/login/LoginBrandStage.tsx, apps/desktop/src/renderer/components/login/LoginCaptchaOverlay.tsx, apps/desktop/src/renderer/components/login/LoginControls.tsx, apps/desktop/src/renderer/components/login/LoginPage.tsx, apps/desktop/src/renderer/components/login/loginDesignTokens.ts | 59 | 0 | 21 | | `desktop.chat.files` | desktop | 工作目录文件浏览 | hash `/cc-agent/files/:sessionId`(WorkdirBrowseRoute) | FileBodyView, FileTreeView, WorkdirBrowseRoute | apps/desktop/src/renderer/features/cc-agent/workdir-browse/DocSearchBar.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/DrawioPreview.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/FileBodyView.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/FileFilterInput.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/FileTabsBar.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/FileTreeView.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/FilterResultList.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/ImagePreview.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/OpenInSystemActions.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/PdfPreview.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/SessionTabsBar.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/UnrenderablePlaceholder.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/WorkdirBrowseRoute.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/WorkdirBrowseSidebar.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useChatRailCollapsed.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useChatRailResize.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useConfirmSwitchAwayIfDirty.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useDelayedFlag.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useFileContent.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useFileTree.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useOpenTabs.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useProjectFileList.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/hooks/useRevealFileInTree.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/activeFileBodyHandle.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/docModeSwitchProjects.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/drawioExt.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/expandedStore.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/fileContentCache.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/fileIcon.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/fileMeta.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/fileScrollStore.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/fileSelectionParams.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/filterFiles.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/imageExt.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/markdownAutosave.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/openTabsStore.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/pdfExt.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/lib/selectedFileStore.ts, apps/desktop/src/renderer/features/cc-agent/workdir-browse/search/SearchInput.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/search/SearchPanel.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/search/SearchResults.tsx, apps/desktop/src/renderer/features/cc-agent/workdir-browse/search/hooks/useProjectSearch.ts | 22 | 7 | 64 | | `desktop.chat.new-draft` | desktop | 新建任务草稿页 | hash `/cc-agent/new`(NewMakerDraftRoute) | NewMakerDraftRoute | apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx | 15 | 0 | 2 | -| `desktop.chat.orca-workflow` | desktop | Orca 协同工作台 | hash `/cc-agent/orca/:sessionId`(OrcaWorkflowRoute) | CCAgentSessionView, OrcaSplitView, OrcaWorkerPanel, OrcaWorkflowRoute | apps/desktop/src/renderer/components/chat/AgentActionRow.tsx, apps/desktop/src/renderer/components/chat/AgentActionsBlock.tsx, apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx, apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx, apps/desktop/src/renderer/components/chat/AssistantMessage.tsx, apps/desktop/src/renderer/components/chat/AutomationOriginBadge.tsx, apps/desktop/src/renderer/components/chat/ChatAudioCard.tsx, apps/desktop/src/renderer/components/chat/ChatDisplaySnapshotContext.tsx, apps/desktop/src/renderer/components/chat/ChatImageView.tsx, apps/desktop/src/renderer/components/chat/ChatSessionFileContext.tsx, apps/desktop/src/renderer/components/chat/ChatSoundEffectCard.tsx, apps/desktop/src/renderer/components/chat/ChatVideoView.tsx, apps/desktop/src/renderer/components/chat/CopyAsImageBlock.tsx, apps/desktop/src/renderer/components/chat/CredentialSwitchWaitBanner.tsx, apps/desktop/src/renderer/components/chat/DiffView.tsx, apps/desktop/src/renderer/components/chat/ErrorBanner.tsx, apps/desktop/src/renderer/components/chat/ErrorMessageCard.tsx, apps/desktop/src/renderer/components/chat/GeneratedFilesCard.tsx, apps/desktop/src/renderer/components/chat/GhostSummonCard.tsx, apps/desktop/src/renderer/components/chat/GhostToolCard.tsx, apps/desktop/src/renderer/components/chat/HookTaskCard.tsx, apps/desktop/src/renderer/components/chat/ImageGalleryContext.tsx, apps/desktop/src/renderer/components/chat/ImageHoverPreview.tsx, apps/desktop/src/renderer/components/chat/ImageLightbox.tsx, apps/desktop/src/renderer/components/chat/ImageMissingPlaceholder.tsx, apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx, apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx, apps/desktop/src/renderer/components/chat/JumpToBottomChip.tsx, apps/desktop/src/renderer/components/chat/MarkdownDiffBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownMermaidBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx, apps/desktop/src/renderer/components/chat/MermaidLightbox.tsx, apps/desktop/src/renderer/components/chat/MessageActionBar.tsx, apps/desktop/src/renderer/components/chat/MessageNavRail.tsx, apps/desktop/src/renderer/components/chat/MessageStream.tsx, apps/desktop/src/renderer/components/chat/ModelLightbox.tsx, apps/desktop/src/renderer/components/chat/NewMessageIndicator.tsx, apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx, apps/desktop/src/renderer/components/chat/PrevMessageJumpChip.tsx, apps/desktop/src/renderer/components/chat/ProjectLinkChip.tsx, apps/desktop/src/renderer/components/chat/QuoteChip.tsx, apps/desktop/src/renderer/components/chat/RewindPreviewDialog.tsx, apps/desktop/src/renderer/components/chat/SelectionQuoteButton.tsx, apps/desktop/src/renderer/components/chat/SentAgentReferenceChip.tsx, apps/desktop/src/renderer/components/chat/SentInlineAtomBody.tsx, apps/desktop/src/renderer/components/chat/SessionHandoffCard.tsx, apps/desktop/src/renderer/components/chat/SessionLinkChip.tsx, apps/desktop/src/renderer/components/chat/ShareMessageCheckbox.tsx, apps/desktop/src/renderer/components/chat/ShareSelectionBar.tsx, apps/desktop/src/renderer/components/chat/SlackIcon.tsx, apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx, apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx, apps/desktop/src/renderer/components/chat/SystemCard.tsx, apps/desktop/src/renderer/components/chat/TextLightbox.tsx, apps/desktop/src/renderer/components/chat/ThinkingCard.tsx, apps/desktop/src/renderer/components/chat/ThinkingText.tsx, apps/desktop/src/renderer/components/chat/TodoListCard.tsx, apps/desktop/src/renderer/components/chat/ToolCallCard.tsx, apps/desktop/src/renderer/components/chat/ToolPayloadLightbox.tsx, apps/desktop/src/renderer/components/chat/TopRightChipStack.tsx, apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx, apps/desktop/src/renderer/components/chat/UpgradeBanner.tsx, apps/desktop/src/renderer/components/chat/UserMessage.tsx, apps/desktop/src/renderer/components/chat/UserMessageEditBox.tsx, apps/desktop/src/renderer/components/chat/UserMessageUrlLink.tsx, apps/desktop/src/renderer/components/chat/VideoLightbox.tsx, apps/desktop/src/renderer/components/chat/WorkGroupBlock.tsx, apps/desktop/src/renderer/components/chat/WorktreeRestoreBanner.tsx, apps/desktop/src/renderer/components/chat/XIcon.tsx, apps/desktop/src/renderer/components/chat/activityRowChrome.ts, apps/desktop/src/renderer/components/chat/autoFollowIntent.ts, apps/desktop/src/renderer/components/chat/errorReasonI18n.ts, apps/desktop/src/renderer/components/chat/imageDisplaySize.ts, apps/desktop/src/renderer/components/chat/lightboxAnnotations.ts, apps/desktop/src/renderer/components/chat/lightboxGestures.ts, apps/desktop/src/renderer/components/chat/markdownImageTargets.ts, apps/desktop/src/renderer/components/chat/messageNavRailModel.ts, apps/desktop/src/renderer/components/chat/messageNavRailWheel.ts, apps/desktop/src/renderer/components/chat/messageStreamIndicatorPosition.ts, apps/desktop/src/renderer/components/chat/rehypeFencedCodeMarker.ts, apps/desktop/src/renderer/components/chat/rehypeMathBlockMarker.ts, apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts, apps/desktop/src/renderer/components/chat/remarkHtmlImages.ts, apps/desktop/src/renderer/components/chat/remarkLocalPathLinks.ts, apps/desktop/src/renderer/components/chat/remarkPreserveRawLocalDestinations.ts, apps/desktop/src/renderer/components/chat/remarkSessionLinks.ts, apps/desktop/src/renderer/components/chat/remarkStrictInlineMath.ts, apps/desktop/src/renderer/components/chat/remarkTruncateCjkUrls.ts, apps/desktop/src/renderer/components/chat/repairStreamingMarkdown.ts, apps/desktop/src/renderer/components/chat/scrollAnchoringDetect.ts, apps/desktop/src/renderer/components/chat/shareSelectionStore.ts, apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts, apps/desktop/src/renderer/components/chat/toolLoopI18n.ts, apps/desktop/src/renderer/components/chat/unreadCount.ts, apps/desktop/src/renderer/components/chat/useAutomaticHistoryLoadBudget.ts, apps/desktop/src/renderer/components/chat/useCopyAsImage.tsx, apps/desktop/src/renderer/components/chat/useDeleteMessage.ts, apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx, apps/desktop/src/renderer/components/chat/useForkAtMessage.ts, apps/desktop/src/renderer/components/chat/useNavigationKeyListener.ts, apps/desktop/src/renderer/components/chat/useOpenWithMenu.tsx, apps/desktop/src/renderer/components/chat/usePrevUserMessageInView.ts, apps/desktop/src/renderer/components/chat/userMessageCollapse.ts, apps/desktop/src/renderer/components/chat/userMessageDisplayText.ts, apps/desktop/src/renderer/components/chat/userMessageLinkify.ts, apps/desktop/src/renderer/components/chat/viewportFillDetect.ts, apps/desktop/src/renderer/components/new-chat/AddRemoteProjectDialog.tsx, apps/desktop/src/renderer/components/new-chat/AgentSelect.tsx, apps/desktop/src/renderer/components/new-chat/AskUserQuestionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/AtMentionPanel.tsx, apps/desktop/src/renderer/components/new-chat/AttachmentTypeThumb.tsx, apps/desktop/src/renderer/components/new-chat/ChatInput.tsx, apps/desktop/src/renderer/components/new-chat/CjkPunctDecoration.ts, apps/desktop/src/renderer/components/new-chat/CjkPunctuationUtils.ts, apps/desktop/src/renderer/components/new-chat/ComposerListIndentDecoration.ts, apps/desktop/src/renderer/components/new-chat/ComposerListNodes.ts, apps/desktop/src/renderer/components/new-chat/ComposerQuoteNode.tsx, apps/desktop/src/renderer/components/new-chat/DeviceSwitcherPill.tsx, apps/desktop/src/renderer/components/new-chat/EffortSlider.tsx, apps/desktop/src/renderer/components/new-chat/EmptyDocSelectionGuard.ts, apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx, apps/desktop/src/renderer/components/new-chat/FastModeToggle.tsx, apps/desktop/src/renderer/components/new-chat/FolderPickerPopover.tsx, apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx, apps/desktop/src/renderer/components/new-chat/GhostCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/GoalAdvancedLimits.tsx, apps/desktop/src/renderer/components/new-chat/GoalIndicator.tsx, apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx, apps/desktop/src/renderer/components/new-chat/ListComposerTextarea.tsx, apps/desktop/src/renderer/components/new-chat/MentionChipNode.tsx, apps/desktop/src/renderer/components/new-chat/MentionDragCaretDecoration.ts, apps/desktop/src/renderer/components/new-chat/ModelConfigFlyout.tsx, apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx, apps/desktop/src/renderer/components/new-chat/NewGoalDialog.tsx, apps/desktop/src/renderer/components/new-chat/PastedTextChipNode.tsx, apps/desktop/src/renderer/components/new-chat/PendingQueuePanel.tsx, apps/desktop/src/renderer/components/new-chat/PermissionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx, apps/desktop/src/renderer/components/new-chat/PinnedPlanPanel.tsx, apps/desktop/src/renderer/components/new-chat/PlanActionCard.tsx, apps/desktop/src/renderer/components/new-chat/PlanModeIndicator.tsx, apps/desktop/src/renderer/components/new-chat/PlanViewerCard.tsx, apps/desktop/src/renderer/components/new-chat/PluginSetupPrompt.tsx, apps/desktop/src/renderer/components/new-chat/QuickStartPillMark.ts, apps/desktop/src/renderer/components/new-chat/SendButton.tsx, apps/desktop/src/renderer/components/new-chat/SlashCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/SlashCommandPalette.tsx, apps/desktop/src/renderer/components/new-chat/TakeoverMask.tsx, apps/desktop/src/renderer/components/new-chat/ThinkingToggle.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRail.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx, apps/desktop/src/renderer/components/new-chat/UsageDailyBars.tsx, apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx, apps/desktop/src/renderer/components/new-chat/VendorSegmentedSwitcher.tsx, apps/desktop/src/renderer/components/new-chat/VoiceInputDraftDecoration.ts, apps/desktop/src/renderer/components/new-chat/WindowsSelectionReplacement.ts, apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx, apps/desktop/src/renderer/components/new-chat/WorktreeCreatingOverlay.tsx, apps/desktop/src/renderer/components/new-chat/agentOptions.ts, apps/desktop/src/renderer/components/new-chat/agentSwitchConfirmation.ts, apps/desktop/src/renderer/components/new-chat/branchPick.ts, apps/desktop/src/renderer/components/new-chat/composerBlankPointerFocus.ts, apps/desktop/src/renderer/components/new-chat/composerContentSerialization.ts, apps/desktop/src/renderer/components/new-chat/composerDocState.ts, apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts, apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts, apps/desktop/src/renderer/components/new-chat/composerPromptRecommendation.ts, apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts, apps/desktop/src/renderer/components/new-chat/composerSendOwnership.ts, apps/desktop/src/renderer/components/new-chat/composerSendSnapshot.ts, apps/desktop/src/renderer/components/new-chat/effortChangeQueue.ts, apps/desktop/src/renderer/components/new-chat/extraDirsActions.ts, apps/desktop/src/renderer/components/new-chat/ghostComposerPlacement.ts, apps/desktop/src/renderer/components/new-chat/localAttachmentPicker.ts, apps/desktop/src/renderer/components/new-chat/mentionChipInsertion.ts, apps/desktop/src/renderer/components/new-chat/pastePipeline.ts, apps/desktop/src/renderer/components/new-chat/pathPaste.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueLocks.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowActivity.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowPresentation.ts, apps/desktop/src/renderer/components/new-chat/planModeComposerCommand.ts, apps/desktop/src/renderer/components/new-chat/remoteBrowseAdapters.ts, apps/desktop/src/renderer/components/new-chat/remoteExistingProjects.ts, apps/desktop/src/renderer/components/new-chat/sessionLinkPaste.ts, apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts, apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts, apps/desktop/src/renderer/components/new-chat/usagePalette.ts, apps/desktop/src/renderer/components/new-chat/useComposerSendFocusRestore.ts, apps/desktop/src/renderer/components/new-chat/useModelDiscoveryPending.ts, apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts, apps/desktop/src/renderer/features/cc-agent/CCAgentFeatureLayout.tsx, apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx, apps/desktop/src/renderer/features/cc-agent/OrcaSplitView.tsx, apps/desktop/src/renderer/features/cc-agent/OrcaWorkerPanel.tsx, apps/desktop/src/renderer/features/cc-agent/OrcaWorkflowRoute.tsx | 266 | 56 | 411 | +| `desktop.chat.orca-workflow` | desktop | Orca 协同工作台 | hash `/cc-agent/orca/:sessionId`(OrcaWorkflowRoute) | CCAgentSessionView, OrcaSplitView, OrcaWorkerPanel, OrcaWorkflowRoute | apps/desktop/src/renderer/components/chat/AgentActionRow.tsx, apps/desktop/src/renderer/components/chat/AgentActionsBlock.tsx, apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx, apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx, apps/desktop/src/renderer/components/chat/AssistantMessage.tsx, apps/desktop/src/renderer/components/chat/AutomationOriginBadge.tsx, apps/desktop/src/renderer/components/chat/ChatAudioCard.tsx, apps/desktop/src/renderer/components/chat/ChatDisplaySnapshotContext.tsx, apps/desktop/src/renderer/components/chat/ChatImageView.tsx, apps/desktop/src/renderer/components/chat/ChatSessionFileContext.tsx, apps/desktop/src/renderer/components/chat/ChatSoundEffectCard.tsx, apps/desktop/src/renderer/components/chat/ChatVideoView.tsx, apps/desktop/src/renderer/components/chat/CopyAsImageBlock.tsx, apps/desktop/src/renderer/components/chat/CredentialSwitchWaitBanner.tsx, apps/desktop/src/renderer/components/chat/DiffView.tsx, apps/desktop/src/renderer/components/chat/ErrorBanner.tsx, apps/desktop/src/renderer/components/chat/ErrorMessageCard.tsx, apps/desktop/src/renderer/components/chat/GeneratedFilesCard.tsx, apps/desktop/src/renderer/components/chat/GhostSummonCard.tsx, apps/desktop/src/renderer/components/chat/GhostToolCard.tsx, apps/desktop/src/renderer/components/chat/HookTaskCard.tsx, apps/desktop/src/renderer/components/chat/ImageGalleryContext.tsx, apps/desktop/src/renderer/components/chat/ImageHoverPreview.tsx, apps/desktop/src/renderer/components/chat/ImageLightbox.tsx, apps/desktop/src/renderer/components/chat/ImageMissingPlaceholder.tsx, apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx, apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx, apps/desktop/src/renderer/components/chat/JumpToBottomChip.tsx, apps/desktop/src/renderer/components/chat/MarkdownDiffBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownMermaidBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx, apps/desktop/src/renderer/components/chat/MermaidLightbox.tsx, apps/desktop/src/renderer/components/chat/MessageActionBar.tsx, apps/desktop/src/renderer/components/chat/MessageNavRail.tsx, apps/desktop/src/renderer/components/chat/MessageStream.tsx, apps/desktop/src/renderer/components/chat/ModelLightbox.tsx, apps/desktop/src/renderer/components/chat/NewMessageIndicator.tsx, apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx, apps/desktop/src/renderer/components/chat/PrevMessageJumpChip.tsx, apps/desktop/src/renderer/components/chat/ProjectLinkChip.tsx, apps/desktop/src/renderer/components/chat/QuoteChip.tsx, apps/desktop/src/renderer/components/chat/RewindPreviewDialog.tsx, apps/desktop/src/renderer/components/chat/SelectionQuoteButton.tsx, apps/desktop/src/renderer/components/chat/SentAgentReferenceChip.tsx, apps/desktop/src/renderer/components/chat/SentInlineAtomBody.tsx, apps/desktop/src/renderer/components/chat/SessionHandoffCard.tsx, apps/desktop/src/renderer/components/chat/SessionLinkChip.tsx, apps/desktop/src/renderer/components/chat/ShareMessageCheckbox.tsx, apps/desktop/src/renderer/components/chat/ShareSelectionBar.tsx, apps/desktop/src/renderer/components/chat/SlackIcon.tsx, apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx, apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx, apps/desktop/src/renderer/components/chat/SystemCard.tsx, apps/desktop/src/renderer/components/chat/TextLightbox.tsx, apps/desktop/src/renderer/components/chat/ThinkingCard.tsx, apps/desktop/src/renderer/components/chat/ThinkingText.tsx, apps/desktop/src/renderer/components/chat/TodoListCard.tsx, apps/desktop/src/renderer/components/chat/ToolCallCard.tsx, apps/desktop/src/renderer/components/chat/ToolPayloadLightbox.tsx, apps/desktop/src/renderer/components/chat/TopRightChipStack.tsx, apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx, apps/desktop/src/renderer/components/chat/UpgradeBanner.tsx, apps/desktop/src/renderer/components/chat/UserMessage.tsx, apps/desktop/src/renderer/components/chat/UserMessageEditBox.tsx, apps/desktop/src/renderer/components/chat/UserMessageUrlLink.tsx, apps/desktop/src/renderer/components/chat/VideoLightbox.tsx, apps/desktop/src/renderer/components/chat/WorkGroupBlock.tsx, apps/desktop/src/renderer/components/chat/WorktreeRestoreBanner.tsx, apps/desktop/src/renderer/components/chat/XIcon.tsx, apps/desktop/src/renderer/components/chat/activityRowChrome.ts, apps/desktop/src/renderer/components/chat/autoFollowIntent.ts, apps/desktop/src/renderer/components/chat/errorReasonI18n.ts, apps/desktop/src/renderer/components/chat/imageDisplaySize.ts, apps/desktop/src/renderer/components/chat/lightboxAnnotations.ts, apps/desktop/src/renderer/components/chat/lightboxGestures.ts, apps/desktop/src/renderer/components/chat/markdownImageTargets.ts, apps/desktop/src/renderer/components/chat/messageNavRailModel.ts, apps/desktop/src/renderer/components/chat/messageNavRailWheel.ts, apps/desktop/src/renderer/components/chat/messageStreamIndicatorPosition.ts, apps/desktop/src/renderer/components/chat/rehypeFencedCodeMarker.ts, apps/desktop/src/renderer/components/chat/rehypeMathBlockMarker.ts, apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts, apps/desktop/src/renderer/components/chat/remarkHtmlImages.ts, apps/desktop/src/renderer/components/chat/remarkLocalPathLinks.ts, apps/desktop/src/renderer/components/chat/remarkPreserveRawLocalDestinations.ts, apps/desktop/src/renderer/components/chat/remarkSessionLinks.ts, apps/desktop/src/renderer/components/chat/remarkStrictInlineMath.ts, apps/desktop/src/renderer/components/chat/remarkTruncateCjkUrls.ts, apps/desktop/src/renderer/components/chat/repairStreamingMarkdown.ts, apps/desktop/src/renderer/components/chat/scrollAnchoringDetect.ts, apps/desktop/src/renderer/components/chat/shareSelectionStore.ts, apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts, apps/desktop/src/renderer/components/chat/toolLoopI18n.ts, apps/desktop/src/renderer/components/chat/unreadCount.ts, apps/desktop/src/renderer/components/chat/useAutomaticHistoryLoadBudget.ts, apps/desktop/src/renderer/components/chat/useCopyAsImage.tsx, apps/desktop/src/renderer/components/chat/useDeleteMessage.ts, apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx, apps/desktop/src/renderer/components/chat/useForkAtMessage.ts, apps/desktop/src/renderer/components/chat/useNavigationKeyListener.ts, apps/desktop/src/renderer/components/chat/useOpenWithMenu.tsx, apps/desktop/src/renderer/components/chat/usePrevUserMessageInView.ts, apps/desktop/src/renderer/components/chat/userMessageCollapse.ts, apps/desktop/src/renderer/components/chat/userMessageDisplayText.ts, apps/desktop/src/renderer/components/chat/userMessageLinkify.ts, apps/desktop/src/renderer/components/chat/viewportFillDetect.ts, apps/desktop/src/renderer/components/new-chat/AddRemoteProjectDialog.tsx, apps/desktop/src/renderer/components/new-chat/AgentSelect.tsx, apps/desktop/src/renderer/components/new-chat/AskUserQuestionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/AtMentionPanel.tsx, apps/desktop/src/renderer/components/new-chat/AttachmentTypeThumb.tsx, apps/desktop/src/renderer/components/new-chat/ChatInput.tsx, apps/desktop/src/renderer/components/new-chat/CjkPunctDecoration.ts, apps/desktop/src/renderer/components/new-chat/CjkPunctuationUtils.ts, apps/desktop/src/renderer/components/new-chat/ComposerListIndentDecoration.ts, apps/desktop/src/renderer/components/new-chat/ComposerListNodes.ts, apps/desktop/src/renderer/components/new-chat/ComposerQuoteNode.tsx, apps/desktop/src/renderer/components/new-chat/DeviceSwitcherPill.tsx, apps/desktop/src/renderer/components/new-chat/EffortSlider.tsx, apps/desktop/src/renderer/components/new-chat/EmptyDocSelectionGuard.ts, apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx, apps/desktop/src/renderer/components/new-chat/FastModeToggle.tsx, apps/desktop/src/renderer/components/new-chat/FolderPickerPopover.tsx, apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx, apps/desktop/src/renderer/components/new-chat/GhostCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/GoalAdvancedLimits.tsx, apps/desktop/src/renderer/components/new-chat/GoalIndicator.tsx, apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx, apps/desktop/src/renderer/components/new-chat/ListComposerTextarea.tsx, apps/desktop/src/renderer/components/new-chat/MentionChipNode.tsx, apps/desktop/src/renderer/components/new-chat/MentionDragCaretDecoration.ts, apps/desktop/src/renderer/components/new-chat/ModelConfigFlyout.tsx, apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx, apps/desktop/src/renderer/components/new-chat/NewGoalDialog.tsx, apps/desktop/src/renderer/components/new-chat/PastedTextChipNode.tsx, apps/desktop/src/renderer/components/new-chat/PendingQueuePanel.tsx, apps/desktop/src/renderer/components/new-chat/PermissionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx, apps/desktop/src/renderer/components/new-chat/PinnedPlanPanel.tsx, apps/desktop/src/renderer/components/new-chat/PlanActionCard.tsx, apps/desktop/src/renderer/components/new-chat/PlanModeIndicator.tsx, apps/desktop/src/renderer/components/new-chat/PlanViewerCard.tsx, apps/desktop/src/renderer/components/new-chat/PluginSetupPrompt.tsx, apps/desktop/src/renderer/components/new-chat/QuickStartPillMark.ts, apps/desktop/src/renderer/components/new-chat/SendButton.tsx, apps/desktop/src/renderer/components/new-chat/SlashCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/SlashCommandPalette.tsx, apps/desktop/src/renderer/components/new-chat/TakeoverMask.tsx, apps/desktop/src/renderer/components/new-chat/ThinkingToggle.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRail.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx, apps/desktop/src/renderer/components/new-chat/UsageDailyBars.tsx, apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx, apps/desktop/src/renderer/components/new-chat/VendorSegmentedSwitcher.tsx, apps/desktop/src/renderer/components/new-chat/VoiceInputDraftDecoration.ts, apps/desktop/src/renderer/components/new-chat/WindowsSelectionReplacement.ts, apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx, apps/desktop/src/renderer/components/new-chat/WorktreeCreatingOverlay.tsx, apps/desktop/src/renderer/components/new-chat/agentOptions.ts, apps/desktop/src/renderer/components/new-chat/agentSwitchConfirmation.ts, apps/desktop/src/renderer/components/new-chat/branchPick.ts, apps/desktop/src/renderer/components/new-chat/composerBlankPointerFocus.ts, apps/desktop/src/renderer/components/new-chat/composerContentSerialization.ts, apps/desktop/src/renderer/components/new-chat/composerDocState.ts, apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts, apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts, apps/desktop/src/renderer/components/new-chat/composerPromptRecommendation.ts, apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts, apps/desktop/src/renderer/components/new-chat/composerSendOwnership.ts, apps/desktop/src/renderer/components/new-chat/composerSendSnapshot.ts, apps/desktop/src/renderer/components/new-chat/effortChangeQueue.ts, apps/desktop/src/renderer/components/new-chat/extraDirsActions.ts, apps/desktop/src/renderer/components/new-chat/ghostComposerPlacement.ts, apps/desktop/src/renderer/components/new-chat/localAttachmentPicker.ts, apps/desktop/src/renderer/components/new-chat/mentionChipInsertion.ts, apps/desktop/src/renderer/components/new-chat/pastePipeline.ts, apps/desktop/src/renderer/components/new-chat/pathPaste.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueLocks.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowActivity.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowPresentation.ts, apps/desktop/src/renderer/components/new-chat/planModeComposerCommand.ts, apps/desktop/src/renderer/components/new-chat/remoteBrowseAdapters.ts, apps/desktop/src/renderer/components/new-chat/remoteExistingProjects.ts, apps/desktop/src/renderer/components/new-chat/sessionLinkPaste.ts, apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts, apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts, apps/desktop/src/renderer/components/new-chat/usagePalette.ts, apps/desktop/src/renderer/components/new-chat/useComposerSendFocusRestore.ts, apps/desktop/src/renderer/components/new-chat/useModelDiscoveryPending.ts, apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts, apps/desktop/src/renderer/features/cc-agent/CCAgentFeatureLayout.tsx, apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx, apps/desktop/src/renderer/features/cc-agent/OrcaSplitView.tsx, apps/desktop/src/renderer/features/cc-agent/OrcaWorkerPanel.tsx, apps/desktop/src/renderer/features/cc-agent/OrcaWorkflowRoute.tsx | 266 | 56 | 413 | | `desktop.chat.scheduled` | desktop | 调度任务列表 | hash `/cc-agent/scheduled`(SchedulerPage) | SchedulerPage | apps/desktop/src/renderer/features/scheduler/SchedulerPage.tsx, apps/desktop/src/renderer/features/scheduler/components/AgentMark.tsx, apps/desktop/src/renderer/features/scheduler/components/BoundSessionCard.tsx, apps/desktop/src/renderer/features/scheduler/components/ProjectAutomationNotifyBridge.tsx, apps/desktop/src/renderer/features/scheduler/components/RunHistoryCard.tsx, apps/desktop/src/renderer/features/scheduler/components/RunHistoryPane.tsx, apps/desktop/src/renderer/features/scheduler/components/ScheduleChips.tsx, apps/desktop/src/renderer/features/scheduler/components/ScheduleFormDialog.tsx, apps/desktop/src/renderer/features/scheduler/components/ScheduleStatusChip.tsx, apps/desktop/src/renderer/features/scheduler/components/ScriptCapabilityMultiSelect.tsx, apps/desktop/src/renderer/features/scheduler/components/TaskListCell.tsx, apps/desktop/src/renderer/features/scheduler/components/TaskListFilterPopover.tsx, apps/desktop/src/renderer/features/scheduler/components/TaskListPane.tsx, apps/desktop/src/renderer/features/scheduler/components/TemplateCard.tsx, apps/desktop/src/renderer/features/scheduler/components/TemplateGallery.tsx, apps/desktop/src/renderer/features/scheduler/components/TemplateParamForm.tsx, apps/desktop/src/renderer/features/scheduler/hooks/useDeleteScheduleWithSessions.tsx, apps/desktop/src/renderer/features/scheduler/hooks/useRunNowBusyGuard.ts, apps/desktop/src/renderer/features/scheduler/hooks/useRuns.ts, apps/desktop/src/renderer/features/scheduler/hooks/useScheduleForm.ts, apps/desktop/src/renderer/features/scheduler/hooks/useScheduleUnreadRunCounts.ts, apps/desktop/src/renderer/features/scheduler/hooks/useSchedules.ts, apps/desktop/src/renderer/features/scheduler/hooks/useSessionReferences.ts, apps/desktop/src/renderer/features/scheduler/hooks/useTemplates.ts, apps/desktop/src/renderer/features/scheduler/index.ts, apps/desktop/src/renderer/features/scheduler/lib/cronCodexPreset.ts, apps/desktop/src/renderer/features/scheduler/lib/cronToHuman.ts, apps/desktop/src/renderer/features/scheduler/lib/formatters.ts, apps/desktop/src/renderer/features/scheduler/lib/pluginScheduleCreateIntent.ts, apps/desktop/src/renderer/features/scheduler/lib/projectAutomationConfig.ts, apps/desktop/src/renderer/features/scheduler/lib/runHistoryGrouping.ts, apps/desktop/src/renderer/features/scheduler/lib/runUnread.ts, apps/desktop/src/renderer/features/scheduler/lib/scheduleFormLogic.ts, apps/desktop/src/renderer/features/scheduler/lib/scheduleRunReadSync.ts, apps/desktop/src/renderer/features/scheduler/lib/scheduleSessionBinding.ts, apps/desktop/src/renderer/features/scheduler/lib/scheduleSidebarIndexRuns.ts, apps/desktop/src/renderer/features/scheduler/lib/schedulesStore.ts, apps/desktop/src/renderer/features/scheduler/lib/statusFilter.ts, apps/desktop/src/renderer/features/scheduler/lib/statusFilterStorage.ts, apps/desktop/src/renderer/features/scheduler/lib/templateLocalization.ts, apps/desktop/src/renderer/features/scheduler/lib/usageLimitScheduleCreateIntent.ts | 44 | 0 | 118 | -| `desktop.chat.session` | desktop | 会话工作台 | hash `/cc-agent/:sessionId`;副窗经 `/cc-agent/boot` 解析后落到同一会话视图 | AssistantMessage, CCAgentFeatureLayout, CCAgentSessionView, ChatInput, PermissionPrompt | apps/desktop/src/renderer/components/chat/AgentActionRow.tsx, apps/desktop/src/renderer/components/chat/AgentActionsBlock.tsx, apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx, apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx, apps/desktop/src/renderer/components/chat/AssistantMessage.tsx, apps/desktop/src/renderer/components/chat/AutomationOriginBadge.tsx, apps/desktop/src/renderer/components/chat/ChatAudioCard.tsx, apps/desktop/src/renderer/components/chat/ChatDisplaySnapshotContext.tsx, apps/desktop/src/renderer/components/chat/ChatImageView.tsx, apps/desktop/src/renderer/components/chat/ChatSessionFileContext.tsx, apps/desktop/src/renderer/components/chat/ChatSoundEffectCard.tsx, apps/desktop/src/renderer/components/chat/ChatVideoView.tsx, apps/desktop/src/renderer/components/chat/CopyAsImageBlock.tsx, apps/desktop/src/renderer/components/chat/CredentialSwitchWaitBanner.tsx, apps/desktop/src/renderer/components/chat/DiffView.tsx, apps/desktop/src/renderer/components/chat/ErrorBanner.tsx, apps/desktop/src/renderer/components/chat/ErrorMessageCard.tsx, apps/desktop/src/renderer/components/chat/GeneratedFilesCard.tsx, apps/desktop/src/renderer/components/chat/GhostSummonCard.tsx, apps/desktop/src/renderer/components/chat/GhostToolCard.tsx, apps/desktop/src/renderer/components/chat/HookTaskCard.tsx, apps/desktop/src/renderer/components/chat/ImageGalleryContext.tsx, apps/desktop/src/renderer/components/chat/ImageHoverPreview.tsx, apps/desktop/src/renderer/components/chat/ImageLightbox.tsx, apps/desktop/src/renderer/components/chat/ImageMissingPlaceholder.tsx, apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx, apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx, apps/desktop/src/renderer/components/chat/JumpToBottomChip.tsx, apps/desktop/src/renderer/components/chat/MarkdownDiffBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownMermaidBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx, apps/desktop/src/renderer/components/chat/MermaidLightbox.tsx, apps/desktop/src/renderer/components/chat/MessageActionBar.tsx, apps/desktop/src/renderer/components/chat/MessageNavRail.tsx, apps/desktop/src/renderer/components/chat/MessageStream.tsx, apps/desktop/src/renderer/components/chat/ModelLightbox.tsx, apps/desktop/src/renderer/components/chat/NewMessageIndicator.tsx, apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx, apps/desktop/src/renderer/components/chat/PrevMessageJumpChip.tsx, apps/desktop/src/renderer/components/chat/ProjectLinkChip.tsx, apps/desktop/src/renderer/components/chat/QuoteChip.tsx, apps/desktop/src/renderer/components/chat/RewindPreviewDialog.tsx, apps/desktop/src/renderer/components/chat/SelectionQuoteButton.tsx, apps/desktop/src/renderer/components/chat/SentAgentReferenceChip.tsx, apps/desktop/src/renderer/components/chat/SentInlineAtomBody.tsx, apps/desktop/src/renderer/components/chat/SessionHandoffCard.tsx, apps/desktop/src/renderer/components/chat/SessionLinkChip.tsx, apps/desktop/src/renderer/components/chat/ShareMessageCheckbox.tsx, apps/desktop/src/renderer/components/chat/ShareSelectionBar.tsx, apps/desktop/src/renderer/components/chat/SlackIcon.tsx, apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx, apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx, apps/desktop/src/renderer/components/chat/SystemCard.tsx, apps/desktop/src/renderer/components/chat/TextLightbox.tsx, apps/desktop/src/renderer/components/chat/ThinkingCard.tsx, apps/desktop/src/renderer/components/chat/ThinkingText.tsx, apps/desktop/src/renderer/components/chat/TodoListCard.tsx, apps/desktop/src/renderer/components/chat/ToolCallCard.tsx, apps/desktop/src/renderer/components/chat/ToolPayloadLightbox.tsx, apps/desktop/src/renderer/components/chat/TopRightChipStack.tsx, apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx, apps/desktop/src/renderer/components/chat/UpgradeBanner.tsx, apps/desktop/src/renderer/components/chat/UserMessage.tsx, apps/desktop/src/renderer/components/chat/UserMessageEditBox.tsx, apps/desktop/src/renderer/components/chat/UserMessageUrlLink.tsx, apps/desktop/src/renderer/components/chat/VideoLightbox.tsx, apps/desktop/src/renderer/components/chat/WorkGroupBlock.tsx, apps/desktop/src/renderer/components/chat/WorktreeRestoreBanner.tsx, apps/desktop/src/renderer/components/chat/XIcon.tsx, apps/desktop/src/renderer/components/chat/activityRowChrome.ts, apps/desktop/src/renderer/components/chat/autoFollowIntent.ts, apps/desktop/src/renderer/components/chat/errorReasonI18n.ts, apps/desktop/src/renderer/components/chat/imageDisplaySize.ts, apps/desktop/src/renderer/components/chat/lightboxAnnotations.ts, apps/desktop/src/renderer/components/chat/lightboxGestures.ts, apps/desktop/src/renderer/components/chat/markdownImageTargets.ts, apps/desktop/src/renderer/components/chat/messageNavRailModel.ts, apps/desktop/src/renderer/components/chat/messageNavRailWheel.ts, apps/desktop/src/renderer/components/chat/messageStreamIndicatorPosition.ts, apps/desktop/src/renderer/components/chat/rehypeFencedCodeMarker.ts, apps/desktop/src/renderer/components/chat/rehypeMathBlockMarker.ts, apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts, apps/desktop/src/renderer/components/chat/remarkHtmlImages.ts, apps/desktop/src/renderer/components/chat/remarkLocalPathLinks.ts, apps/desktop/src/renderer/components/chat/remarkPreserveRawLocalDestinations.ts, apps/desktop/src/renderer/components/chat/remarkSessionLinks.ts, apps/desktop/src/renderer/components/chat/remarkStrictInlineMath.ts, apps/desktop/src/renderer/components/chat/remarkTruncateCjkUrls.ts, apps/desktop/src/renderer/components/chat/repairStreamingMarkdown.ts, apps/desktop/src/renderer/components/chat/scrollAnchoringDetect.ts, apps/desktop/src/renderer/components/chat/shareSelectionStore.ts, apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts, apps/desktop/src/renderer/components/chat/toolLoopI18n.ts, apps/desktop/src/renderer/components/chat/unreadCount.ts, apps/desktop/src/renderer/components/chat/useAutomaticHistoryLoadBudget.ts, apps/desktop/src/renderer/components/chat/useCopyAsImage.tsx, apps/desktop/src/renderer/components/chat/useDeleteMessage.ts, apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx, apps/desktop/src/renderer/components/chat/useForkAtMessage.ts, apps/desktop/src/renderer/components/chat/useNavigationKeyListener.ts, apps/desktop/src/renderer/components/chat/useOpenWithMenu.tsx, apps/desktop/src/renderer/components/chat/usePrevUserMessageInView.ts, apps/desktop/src/renderer/components/chat/userMessageCollapse.ts, apps/desktop/src/renderer/components/chat/userMessageDisplayText.ts, apps/desktop/src/renderer/components/chat/userMessageLinkify.ts, apps/desktop/src/renderer/components/chat/viewportFillDetect.ts, apps/desktop/src/renderer/components/new-chat/AddRemoteProjectDialog.tsx, apps/desktop/src/renderer/components/new-chat/AgentSelect.tsx, apps/desktop/src/renderer/components/new-chat/AskUserQuestionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/AtMentionPanel.tsx, apps/desktop/src/renderer/components/new-chat/AttachmentTypeThumb.tsx, apps/desktop/src/renderer/components/new-chat/ChatInput.tsx, apps/desktop/src/renderer/components/new-chat/CjkPunctDecoration.ts, apps/desktop/src/renderer/components/new-chat/CjkPunctuationUtils.ts, apps/desktop/src/renderer/components/new-chat/ComposerListIndentDecoration.ts, apps/desktop/src/renderer/components/new-chat/ComposerListNodes.ts, apps/desktop/src/renderer/components/new-chat/ComposerQuoteNode.tsx, apps/desktop/src/renderer/components/new-chat/DeviceSwitcherPill.tsx, apps/desktop/src/renderer/components/new-chat/EffortSlider.tsx, apps/desktop/src/renderer/components/new-chat/EmptyDocSelectionGuard.ts, apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx, apps/desktop/src/renderer/components/new-chat/FastModeToggle.tsx, apps/desktop/src/renderer/components/new-chat/FolderPickerPopover.tsx, apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx, apps/desktop/src/renderer/components/new-chat/GhostCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/GoalAdvancedLimits.tsx, apps/desktop/src/renderer/components/new-chat/GoalIndicator.tsx, apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx, apps/desktop/src/renderer/components/new-chat/ListComposerTextarea.tsx, apps/desktop/src/renderer/components/new-chat/MentionChipNode.tsx, apps/desktop/src/renderer/components/new-chat/MentionDragCaretDecoration.ts, apps/desktop/src/renderer/components/new-chat/ModelConfigFlyout.tsx, apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx, apps/desktop/src/renderer/components/new-chat/NewGoalDialog.tsx, apps/desktop/src/renderer/components/new-chat/PastedTextChipNode.tsx, apps/desktop/src/renderer/components/new-chat/PendingQueuePanel.tsx, apps/desktop/src/renderer/components/new-chat/PermissionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx, apps/desktop/src/renderer/components/new-chat/PinnedPlanPanel.tsx, apps/desktop/src/renderer/components/new-chat/PlanActionCard.tsx, apps/desktop/src/renderer/components/new-chat/PlanModeIndicator.tsx, apps/desktop/src/renderer/components/new-chat/PlanViewerCard.tsx, apps/desktop/src/renderer/components/new-chat/PluginSetupPrompt.tsx, apps/desktop/src/renderer/components/new-chat/QuickStartPillMark.ts, apps/desktop/src/renderer/components/new-chat/SendButton.tsx, apps/desktop/src/renderer/components/new-chat/SlashCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/SlashCommandPalette.tsx, apps/desktop/src/renderer/components/new-chat/TakeoverMask.tsx, apps/desktop/src/renderer/components/new-chat/ThinkingToggle.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRail.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx, apps/desktop/src/renderer/components/new-chat/UsageDailyBars.tsx, apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx, apps/desktop/src/renderer/components/new-chat/VendorSegmentedSwitcher.tsx, apps/desktop/src/renderer/components/new-chat/VoiceInputDraftDecoration.ts, apps/desktop/src/renderer/components/new-chat/WindowsSelectionReplacement.ts, apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx, apps/desktop/src/renderer/components/new-chat/WorktreeCreatingOverlay.tsx, apps/desktop/src/renderer/components/new-chat/agentOptions.ts, apps/desktop/src/renderer/components/new-chat/agentSwitchConfirmation.ts, apps/desktop/src/renderer/components/new-chat/branchPick.ts, apps/desktop/src/renderer/components/new-chat/composerBlankPointerFocus.ts, apps/desktop/src/renderer/components/new-chat/composerContentSerialization.ts, apps/desktop/src/renderer/components/new-chat/composerDocState.ts, apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts, apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts, apps/desktop/src/renderer/components/new-chat/composerPromptRecommendation.ts, apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts, apps/desktop/src/renderer/components/new-chat/composerSendOwnership.ts, apps/desktop/src/renderer/components/new-chat/composerSendSnapshot.ts, apps/desktop/src/renderer/components/new-chat/effortChangeQueue.ts, apps/desktop/src/renderer/components/new-chat/extraDirsActions.ts, apps/desktop/src/renderer/components/new-chat/ghostComposerPlacement.ts, apps/desktop/src/renderer/components/new-chat/localAttachmentPicker.ts, apps/desktop/src/renderer/components/new-chat/mentionChipInsertion.ts, apps/desktop/src/renderer/components/new-chat/pastePipeline.ts, apps/desktop/src/renderer/components/new-chat/pathPaste.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueLocks.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowActivity.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowPresentation.ts, apps/desktop/src/renderer/components/new-chat/planModeComposerCommand.ts, apps/desktop/src/renderer/components/new-chat/remoteBrowseAdapters.ts, apps/desktop/src/renderer/components/new-chat/remoteExistingProjects.ts, apps/desktop/src/renderer/components/new-chat/sessionLinkPaste.ts, apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts, apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts, apps/desktop/src/renderer/components/new-chat/usagePalette.ts, apps/desktop/src/renderer/components/new-chat/useComposerSendFocusRestore.ts, apps/desktop/src/renderer/components/new-chat/useModelDiscoveryPending.ts, apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts, apps/desktop/src/renderer/features/cc-agent/CCAgentFeatureLayout.tsx, apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx | 266 | 56 | 410 | +| `desktop.chat.session` | desktop | 会话工作台 | hash `/cc-agent/:sessionId`;副窗经 `/cc-agent/boot` 解析后落到同一会话视图 | AssistantMessage, CCAgentFeatureLayout, CCAgentSessionView, ChatInput, PermissionPrompt | apps/desktop/src/renderer/components/chat/AgentActionRow.tsx, apps/desktop/src/renderer/components/chat/AgentActionsBlock.tsx, apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx, apps/desktop/src/renderer/components/chat/AskUserQuestionBubble.tsx, apps/desktop/src/renderer/components/chat/AssistantMessage.tsx, apps/desktop/src/renderer/components/chat/AutomationOriginBadge.tsx, apps/desktop/src/renderer/components/chat/ChatAudioCard.tsx, apps/desktop/src/renderer/components/chat/ChatDisplaySnapshotContext.tsx, apps/desktop/src/renderer/components/chat/ChatImageView.tsx, apps/desktop/src/renderer/components/chat/ChatSessionFileContext.tsx, apps/desktop/src/renderer/components/chat/ChatSoundEffectCard.tsx, apps/desktop/src/renderer/components/chat/ChatVideoView.tsx, apps/desktop/src/renderer/components/chat/CopyAsImageBlock.tsx, apps/desktop/src/renderer/components/chat/CredentialSwitchWaitBanner.tsx, apps/desktop/src/renderer/components/chat/DiffView.tsx, apps/desktop/src/renderer/components/chat/ErrorBanner.tsx, apps/desktop/src/renderer/components/chat/ErrorMessageCard.tsx, apps/desktop/src/renderer/components/chat/GeneratedFilesCard.tsx, apps/desktop/src/renderer/components/chat/GhostSummonCard.tsx, apps/desktop/src/renderer/components/chat/GhostToolCard.tsx, apps/desktop/src/renderer/components/chat/HookTaskCard.tsx, apps/desktop/src/renderer/components/chat/ImageGalleryContext.tsx, apps/desktop/src/renderer/components/chat/ImageHoverPreview.tsx, apps/desktop/src/renderer/components/chat/ImageLightbox.tsx, apps/desktop/src/renderer/components/chat/ImageMissingPlaceholder.tsx, apps/desktop/src/renderer/components/chat/InlineReferenceChip.tsx, apps/desktop/src/renderer/components/chat/InterruptedTurnBanner.tsx, apps/desktop/src/renderer/components/chat/JumpToBottomChip.tsx, apps/desktop/src/renderer/components/chat/MarkdownDiffBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownMermaidBlock.tsx, apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx, apps/desktop/src/renderer/components/chat/MermaidLightbox.tsx, apps/desktop/src/renderer/components/chat/MessageActionBar.tsx, apps/desktop/src/renderer/components/chat/MessageNavRail.tsx, apps/desktop/src/renderer/components/chat/MessageStream.tsx, apps/desktop/src/renderer/components/chat/ModelLightbox.tsx, apps/desktop/src/renderer/components/chat/NewMessageIndicator.tsx, apps/desktop/src/renderer/components/chat/PlanReviewBubble.tsx, apps/desktop/src/renderer/components/chat/PrevMessageJumpChip.tsx, apps/desktop/src/renderer/components/chat/ProjectLinkChip.tsx, apps/desktop/src/renderer/components/chat/QuoteChip.tsx, apps/desktop/src/renderer/components/chat/RewindPreviewDialog.tsx, apps/desktop/src/renderer/components/chat/SelectionQuoteButton.tsx, apps/desktop/src/renderer/components/chat/SentAgentReferenceChip.tsx, apps/desktop/src/renderer/components/chat/SentInlineAtomBody.tsx, apps/desktop/src/renderer/components/chat/SessionHandoffCard.tsx, apps/desktop/src/renderer/components/chat/SessionLinkChip.tsx, apps/desktop/src/renderer/components/chat/ShareMessageCheckbox.tsx, apps/desktop/src/renderer/components/chat/ShareSelectionBar.tsx, apps/desktop/src/renderer/components/chat/SlackIcon.tsx, apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx, apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx, apps/desktop/src/renderer/components/chat/SystemCard.tsx, apps/desktop/src/renderer/components/chat/TextLightbox.tsx, apps/desktop/src/renderer/components/chat/ThinkingCard.tsx, apps/desktop/src/renderer/components/chat/ThinkingText.tsx, apps/desktop/src/renderer/components/chat/TodoListCard.tsx, apps/desktop/src/renderer/components/chat/ToolCallCard.tsx, apps/desktop/src/renderer/components/chat/ToolPayloadLightbox.tsx, apps/desktop/src/renderer/components/chat/TopRightChipStack.tsx, apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx, apps/desktop/src/renderer/components/chat/UpgradeBanner.tsx, apps/desktop/src/renderer/components/chat/UserMessage.tsx, apps/desktop/src/renderer/components/chat/UserMessageEditBox.tsx, apps/desktop/src/renderer/components/chat/UserMessageUrlLink.tsx, apps/desktop/src/renderer/components/chat/VideoLightbox.tsx, apps/desktop/src/renderer/components/chat/WorkGroupBlock.tsx, apps/desktop/src/renderer/components/chat/WorktreeRestoreBanner.tsx, apps/desktop/src/renderer/components/chat/XIcon.tsx, apps/desktop/src/renderer/components/chat/activityRowChrome.ts, apps/desktop/src/renderer/components/chat/autoFollowIntent.ts, apps/desktop/src/renderer/components/chat/errorReasonI18n.ts, apps/desktop/src/renderer/components/chat/imageDisplaySize.ts, apps/desktop/src/renderer/components/chat/lightboxAnnotations.ts, apps/desktop/src/renderer/components/chat/lightboxGestures.ts, apps/desktop/src/renderer/components/chat/markdownImageTargets.ts, apps/desktop/src/renderer/components/chat/messageNavRailModel.ts, apps/desktop/src/renderer/components/chat/messageNavRailWheel.ts, apps/desktop/src/renderer/components/chat/messageStreamIndicatorPosition.ts, apps/desktop/src/renderer/components/chat/rehypeFencedCodeMarker.ts, apps/desktop/src/renderer/components/chat/rehypeMathBlockMarker.ts, apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts, apps/desktop/src/renderer/components/chat/remarkHtmlImages.ts, apps/desktop/src/renderer/components/chat/remarkLocalPathLinks.ts, apps/desktop/src/renderer/components/chat/remarkPreserveRawLocalDestinations.ts, apps/desktop/src/renderer/components/chat/remarkSessionLinks.ts, apps/desktop/src/renderer/components/chat/remarkStrictInlineMath.ts, apps/desktop/src/renderer/components/chat/remarkTruncateCjkUrls.ts, apps/desktop/src/renderer/components/chat/repairStreamingMarkdown.ts, apps/desktop/src/renderer/components/chat/scrollAnchoringDetect.ts, apps/desktop/src/renderer/components/chat/shareSelectionStore.ts, apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts, apps/desktop/src/renderer/components/chat/toolLoopI18n.ts, apps/desktop/src/renderer/components/chat/unreadCount.ts, apps/desktop/src/renderer/components/chat/useAutomaticHistoryLoadBudget.ts, apps/desktop/src/renderer/components/chat/useCopyAsImage.tsx, apps/desktop/src/renderer/components/chat/useDeleteMessage.ts, apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx, apps/desktop/src/renderer/components/chat/useForkAtMessage.ts, apps/desktop/src/renderer/components/chat/useNavigationKeyListener.ts, apps/desktop/src/renderer/components/chat/useOpenWithMenu.tsx, apps/desktop/src/renderer/components/chat/usePrevUserMessageInView.ts, apps/desktop/src/renderer/components/chat/userMessageCollapse.ts, apps/desktop/src/renderer/components/chat/userMessageDisplayText.ts, apps/desktop/src/renderer/components/chat/userMessageLinkify.ts, apps/desktop/src/renderer/components/chat/viewportFillDetect.ts, apps/desktop/src/renderer/components/new-chat/AddRemoteProjectDialog.tsx, apps/desktop/src/renderer/components/new-chat/AgentSelect.tsx, apps/desktop/src/renderer/components/new-chat/AskUserQuestionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/AtMentionPanel.tsx, apps/desktop/src/renderer/components/new-chat/AttachmentTypeThumb.tsx, apps/desktop/src/renderer/components/new-chat/ChatInput.tsx, apps/desktop/src/renderer/components/new-chat/CjkPunctDecoration.ts, apps/desktop/src/renderer/components/new-chat/CjkPunctuationUtils.ts, apps/desktop/src/renderer/components/new-chat/ComposerListIndentDecoration.ts, apps/desktop/src/renderer/components/new-chat/ComposerListNodes.ts, apps/desktop/src/renderer/components/new-chat/ComposerQuoteNode.tsx, apps/desktop/src/renderer/components/new-chat/DeviceSwitcherPill.tsx, apps/desktop/src/renderer/components/new-chat/EffortSlider.tsx, apps/desktop/src/renderer/components/new-chat/EmptyDocSelectionGuard.ts, apps/desktop/src/renderer/components/new-chat/ExtraDirsButton.tsx, apps/desktop/src/renderer/components/new-chat/FastModeToggle.tsx, apps/desktop/src/renderer/components/new-chat/FolderPickerPopover.tsx, apps/desktop/src/renderer/components/new-chat/FullAccessConfirmContent.tsx, apps/desktop/src/renderer/components/new-chat/GhostCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/GoalAdvancedLimits.tsx, apps/desktop/src/renderer/components/new-chat/GoalIndicator.tsx, apps/desktop/src/renderer/components/new-chat/HomeUsageDashboard.tsx, apps/desktop/src/renderer/components/new-chat/ListComposerTextarea.tsx, apps/desktop/src/renderer/components/new-chat/MentionChipNode.tsx, apps/desktop/src/renderer/components/new-chat/MentionDragCaretDecoration.ts, apps/desktop/src/renderer/components/new-chat/ModelConfigFlyout.tsx, apps/desktop/src/renderer/components/new-chat/ModelSelector.tsx, apps/desktop/src/renderer/components/new-chat/NewGoalDialog.tsx, apps/desktop/src/renderer/components/new-chat/PastedTextChipNode.tsx, apps/desktop/src/renderer/components/new-chat/PendingQueuePanel.tsx, apps/desktop/src/renderer/components/new-chat/PermissionPrompt.tsx, apps/desktop/src/renderer/components/new-chat/PermissionSelector.tsx, apps/desktop/src/renderer/components/new-chat/PinnedPlanPanel.tsx, apps/desktop/src/renderer/components/new-chat/PlanActionCard.tsx, apps/desktop/src/renderer/components/new-chat/PlanModeIndicator.tsx, apps/desktop/src/renderer/components/new-chat/PlanViewerCard.tsx, apps/desktop/src/renderer/components/new-chat/PluginSetupPrompt.tsx, apps/desktop/src/renderer/components/new-chat/QuickStartPillMark.ts, apps/desktop/src/renderer/components/new-chat/SendButton.tsx, apps/desktop/src/renderer/components/new-chat/SlashCommandDecoration.ts, apps/desktop/src/renderer/components/new-chat/SlashCommandPalette.tsx, apps/desktop/src/renderer/components/new-chat/TakeoverMask.tsx, apps/desktop/src/renderer/components/new-chat/ThinkingToggle.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedFlyoutHost.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelPanel.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRail.tsx, apps/desktop/src/renderer/components/new-chat/UnifiedModelRow.tsx, apps/desktop/src/renderer/components/new-chat/UsageDailyBars.tsx, apps/desktop/src/renderer/components/new-chat/UsageHeatmap.tsx, apps/desktop/src/renderer/components/new-chat/VendorSegmentedSwitcher.tsx, apps/desktop/src/renderer/components/new-chat/VoiceInputDraftDecoration.ts, apps/desktop/src/renderer/components/new-chat/WindowsSelectionReplacement.ts, apps/desktop/src/renderer/components/new-chat/WorktreeChipsRow.tsx, apps/desktop/src/renderer/components/new-chat/WorktreeCreatingOverlay.tsx, apps/desktop/src/renderer/components/new-chat/agentOptions.ts, apps/desktop/src/renderer/components/new-chat/agentSwitchConfirmation.ts, apps/desktop/src/renderer/components/new-chat/branchPick.ts, apps/desktop/src/renderer/components/new-chat/composerBlankPointerFocus.ts, apps/desktop/src/renderer/components/new-chat/composerContentSerialization.ts, apps/desktop/src/renderer/components/new-chat/composerDocState.ts, apps/desktop/src/renderer/components/new-chat/composerFrameScheduler.ts, apps/desktop/src/renderer/components/new-chat/composerHistoryProjection.ts, apps/desktop/src/renderer/components/new-chat/composerPromptRecommendation.ts, apps/desktop/src/renderer/components/new-chat/composerRenderGate.ts, apps/desktop/src/renderer/components/new-chat/composerSendOwnership.ts, apps/desktop/src/renderer/components/new-chat/composerSendSnapshot.ts, apps/desktop/src/renderer/components/new-chat/effortChangeQueue.ts, apps/desktop/src/renderer/components/new-chat/extraDirsActions.ts, apps/desktop/src/renderer/components/new-chat/ghostComposerPlacement.ts, apps/desktop/src/renderer/components/new-chat/localAttachmentPicker.ts, apps/desktop/src/renderer/components/new-chat/mentionChipInsertion.ts, apps/desktop/src/renderer/components/new-chat/pastePipeline.ts, apps/desktop/src/renderer/components/new-chat/pathPaste.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueLocks.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowActivity.ts, apps/desktop/src/renderer/components/new-chat/pendingQueueRowPresentation.ts, apps/desktop/src/renderer/components/new-chat/planModeComposerCommand.ts, apps/desktop/src/renderer/components/new-chat/remoteBrowseAdapters.ts, apps/desktop/src/renderer/components/new-chat/remoteExistingProjects.ts, apps/desktop/src/renderer/components/new-chat/sessionLinkPaste.ts, apps/desktop/src/renderer/components/new-chat/sourceSwitch.ts, apps/desktop/src/renderer/components/new-chat/unifiedModelSelection.ts, apps/desktop/src/renderer/components/new-chat/usagePalette.ts, apps/desktop/src/renderer/components/new-chat/useComposerSendFocusRestore.ts, apps/desktop/src/renderer/components/new-chat/useModelDiscoveryPending.ts, apps/desktop/src/renderer/components/new-chat/useUnifiedRowActions.ts, apps/desktop/src/renderer/features/cc-agent/CCAgentFeatureLayout.tsx, apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx | 266 | 56 | 412 | | `desktop.dev.maker-experimental` | desktop | Maker 实验诊断页 | hash `/maker-experimental`(MakerExperimentalView;EXPERIMENTAL_FEATURES 现为空,仍是生产路由) | MakerExperimentalView | apps/desktop/src/renderer/features/maker-experimental/MakerExperimentalView.tsx | 1 | 39 | 11 | | `desktop.issues.guide` | desktop | Issue 引导页 | hash `/issues`(IssueTrackerFeatureLayout,已迁 GitHub 后的引导页) | IssueTrackerFeatureLayout | apps/desktop/src/renderer/features/issue-tracker/IssueTrackerFeatureLayout.tsx, apps/desktop/src/renderer/features/issue-tracker/MyIssueList.tsx, apps/desktop/src/renderer/features/issue-tracker/hooks/useMyIssues.ts, apps/desktop/src/renderer/features/issue-tracker/lib/myIssuesNotices.ts, apps/desktop/src/renderer/features/issue-tracker/lib/startIssueChat.ts | 0 | 0 | 8 | | `desktop.native.app-menu` | desktop | 应用菜单 | Menu.setApplicationMenu(用户可见出口,不展开业务逻辑) | applicationMenuLabels | apps/desktop/src/main/applicationMenuLabels.ts | 0 | 0 | 0 | @@ -37,7 +37,7 @@ | `desktop.overlay.toast` | desktop | Toast | App 常驻 ToastContainer(用户可见出口,不展开业务逻辑) | Toast, ToastContainer | apps/desktop/src/renderer/components/ui/toast/Toast.tsx, apps/desktop/src/renderer/components/ui/toast/ToastContainer.tsx, apps/desktop/src/renderer/components/ui/toast/index.ts | 4 | 4 | 2 | | `desktop.plugins.app-main` | desktop | 插件主视图 | hash `/apps/:ghostId`(GhostMainViewFeatureLayout) | GhostMainViewFeatureLayout, GhostMainViewHost | apps/desktop/src/renderer/features/plugin/GhostMainViewFeatureLayout.tsx, apps/desktop/src/renderer/features/plugin/GhostMainViewHost.tsx | 0 | 0 | 0 | | `desktop.plugins.installed` | desktop | 已装插件 | hash `/plugins`(GhostPluginPage) | AddMarketplaceDialog, GhostPluginDetailView, GhostPluginPage, MarketPluginDetailView, MyPublishesSection, PluginManagementLayout, PluginScopePicker, UpdateAllDialog | apps/desktop/src/renderer/features/plugin/AddMarketplaceDialog.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx, apps/desktop/src/renderer/features/plugin/MarketPluginDetailView.tsx, apps/desktop/src/renderer/features/plugin/MyPublishesSection.tsx, apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/plugin/PluginScopePicker.tsx, apps/desktop/src/renderer/features/plugin/UpdateAllDialog.tsx, apps/desktop/src/renderer/features/plugin/plugin-motion.css | 49 | 0 | 81 | -| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 143 | 121 | 722 | +| `desktop.settings` | desktop | 设置 | hash `/settings`(SettingsView;tab 含 general / personalization / providers / billing / usage / voice-input / im-bot / shortcuts / agent-island / import / remote-control / ghosts / builtin-tools / computer-use / help / about) | SettingsView | apps/desktop/src/renderer/components/settings/AboutSection.tsx, apps/desktop/src/renderer/components/settings/AccountDeletionSection.tsx, apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx, apps/desktop/src/renderer/components/settings/AgentIslandSection.tsx, apps/desktop/src/renderer/components/settings/AgentResourceSection.tsx, apps/desktop/src/renderer/components/settings/AppearanceSection.tsx, apps/desktop/src/renderer/components/settings/AuxiliaryModelSection.tsx, apps/desktop/src/renderer/components/settings/BetaChannelCell.tsx, apps/desktop/src/renderer/components/settings/BrowserBackendSubsection.tsx, apps/desktop/src/renderer/components/settings/BrowserRealProfileSubsection.tsx, apps/desktop/src/renderer/components/settings/BuiltinToolsSection.tsx, apps/desktop/src/renderer/components/settings/ChatEmbeddingCell.tsx, apps/desktop/src/renderer/components/settings/ChipMetricsSection.tsx, apps/desktop/src/renderer/components/settings/CollaborationSection.tsx, apps/desktop/src/renderer/components/settings/CompactionSection.tsx, apps/desktop/src/renderer/components/settings/ComposerSendShortcutSection.tsx, apps/desktop/src/renderer/components/settings/ComputerPermissionGuideWindow.tsx, apps/desktop/src/renderer/components/settings/ComputerUseSection.tsx, apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx, apps/desktop/src/renderer/components/settings/CustomProviderRuntimeFillOverlay.tsx, apps/desktop/src/renderer/components/settings/DefaultOverrideControls.tsx, apps/desktop/src/renderer/components/settings/DingTalkBotSection.tsx, apps/desktop/src/renderer/components/settings/DiscordBotSection.tsx, apps/desktop/src/renderer/components/settings/DownloadMeter.tsx, apps/desktop/src/renderer/components/settings/ExperimentalSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotNotificationSection.tsx, apps/desktop/src/renderer/components/settings/FeishuBotSection.tsx, apps/desktop/src/renderer/components/settings/FontFamilyPicker.tsx, apps/desktop/src/renderer/components/settings/GenericGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/GitSafetySection.tsx, apps/desktop/src/renderer/components/settings/HelpAssistantPanel.tsx, apps/desktop/src/renderer/components/settings/HelpSection.tsx, apps/desktop/src/renderer/components/settings/HelpThreadView.tsx, apps/desktop/src/renderer/components/settings/HookConnectionsSection.tsx, apps/desktop/src/renderer/components/settings/HookWorkspacePrefsEditor.tsx, apps/desktop/src/renderer/components/settings/ImBotSection.tsx, apps/desktop/src/renderer/components/settings/ImChannelSettingsCard.tsx, apps/desktop/src/renderer/components/settings/ImDefaultSettingsSection.tsx, apps/desktop/src/renderer/components/settings/ImLifecycleAnnouncementSection.tsx, apps/desktop/src/renderer/components/settings/InputDeviceConnectionStatus.tsx, apps/desktop/src/renderer/components/settings/JoyConGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/KeyboardShortcutsSection.tsx, apps/desktop/src/renderer/components/settings/LanguageSection.tsx, apps/desktop/src/renderer/components/settings/LayoutResetControl.tsx, apps/desktop/src/renderer/components/settings/LinkOpenSection.tsx, apps/desktop/src/renderer/components/settings/LocalOllamaInstall.tsx, apps/desktop/src/renderer/components/settings/LocalPackagingTag.tsx, apps/desktop/src/renderer/components/settings/LogoutSection.tsx, apps/desktop/src/renderer/components/settings/LspBetaCell.tsx, apps/desktop/src/renderer/components/settings/McpServerDialog.tsx, apps/desktop/src/renderer/components/settings/McpServersSection.tsx, apps/desktop/src/renderer/components/settings/MemorySection.tsx, apps/desktop/src/renderer/components/settings/MessageNavRailCell.tsx, apps/desktop/src/renderer/components/settings/ModelPriceOverrideDialog.tsx, apps/desktop/src/renderer/components/settings/MyDevicesPanel.tsx, apps/desktop/src/renderer/components/settings/NotificationSection.tsx, apps/desktop/src/renderer/components/settings/OAuthDeviceCodeCard.tsx, apps/desktop/src/renderer/components/settings/OllamaProviderDetail.tsx, apps/desktop/src/renderer/components/settings/PiPackagesSection.tsx, apps/desktop/src/renderer/components/settings/PlayStationGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/ProfileEditDialog.tsx, apps/desktop/src/renderer/components/settings/ProvidersSection.tsx, apps/desktop/src/renderer/components/settings/RemoteControlSection.tsx, apps/desktop/src/renderer/components/settings/RemoteHostDetail.tsx, apps/desktop/src/renderer/components/settings/RemoteSection.tsx, apps/desktop/src/renderer/components/settings/SessionImportSection.tsx, apps/desktop/src/renderer/components/settings/SessionRuntimeFallbackCell.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/settings/SettingsCatalogPanel.tsx, apps/desktop/src/renderer/components/settings/SettingsSidebarNav.tsx, apps/desktop/src/renderer/components/settings/SettingsTextInput.tsx, apps/desktop/src/renderer/components/settings/SettingsView.tsx, apps/desktop/src/renderer/components/settings/SilentEncryptedRetryCell.tsx, apps/desktop/src/renderer/components/settings/SshKeySetupDialog.tsx, apps/desktop/src/renderer/components/settings/StorageManagementCard.tsx, apps/desktop/src/renderer/components/settings/StreamFadeSection.tsx, apps/desktop/src/renderer/components/settings/SubagentModelSection.tsx, apps/desktop/src/renderer/components/settings/SwitchProGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/TelegramBehaviorSettings.tsx, apps/desktop/src/renderer/components/settings/TelegramBotSection.tsx, apps/desktop/src/renderer/components/settings/TelegramRemoteDevices.tsx, apps/desktop/src/renderer/components/settings/TerminalShellSection.tsx, apps/desktop/src/renderer/components/settings/TipsSection.tsx, apps/desktop/src/renderer/components/settings/UnifiedModelList.tsx, apps/desktop/src/renderer/components/settings/UserProfileCard.tsx, apps/desktop/src/renderer/components/settings/UserPromptSection.tsx, apps/desktop/src/renderer/components/settings/VisionBridgeSection.tsx, apps/desktop/src/renderer/components/settings/VoiceInputSection.tsx, apps/desktop/src/renderer/components/settings/WechatBotSection.tsx, apps/desktop/src/renderer/components/settings/WecomBotSection.tsx, apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeyboardLayout.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexKeycapGlyphs.tsx, apps/desktop/src/renderer/components/settings/WorkLouderCodexSettings.tsx, apps/desktop/src/renderer/components/settings/XUsageGuide.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadLayout.tsx, apps/desktop/src/renderer/components/settings/XboxGamepadSettings.tsx, apps/desktop/src/renderer/components/settings/androidStatusPresentation.ts, apps/desktop/src/renderer/components/settings/billingVisibility.ts, apps/desktop/src/renderer/components/settings/browserOpenForLoginError.ts, apps/desktop/src/renderer/components/settings/computerPermissionFlow.ts, apps/desktop/src/renderer/components/settings/contacts/ContactDetailPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsImportDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsListPane.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsManagerDialog.tsx, apps/desktop/src/renderer/components/settings/contacts/ContactsSection.tsx, apps/desktop/src/renderer/components/settings/contacts/startContactsAiSession.ts, apps/desktop/src/renderer/components/settings/dualSenseSilhouette.ts, apps/desktop/src/renderer/components/settings/feishuBotPresentation.ts, apps/desktop/src/renderer/components/settings/fontFamilyValue.ts, apps/desktop/src/renderer/components/settings/gamepadLayoutPrimitives.tsx, apps/desktop/src/renderer/components/settings/gamepadSilhouetteGeom.ts, apps/desktop/src/renderer/components/settings/hookWorkspacePrefsLogic.ts, apps/desktop/src/renderer/components/settings/imBotVisibility.ts, apps/desktop/src/renderer/components/settings/imDefaultSettingsLogic.ts, apps/desktop/src/renderer/components/settings/joyConSilhouette.ts, apps/desktop/src/renderer/components/settings/myDevicesModel.ts, apps/desktop/src/renderer/components/settings/providerAssetModule.ts, apps/desktop/src/renderer/components/settings/realProfilePermissionGuide.ts, apps/desktop/src/renderer/components/settings/switchProSilhouette.ts, apps/desktop/src/renderer/components/settings/ultimateC1Silhouette.ts, apps/desktop/src/renderer/components/settings/usage/UsageBreakdownTables.tsx, apps/desktop/src/renderer/components/settings/usage/UsageHistorySection.tsx, apps/desktop/src/renderer/components/settings/usage/UsageStatRow.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTaskTable.tsx, apps/desktop/src/renderer/components/settings/usage/UsageTokenBars.tsx, apps/desktop/src/renderer/components/settings/usage/formatUsagePercent.ts, apps/desktop/src/renderer/components/settings/usage/usageHistoryStats.ts, apps/desktop/src/renderer/components/settings/usageVisibility.ts, apps/desktop/src/renderer/components/settings/wizardRecommend.ts, apps/desktop/src/renderer/components/settings/workLouderCodexCommandCopy.ts, apps/desktop/src/renderer/components/settings/xboxSeriesSilhouette.ts, apps/desktop/src/renderer/styles/sortable.css | 143 | 121 | 729 | | `desktop.shell.main-layout` | desktop | 主窗口壳(标题栏 / 左右栏 / 内容区) | main BrowserWindow → renderer/index.tsx → main-entry.tsx → App → MainLayout (`#/` 受保护壳) | ChromeActions, FeishuConflictDialogHost, GhostMediaLightboxHost, MainLayout, RightSidebar, RightSidebarShell, SessionShareImportWizard, Sidebar, UpdateNoticeDialog, WindowControls | apps/desktop/src/renderer/cindy-brain/GhostMediaLightboxHost.tsx, apps/desktop/src/renderer/components/UpdateNoticeDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialog.tsx, apps/desktop/src/renderer/components/feishuBot/FeishuConflictDialogHost.tsx, apps/desktop/src/renderer/components/layout/BrowserWebviewPool.tsx, apps/desktop/src/renderer/components/layout/ChromeActions.tsx, apps/desktop/src/renderer/components/layout/ContentHeader.tsx, apps/desktop/src/renderer/components/layout/CredentialStoreBanner.tsx, apps/desktop/src/renderer/components/layout/FadeSwitcher.tsx, apps/desktop/src/renderer/components/layout/GhostPanelWindowLayout.tsx, apps/desktop/src/renderer/components/layout/GlobalDropImportListener.tsx, apps/desktop/src/renderer/components/layout/MainLayout.tsx, apps/desktop/src/renderer/components/layout/RightSidebar.tsx, apps/desktop/src/renderer/components/layout/RightSidebarDetach.tsx, apps/desktop/src/renderer/components/layout/RightSidebarMaximize.tsx, apps/desktop/src/renderer/components/layout/RightSidebarToggle.tsx, apps/desktop/src/renderer/components/layout/SidebarWindowLayout.tsx, apps/desktop/src/renderer/components/layout/chromeActionsGeometry.ts, apps/desktop/src/renderer/components/layout/chromeActionsLayout.ts, apps/desktop/src/renderer/components/layout/railChromeActions.ts, apps/desktop/src/renderer/components/layout/windowDrag.tsx, apps/desktop/src/renderer/components/settings/SessionShareImportWizard.tsx, apps/desktop/src/renderer/components/sidebar/AccountSwitcherDialog.tsx, apps/desktop/src/renderer/components/sidebar/AttentionDot.tsx, apps/desktop/src/renderer/components/sidebar/GhostMainViewNavEntries.tsx, apps/desktop/src/renderer/components/sidebar/MobileDownloadDialog.tsx, apps/desktop/src/renderer/components/sidebar/Sidebar.tsx, apps/desktop/src/renderer/components/sidebar/SidebarIconButton.tsx, apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx, apps/desktop/src/renderer/components/sidebar/SortableList.tsx, apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx, apps/desktop/src/renderer/components/sidebar/UserInfoSection.tsx, apps/desktop/src/renderer/components/sidebar/VendorIcon.tsx, apps/desktop/src/renderer/components/sidebar/VendorReadinessBadge.tsx, apps/desktop/src/renderer/components/sidebar/WorktreeBadge.tsx, apps/desktop/src/renderer/components/title-bar/ChromeIconButton.tsx, apps/desktop/src/renderer/components/title-bar/MenuButton.tsx, apps/desktop/src/renderer/components/title-bar/WindowControls.tsx, apps/desktop/src/renderer/features/right-sidebar/AddTabDropdown.tsx, apps/desktop/src/renderer/features/right-sidebar/EmptyState.tsx, apps/desktop/src/renderer/features/right-sidebar/RightSidebarShell.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBar.tsx, apps/desktop/src/renderer/features/right-sidebar/TabBodyErrorBoundary.tsx, apps/desktop/src/renderer/features/right-sidebar/hooks/useBrowserWebview.ts, apps/desktop/src/renderer/features/right-sidebar/hooks/useNativePopupSurface.ts, apps/desktop/src/renderer/features/right-sidebar/iosSimulatorPluginAvailability.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserPartition.ts, apps/desktop/src/renderer/features/right-sidebar/lib/browserWebviewPool.ts, apps/desktop/src/renderer/features/right-sidebar/lib/detachedSidebarRouting.ts, apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts, apps/desktop/src/renderer/features/right-sidebar/lib/iosSimulatorFocusBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/lastTurnChangedFiles.ts, apps/desktop/src/renderer/features/right-sidebar/lib/nativePopupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openBackgroundTasksTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openInSidebarFileBrowser.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openSubagentsTab.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTerminalShortcut.ts, apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupRouter.ts, apps/desktop/src/renderer/features/right-sidebar/lib/popupTabs.ts, apps/desktop/src/renderer/features/right-sidebar/lib/rsbBrowserBridge.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarCommands.ts, apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/BackgroundTasksBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowAgentStrip.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/WorkflowProgressTree.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/chatTaskFocusIntent.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/workflowProgressModel.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.css, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/FileBrowserBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/dropExternalFile.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/fileTreeImagePreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/file-browser/useSessionScopedTreeWidth.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/index.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorInstanceGrid.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/IOSSimulatorTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/ios-simulator/iosSimulatorH264Decoder.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersAttentionIcon.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/OrcaWorkersTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/actions.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/closeDecision.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/ResourceUsageBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/resource-usage.css, apps/desktop/src/renderer/features/right-sidebar/plugins/resource-usage/subscription.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/ImageDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/MarkdownDiffPreview.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/PlainUnifiedDiff.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/diffRows.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/highlight.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/inlineDiff.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/DiffViewer/useDiffHighlights.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/diffExpansionPreference.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/fileTree.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/gitApplyCommand.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/review/markdownPreview.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useLastTurnFilter.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewGitState.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/review/useReviewSource.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/ConversationStream.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/DetailView.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/RunList.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentToolCard.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/SubagentsBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentChangeFence.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentConversation.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/subagents/subagentFormat.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/TerminalTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/terminal/lib/xtermPool.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserChrome.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserCommentPopover.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/BrowserTabBody.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/browserCommentEditorDraft.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/index.tsx, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/lib/parseOmnibox.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useBrowserComment.ts, apps/desktop/src/renderer/features/right-sidebar/plugins/web-browser/useLocalHtmlAutoReload.ts, apps/desktop/src/renderer/features/right-sidebar/registry.ts, apps/desktop/src/renderer/features/right-sidebar/store.ts, apps/desktop/src/renderer/features/right-sidebar/types.ts, apps/desktop/src/renderer/layout/LayoutRoot.tsx, apps/desktop/src/renderer/layout/PanelDragController.tsx, apps/desktop/src/renderer/layout/PanelDragPrototype.tsx, apps/desktop/src/renderer/layout/collapsePrefs.ts, apps/desktop/src/renderer/layout/layoutDevTools.ts, apps/desktop/src/renderer/layout/panePlacement.tsx, apps/desktop/src/renderer/layout/paneWidths.tsx, apps/desktop/src/renderer/layout/panelMaximize.tsx, apps/desktop/src/renderer/styles/globals.css | 145 | 72 | 288 | | `desktop.skillhub.local` | desktop | SkillHub 本地技能 | hash `/skillhub/local` 及详情 `/skillhub/local/:kind/global/:name`、`/skillhub/local/:kind/project/:projectHash/:name` | InstallTargetPicker, PluginManagementLayout, SkillhubDetailView, SkillhubFeatureLayout, SkillhubHomeView, SkillhubMarketPreviewPanel | apps/desktop/src/renderer/features/plugin/PluginManagementLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubDetailView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubFeatureLayout.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubHomeView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx | 41 | 0 | 71 | | `desktop.skillhub.market` | desktop | SkillHub 市场 | hash `/skillhub/market`(SkillhubMarketListView) | InstallTargetPicker, MarketCard, MarketInfoEditDialog, SkillhubMarketListView, SkillhubMarketPreviewPanel, VisibilityEditorDialog | apps/desktop/src/renderer/features/skillhub/SkillhubMarketListView.tsx, apps/desktop/src/renderer/features/skillhub/SkillhubMarketPreviewPanel.tsx, apps/desktop/src/renderer/features/skillhub/components/InstallTargetPicker.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketCard.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketInfoEditDialog.tsx, apps/desktop/src/renderer/features/skillhub/components/MarketPreviewTree.tsx, apps/desktop/src/renderer/features/skillhub/components/PlatformTagSelector.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillCategoryFilterBar.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillIcon.tsx, apps/desktop/src/renderer/features/skillhub/components/SkillTagList.tsx, apps/desktop/src/renderer/features/skillhub/components/TeamScopePicker.tsx, apps/desktop/src/renderer/features/skillhub/components/VisibilityEditorDialog.tsx | 46 | 0 | 59 |