From 02ee9b9bb3cb5ba4cb5ed78d3b8dff9a502e3a64 Mon Sep 17 00:00:00 2001 From: limit_yan Date: Mon, 11 May 2026 17:07:42 +0800 Subject: [PATCH 1/3] feat(usage): persist runtime report facts --- .../session-runtime-usage-report-design.md | 250 +++++-- .../src/agentic/coordination/coordinator.rs | 28 + .../src/agentic/session/session_manager.rs | 20 + src/crates/core/src/service/session/types.rs | 169 ++++- .../core/src/service/session_usage/render.rs | 44 +- .../core/src/service/session_usage/service.rs | 701 ++++++++++++++++-- .../core/src/service/session_usage/types.rs | 4 + .../services/AgenticEventListener.ts | 10 + .../src/flow_chat/services/EventBatcher.ts | 14 + .../flow-chat-manager/EventHandlerModule.ts | 66 ++ .../flow-chat-manager/ToolEventModule.ts | 27 +- .../services/usageReportService.test.ts | 156 +++- .../flow_chat/services/usageReportService.ts | 146 +++- .../src/flow_chat/store/FlowChatStore.test.ts | 40 +- .../src/flow_chat/store/FlowChatStore.ts | 66 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 15 + .../api/service-api/AgentAPI.ts | 20 + .../api/service-api/SessionAPI.ts | 6 + .../src/shared/types/session-history.ts | 14 + 19 files changed, 1616 insertions(+), 180 deletions(-) diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md index 0f8e27a4d..6073cb2d8 100644 --- a/docs/features/session-runtime-usage-report-design.md +++ b/docs/features/session-runtime-usage-report-design.md @@ -1,7 +1,7 @@ # Session Runtime Usage Report Design -> Status: implemented through the session runtime usage report milestones; release-hardening review updated 2026-05-10 -> Scope: `/usage`, Desktop Flow Chat usage reports, CLI usage reports, session runtime metrics, responsive status entry +> Status: P0 and P1 implemented; P2 Desktop analysis surface partially implemented and under hardening review as of 2026-05-11 +> Scope: `/usage`, Desktop Flow Chat usage reports, CLI usage reports, session runtime metrics, Chat-bottom usage entry > Non-goal: this document does not prescribe exact code edits or final UI copy. ## Background @@ -31,7 +31,7 @@ BitFun should support a Claude-like `/usage` command and a richer Desktop runtim The key product decision is: -> `/usage` creates a durable, readable session report in the current conversation, while the top status bar remains a compact entry point that can generate the same report. +> `/usage` creates a durable, readable session report in the current conversation, while Desktop also exposes the same action from one compact button in the Chat input footer. The implemented report is intentionally a persisted-data report. It does not claim pure model streaming throughput, first-token latency, token-per-second speed, monetary cost, or live per-model timing unless a future runtime span contract provides those fields. @@ -44,7 +44,7 @@ Verdict: the direction is reasonable, but the current draft needs a few product - Use one structured report contract and surface-specific renderers instead of separate CLI/Desktop counters. - Store Desktop `/usage` as user-visible but model-invisible local output. - Start with existing persisted data, then improve accuracy with additive runtime spans. -- Exclude monetary cost estimates, charts, and cross-session analytics until they have separate product ownership; keep the header as a compact entry point backed by the same session-level contract. +- Exclude monetary cost estimates, charts, and cross-session analytics until they have separate product ownership; keep the Chat-bottom action as a compact entry point backed by the same session-level contract. The main remaining risk is not that the feature is too ambitious. The risk is that "usage reporting" accidentally becomes a second runtime control plane for budget, scheduler, retry, artifact, or context mutation behavior. The report should be an observability projection. It may consume runtime facts, but it must not decide scheduling, retries, context compaction, permissions, or cost governance. @@ -146,9 +146,9 @@ Current implemented Markdown shape: The detailed visual report exists alongside the Markdown snapshot. It uses the structured DTO when present and falls back to the Markdown snapshot for historical/local-only reports. -### Desktop Header Status Entry +### Desktop Chat-Bottom Usage Entry -The implemented Flow Chat header shows a compact usage entry button that generates `/usage` in the current chat. It intentionally avoids live timing shares until runtime span data is reliable enough. +The implemented Flow Chat entry is a compact action in the Chat input footer (`ChatInputWorkspaceStrip`) that generates `/usage` in the current chat. It intentionally avoids title/header placement and live timing shares until runtime span data is reliable enough. Possible future live states: @@ -159,7 +159,7 @@ Possible future live states: | Narrow | small clock/activity icon + `18m` | | Very narrow | icon only, tooltip contains the summary | -The header should never push the current turn title, search box, model selector, or file badge into overlap. If future live values are added, lower-priority segments must hide before shrinking text beyond legibility. +The Chat-bottom entry should never compete with the title/header row. It must preserve the input footer's workspace/branch controls, model selector, and send affordances. If future live values are added, lower-priority segments must hide before shrinking text beyond legibility. ## Current Reusable Capabilities @@ -177,7 +177,7 @@ BitFun can reuse several existing surfaces. | CLI slash command handling | `src/apps/cli/src/modes/chat.rs` | | CLI session/tool persistence | `src/apps/cli/src/session.rs`, `src/apps/cli/src/agent/core_adapter.rs` | | Desktop token/compression event routing | `src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts` | -| Flow Chat header entry point | `src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx` | +| Flow Chat Chat-bottom usage entry | `src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx` | | Session file badge and diff affordances | `src/web-ui/src/flow_chat/components/modern/{SessionFilesBadge.tsx,SessionFileModificationsBar.tsx}` | | Operation-level file diff and summary entry | `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` | @@ -210,7 +210,7 @@ It also provides summary aggregation by model and session. - Flow Chat already listens to token usage and context compression events. - Context compression is already rendered as a tool-like card. - `SessionFilesBadge`, `SessionFileModificationsBar`, and file operation tool cards already connect chat with file diffs. -- The Flow Chat header already hosts compact session-level controls. +- The Chat input footer already hosts compact session-level controls, including workspace/branch context and the usage action. ### Existing CLI surfaces @@ -218,7 +218,28 @@ It also provides summary aggregation by model and session. - `/history` already shows basic session statistics. - CLI session messages and tool cards already persist tool call count and tool duration. -## Missing Capabilities and Required Logic Changes +## Original Gaps and Current Implementation Status + +The subsections below preserve the original gap analysis so later reviews can +see why each work item existed. The current code review on 2026-05-11 checked +the plan against `origin/main..HEAD` and found P0/P1 implemented, with P2 +partially implemented. "Done" means code and automated coverage exist in this +branch; "Partial" means the foundation exists, but the remaining work below +still needs product or technical signoff before the item should be considered +complete. + +| Area | Current status | Code evidence | Remaining work | +| --- | --- | --- | --- | +| Shared report service | Done | `src/crates/core/src/service/session_usage/{service.rs,types.rs,render.rs}` and `SessionAPI.getSessionUsageReport` | Keep the API contract stable while adding future analytics. | +| Durable local report message | Done | `DialogTurnKind::LocalCommand`, `localCommandKind: 'usage_report'`, `modelVisible: false` | Keep usage report snapshots model-invisible through future history, export, and transcript changes. | +| CLI `/usage` coverage | Done for interactive CLI | CLI `usage_*` coverage and the shared renderer | Top-level `bitfun usage --session` remains deferred. | +| Model timing | Mostly done | Optional event and persisted fields for duration, provider/model identity, first chunk, visible output, stream duration, attempts, failure category, and token details | Throughput/TPS and provider-latency claims stay deferred until semantics are stable enough to avoid misleading users. | +| Tool phase timing/classification | Mostly done | Optional terminal tool duration fields and `session_usage::classifier` coverage | Scheduler, budget, and backoff facts still depend on owning modules emitting typed facts. | +| File correlation and diff links | Partial | Snapshot summaries, `UsageFileRow.operationIds`, and `SessionUsagePanel` diff actions | Transcript/tool-card anchor links and long-session row virtualization remain follow-up work. | +| Token-only cost boundary | Done | Token-only locale guard coverage and no monetary DTO fields | Future billing or cost analytics must remain a separate feature surface. | +| i18n and theme | Mostly done | Flow Chat locale alignment coverage, semantic style guard coverage, and quick-action localization coverage | Manual light/dark screenshot, keyboard, and accessibility pass is still needed before final UX signoff. | +| Scope and workspace identity | Done for current session | Request carries workspace path/remote identity and DTO exposes `UsageWorkspace` | Hidden subagent and visible side-session aggregation remain open decisions. | +| Redaction/export policy | Partial | Bounded labels, privacy flags, workspace-relative display, and copied metadata | Exact exported path redaction rules for local and remote paths still need a dedicated policy. | ### 1. Shared session usage report service @@ -583,10 +604,10 @@ Exact field names can change during implementation. The important boundary is th - If `/usage` is invoked during an active turn, the implementation must either reject it with a friendly local-only message or insert a clearly marked in-progress snapshot without touching the active model/tool state. - A standard session report should not silently include visible side sessions. Hidden subagent inclusion requires reliable parent linkage and an explicit scope marker. -### Header status entry +### Chat-bottom usage entry -- The header status entry is a convenience, not the source of truth. -- It should show only the most useful live metric at constrained widths. +- The Chat-bottom usage entry is a convenience, not the source of truth. +- It should remain action-only at constrained widths; live metrics belong to a later design. - It must not introduce horizontal overflow. - It must support keyboard focus, screen-reader labels, and tooltip summaries. - It should hide itself automatically when no active session or no reportable data exists. @@ -598,16 +619,28 @@ The panel is not required for the first `/usage` milestone, but the data shape s Recommended tabs: - Overview -- Timeline - Models - Tools - Files - Errors +- Slowest + +Timeline remains a future option. Current P2 uses the Slowest tab plus +turn-level navigation because stable transcript/tool-card anchors are not yet +available for every row type. ## Milestone Execution Plan Implementation should be delivered through at most three mergeable milestones. Each milestone must leave CLI, Desktop, and existing session replay behavior usable, even if later metrics are still partial. The detailed tasks below remain the task inventory; this section defines the delivery order, release gates, and rollback boundaries. +Current milestone progress, verified against the current branch on 2026-05-11: + +| Milestone | Status | Current evidence | Remaining work | +| --- | --- | --- | --- | +| P0: Safe `/usage` foundation | Complete | Shared report service/renderers, model-invisible local report items, CLI interactive `/usage`, Desktop report cards, repeated-report exclusion, and old-session/cache-unavailable fixtures are covered by Rust and Web UI tests. | Keep future changes within the same local-only and model-invisible contract. | +| P1: Runtime spans and file correlation | Complete for the approved P1 scope | Optional model/tool runtime facts persist and aggregate; local usage reports are excluded from the next report span; snapshot-backed and recognized tool-input file rows are surfaced; missing model identity uses legacy-session copy instead of implementation labels. | Scheduler/budget/context/artifact facts remain projections only when their owning modules emit typed facts. Hidden subagents remain excluded by default. | +| P2: Desktop analysis surface | Partial | Chat-bottom entry, detail panel tabs, copyable metadata, file diff actions, slow-span turn jumps, i18n coverage, and semantic style tests are present. | Stable transcript/tool-card anchors, long-session caps or virtualization, manual light/dark/accessibility checks, and exported path redaction policy still need completion. | + ### Milestone P0: Safe `/usage` Foundation Goal: ship a Claude-like `/usage` command that is useful with existing data only and cannot affect model execution. @@ -649,7 +682,7 @@ Risk and drift controls: | CLI and Desktop diverge | Both call the same report service and only differ at renderer/adaptor boundaries | Same fixture produces different counts | | Workspace/session id ambiguity | Require workspace identity in report API requests and service lookup | Same session id can read data from another workspace | | Cached token metric is fake | Add `cached_tokens` coverage and hide/unavailable-state when only zero-filled records exist | Report labels cached tokens as known while source cannot measure them | -| Running `/usage` disturbs active turn | Reject during active turn or insert only a local point-in-time item outside the active model round | `/usage` changes queued input, model state, or active turn persistence | +| Running `/usage` disturbs active turn | Reject during active turn or insert only a local point-in-time item outside the active model round; exclude local usage-report turns from aggregation and session activity ordering | `/usage` changes queued input, model state, active turn persistence, report scope, or session recency | Required verification before merging P0: @@ -661,13 +694,21 @@ Required verification before merging P0: - `pnpm --dir src/web-ui run test:run` - Manual or automated proof that Desktop `/usage` does not call the model send path. - Fixture proof that cache-unavailable, remote-snapshot-unavailable, and old-session reports render as partial rather than zero/empty success. -- Regression proof that repeated `/usage` creates separate historical snapshots, not a mutable transcript entry. +- Regression proof that repeated `/usage` creates separate historical snapshots while the previous report is excluded from the next report's scope and timing. Rollback boundary: - P0 can be disabled by hiding `/usage` command registration in CLI/Desktop while leaving DTO and read-only service code in place. - The local report item type must remain backward-compatible once persisted; if it needs removal, migrate it as a generic local system/report item instead of deleting session records. +P0 implementation status (2026-05-11): complete. The current branch has the +shared DTO/service/renderer path, interactive CLI `/usage`, Desktop local +report card insertion, and regression coverage for old sessions, unavailable +cache fields, repeated usage reports, workspace identity, and model-invisible +local report turns. The original rollback boundary still applies: disable the +command entry points first if product rollback is needed, and keep persisted +local command records backward-compatible. + ### Milestone P1: Accurate Runtime Spans and File Correlation Goal: make P0 reports more accurate by enriching runtime span data and linking file-change summaries without changing tool/model behavior. @@ -718,26 +759,97 @@ Rollback boundary: - P1 can be rolled back by ignoring new span fields in aggregation while leaving additive event fields in place. - If an event field proves risky, keep the DTO coverage key and revert only the producer path, so `/usage` continues to work with P0 data. +Executable implementation plan: + +P1 must move `/usage` from P0 approximation toward factual runtime accounting without changing the report command contract. The implementation order below is test-first and split by ownership boundary so each step can be reviewed independently. + +1. Persist runtime facts already owned by the runtime. + - Files: `src/crates/core/src/service/session/types.rs`, `src/crates/core/src/agentic/session/session_manager.rs`, `src/crates/core/src/agentic/coordination/coordinator.rs`, and the tool/model execution call sites that construct persisted session items. + - Add optional fields to persisted model rounds for provider/model identity, first chunk latency, first visible output latency, stream duration, attempt count, failure category, token details, and total duration. + - Add optional tool phase durations to persisted tool items: queue wait, preflight, confirmation wait, and execution. + - Acceptance: old session JSON still deserializes, new session JSON round-trips with these fields, and missing fields never fail report generation. + +2. Consume persisted facts in the usage service. + - File: `src/crates/core/src/service/session_usage/service.rs`. + - Prefer persisted model/tool duration fields when present; fall back to existing start/end or result durations only when facts are missing. + - Compute active time as a union of known active intervals so overlapping spans do not double-count the denominator. + - Exclude `local_command` usage-report turns from scope, wall/active time, model/tool/file/error rows, and slowest spans so generating a report cannot affect the next report. + - Use a localized "model not recorded" label for persisted model spans that have timing but no model identity, instead of exposing implementation terms such as `model round 0`. + - Mark `ModelRoundTiming` and `ToolPhaseTiming` coverage available only from actual recorded facts, not from guessed fallback data. + - Acceptance: model rows can exist from runtime span facts even when token records are absent, tool rows expose phase subtotals, slowest spans include model rounds and tools, and the coverage panel explains missing facts conservatively. + +3. Keep file-change correlation conservative. + - File: `src/crates/core/src/service/session_usage/service.rs`. + - Keep snapshot operations as the highest-trust source for file rows, including remote sessions when cached snapshot summaries are present, then use tool-call metadata as a fallback only for recognized edit/write/delete operations. + - Preserve operation ids and turn indexes for later UI navigation, but do not invent line counts when no snapshot or diff fact exists. + - Acceptance: remote sessions with cached snapshot summaries show file/line rows; remote sessions that only have tool metadata show edited files with unknown line counts instead of "unavailable"; files without trustworthy evidence remain omitted. + +4. Surface the new facts without adding noise. + - Files: `src/web-ui/src/flow_chat/components/usage/*`, `src/web-ui/src/flow_chat/store/FlowChatStore.ts`, `src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts`, and `src/web-ui/src/locales/*/flow-chat.json`. + - Show model duration only when at least one model duration is recorded. + - Keep error and coverage explanations visible through concise hover text plus detail-page descriptions. + - Avoid new claims such as exact token pricing or file-line changes unless the backend has the underlying fact. + +5. Verify and review consistency. + - Required checks: `pnpm run lint:web`, `pnpm run type-check:web`, `pnpm --dir src/web-ui run test:run`, `cargo check --workspace`, and `cargo test --workspace`. + - Review pass: compare backend DTOs, TypeScript types, visible copy, and coverage explanations for the same semantics; list any remaining approximate or inferred fields explicitly. + +P1 red/green test plan: + +- Rust service tests: + - model span facts create model rows and slow-span rows without token records; + - local usage-report turns are excluded from report scope, timing, model/tool/file/error rows, and slowest spans; + - missing model identity renders as localized "model not recorded" copy rather than `model round N`; + - active time uses interval union instead of summing overlapping turns; + - tool phase timings are summed by tool and enable `ToolPhaseTiming` coverage; + - file rows prefer snapshot operations for local and remote sessions, and use tool-call metadata only as fallback. +- Rust persistence tests: + - legacy persisted model/tool JSON without new fields still deserializes; + - persisted model/tool JSON with P1 fields round-trips. +- Web UI tests: + - model duration column appears only when duration facts exist; + - missing timing facts still use coverage/error explanations instead of absolute claims. + +P1 residual-risk checklist: + +- Hallucination risk: any field derived from a fallback must be labeled approximate or unavailable, never exact. +- Drift risk: frontend labels must match backend `accounting`, `denominator`, and coverage states. +- Privacy risk: file paths must continue to use existing redaction/path-label behavior. +- Compatibility risk: optional fields must not invalidate old persisted sessions or remote-session reports. +- Rollback risk: ignoring new optional fields must leave the P0 report usable. + +P1 implementation review note (2026-05-11): + +- Third-party review result: the P1 data contract is additive and optional; old persisted turns, model rounds, and tool items still deserialize, while new facts round-trip through Rust and Web UI session persistence. +- Product-risk review result: visible copy now distinguishes recorded runtime from provider latency, model duration columns stay hidden until at least one model row has timing, and unavailable file/error facts have short hover text plus detail-panel explanations. +- Configuration-side review result: built-in Commit/Create PR quick actions display localized defaults, but unchanged localized defaults are normalized back to canonical storage values when saved so language switching is not pinned to one locale. +- Known boundary: hidden subagent totals remain excluded from the standard report until parent linkage and scheduler/event aggregation are reliable enough for default inclusion. +- Known boundary: legacy start/end timing can make the report useful but still approximate; `accounting` and help text must remain the source of truth for precision. +- Known boundary: remote-session file rows use snapshot summaries when available and recognized file-edit tool inputs otherwise; line counts are still unavailable without snapshot facts. +- Current consistency update: Chat-bottom usage is the only Desktop entry point; report generation appends a local visible report card but that local command is excluded from future usage aggregation and does not update session activity ordering. +- Verification evidence for this review: `pnpm run lint:web`, `pnpm run type-check:web`, `pnpm --dir src/web-ui run test:run`, `cargo check --workspace`, and `cargo test --workspace`. + ### Milestone P2: Responsive Desktop Analysis Surface -Goal: add the interactive Desktop analysis panel and a compact header entry point after the report contract is stable. The implemented header entry is a lightweight `/usage` trigger; live timing values remain deferred until span quality is sufficient. +Goal: add the interactive Desktop analysis panel and keep a compact Chat-bottom entry point after the report contract is stable. The implemented entry is a lightweight `/usage` trigger in the Chat input footer; title/header placement and live timing values remain deferred until span quality and layout needs justify a separate design. Included task groups: | Order | Work item | Detailed tasks | Output | | --- | --- | --- | --- | -| P2.0 | Header action contract | Task 11 | Entry point that can generate the current session report | -| P2.1 | Responsive header entry | Task 11 | `SessionRuntimeStatusEntry` as a stable icon/text usage trigger | -| P2.2 | Detailed report panel | Task 12 | Overview, Models, Tools, Files, and Errors tabs using the shared DTO | -| P2.3 | Diff and transcript links | Tasks 10, 12 | Report rows open existing tool cards or diff viewers without changing their behavior | +| P2.0 | Chat-bottom action contract | Task 11 | Entry point that can generate the current session report | +| P2.1 | Responsive Chat-bottom entry | Task 11 | `ChatInputWorkspaceStrip` usage action as a stable icon/text trigger | +| P2.2 | Detailed report panel | Task 12 | Overview, Models, Tools, Files, Errors, and Slowest tabs using the shared DTO | +| P2.3 | Diff and transcript links | Tasks 10, 12 | Snapshot-backed file rows open existing diff viewers; slow-span rows can jump to known turns; stable tool-card anchors remain follow-up | | P2.4 | i18n, theme, accessibility hardening | Tasks 7, 11, 12 | Locale-safe labels, semantic colors, keyboard access, tooltips, and screen-reader labels | Functional guardrails: -- The header entry is an optional entry point, not the only way to access `/usage`. -- The implemented header entry does not show live metrics or model/tool percentages. -- The header must preserve existing title, branch, file badge, search, and chat controls at small widths. -- Header rendering must use priority collapse instead of viewport-scaled fonts or clipped text. +- The Chat-bottom entry is an optional entry point, not the only way to access `/usage`. +- The implemented Chat-bottom entry does not show live metrics or model/tool percentages. +- The title/header must stay free of usage controls in the current implementation. +- The Chat input footer must preserve existing workspace, branch, model, attachment, and send controls at small widths. +- Entry rendering must use priority collapse instead of viewport-scaled fonts or clipped text. - The panel must not render raw prompts, full command output, file contents, or secret-bearing tool payloads. - P2 must not add large charting libraries; use existing components, simple bars, tables, or capped lists. @@ -745,8 +857,8 @@ Risk and drift controls: | Risk or drift | Mitigation | Stop condition | | --- | --- | --- | -| Small windows become cluttered | Use container-aware priority collapse and icon-only fallback | Header controls overlap or disappear in narrow desktop widths | -| Future live summary causes reflow while streaming | Throttle updates and keep header content dimensionally stable | Streaming causes visible layout jitter | +| Small windows become cluttered | Use container-aware priority collapse and icon-only fallback | Chat footer controls overlap or disappear in narrow desktop widths | +| Future live summary causes reflow while streaming | Throttle updates and keep footer content dimensionally stable | Streaming causes visible layout jitter | | Panel becomes a debugger replacement | Keep default view summary-first and deep links back to existing transcript/diff surfaces | Panel starts duplicating raw tool output or full diffs | | i18n text overflows | Test `en-US`, `zh-CN`, and `zh-TW`; prefer card/list fallback over wide tables | Any required label clips in supported locales | | Theme contrast regresses | Use semantic tokens and light/dark checks | New colors bypass theme tokens | @@ -756,16 +868,26 @@ Required verification before merging P2: - `pnpm run lint:web` - `pnpm run type-check:web` - `pnpm --dir src/web-ui run test:run` -- Component/layout tests for the usage trigger in wide and narrow header states. +- Component/layout tests for the usage trigger in wide and narrow Chat footer states. - Locale smoke checks for `en-US`, `zh-CN`, and `zh-TW`. - Light and dark theme screenshot or manual checks. - Manual proof that existing file diff buttons and report-linked diff buttons open the same scopes. Rollback boundary: -- P2 can be disabled by hiding the header entry and panel route/action while keeping `/usage` Markdown reports available. +- P2 can be disabled by hiding the Chat-bottom entry and panel route/action while keeping `/usage` Markdown reports available. - If the panel has performance issues on long sessions, keep P2.0/P2.1 and disable only the detailed tab content behind a feature flag or capability switch. +P2 implementation progress note (2026-05-11): + +- P2.0/P2.1 entry placement matches current code: the usage action lives in `ChatInputWorkspaceStrip` at the Chat bottom, not in `FlowChatHeader` or the window title/header area. +- P2.2 is implemented as a single detail-panel module today: `SessionUsagePanel.tsx`, `SessionUsagePanel.scss`, `sessionUsagePanelTypes.ts`, and `openSessionUsageReport.ts`. The panel includes Overview, Models, Tools, Files, Errors, and Slowest tabs. Splitting tab bodies into separate files is deferred until component size or ownership makes that cheaper than a consolidated module. +- P2.3 is partially implemented. File rows open the existing snapshot diff viewer through `snapshotAPI.getOperationDiff` and `createDiffEditorTab`; no new diff renderer or mutation path is introduced. Slowest rows can jump to known turns through the existing Flow Chat pin-to-top event. +- The file diff action is intentionally enabled only for trustworthy snapshot-backed rows with a visible path and session id. Redacted rows, tool-input-only rows, and unavailable rows show a disabled placeholder with an explanation instead of attempting a best-effort open. +- Remaining P2.3 work: stable transcript/tool-card anchors are still needed because the current report can preserve turn indexes and operation ids, but not every row type has a durable virtual-list anchor. +- Remaining P2 hardening: add long-session row caps or virtualization, complete manual light/dark/accessibility checks, and finalize exported path redaction rules. +- Verification evidence for current P2 slices: `SessionUsageComponents` covers detail tabs, file diff action, slowest turn jumps, copyable metadata, unavailable help, token-only copy, i18n behavior, and semantic color usage; broader web/Rust verification is tracked in the P1 review note above. + ### Deferred Beyond P2 The following work is intentionally not part of the first three milestones: @@ -1297,48 +1419,49 @@ Verification: - Manual check that existing file diff buttons still open the same diff. - Tests for remote/no-snapshot coverage and path display redaction. -### Task 11: Responsive header usage entry +### Task 11: Responsive Chat-bottom usage entry -Goal: add a compact header entry that generates the current session usage report without displaying live metrics. A live summary can be planned later when runtime spans are reliable. +Goal: add one compact Chat-bottom entry that generates the current session usage report without displaying live metrics. A live summary or header entry can be planned later when runtime spans and layout needs are reliable. Files: -- Modify: `src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.tsx` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.scss` -- Modify: `src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss` -- Test: layout/component tests near existing Flow Chat header tests +- Modify: `src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx` +- Modify: `src/web-ui/src/flow_chat/components/ChatInput.tsx` only for command/action wiring that already belongs to the input surface +- Existing: `src/web-ui/src/flow_chat/components/usage/SessionRuntimeStatusEntry.tsx` remains a lightweight/tested action component, but the production Chat-bottom entry is owned by `ChatInputWorkspaceStrip` +- Test: layout/component tests near existing Chat input/footer tests Steps: -1. Add a status entry component that triggers the same report generation path as `/usage`. +1. Add or keep a Chat-bottom usage action that triggers the same report generation path as `/usage`. 2. Implement a stable icon/text button with an icon-only narrow fallback. -3. Preserve header search, current turn title, branch badge, and file badge layout priority. +3. Preserve Chat input footer workspace, branch, model, attachment, and send-control layout priority. 4. Add tooltip and accessible label that describe the action, not live report values. 5. Hide the entry when no active session exists. -6. Keep live values out of the header until a later span contract can support them. +6. Keep live values out of the entry until a later span contract can support them. +7. Do not add a duplicate title/header entry while the Chat-bottom action is the product-approved entry point. Functional guardrails: - Do not make the status entry the only way to access usage details. - Do not use viewport-scaled font sizes. -- Do not allow the entry to push core header controls offscreen. +- Do not allow the entry to push core Chat input controls offscreen. - Do not show high-frequency timing changes in a way that causes constant reflow. -- Do not show model/tool percentages in the header. +- Do not show model/tool percentages in the Chat footer. +- Do not add a title/header affordance unless a separate product/design review reopens that placement. Risks and mitigations: | Risk | Mitigation | | --- | --- | | Small windows become cluttered | Use container-query or measured available-space collapse | -| The header becomes too dynamic while streaming | Keep the implemented entry action-only; require a separate design before adding live values | +| The Chat footer becomes too dynamic while streaming | Keep the implemented entry action-only; require a separate design before adding live values | | Accessibility suffers in icon-only mode | Provide aria-label and tooltip with text summary | -| Users confuse live header and historical report | Label report generated time and keep header as current-session status only | +| Users confuse live action and historical report | Label report generated time and keep the entry action-only | Verification: - Component/layout tests for visible text and icon-only narrow states. -- Playwright or manual screenshot checks for small desktop windows. +- Playwright or manual screenshot checks for small desktop windows and the Chat input footer. - Theme checks in dark and light mode. ### Task 12: Detailed report panel @@ -1347,18 +1470,17 @@ Goal: provide interactive analysis without making `/usage` depend on a heavy UI. Files: -- Create: `src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionUsageOverview.tsx` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionUsageModels.tsx` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionUsageTools.tsx` -- Create: `src/web-ui/src/flow_chat/components/usage/SessionUsageFiles.tsx` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/sessionUsagePanelTypes.ts` +- Current implementation: `src/web-ui/src/flow_chat/components/usage/openSessionUsageReport.ts` +- Deferred split, only if needed: `SessionUsageOverview.tsx`, `SessionUsageModels.tsx`, `SessionUsageTools.tsx`, and `SessionUsageFiles.tsx` - Test: focused component tests for panel tabs and empty states Steps: -1. Open the panel from the Markdown report action and header entry. -2. Add tabs: Overview, Models, Tools, Files, Errors. +1. Open the panel from the Markdown/report-card action and Chat-bottom usage entry. +2. Add tabs: Overview, Models, Tools, Files, Errors, Slowest. 3. Use virtualized or capped lists for slowest spans and file rows. 4. Link file rows to existing diff open paths. 5. Show partial coverage explanations close to affected metrics. @@ -1434,7 +1556,7 @@ Verification: | Usage report pollutes future model context | Higher token usage, confusing self-reference | Store as non-model-visible local command output | | Metrics look authoritative when data is partial | User mistrust | Include coverage state and "partial data" notes | | Token-count proxy is mistaken for provider billing | Billing confusion | Do not render money, price sources, packages, or invoice language | -| Header becomes noisy or breaks small windows | Worse chat UX | Responsive priority collapse and tooltip-only narrow mode | +| Chat footer becomes noisy or breaks small windows | Worse chat UX | Responsive priority collapse and tooltip-only narrow mode | | Report exposes sensitive command/file details | Privacy concern | Default to aggregate labels; detailed command/file rows follow existing transcript visibility rules | | Runtime tracing adds overhead | Slower sessions | Persist request/tool terminal summaries, not per-token events | | i18n tables become unreadable in CJK locales | Poor localization | Use responsive table/cards and locale-aware number/duration formatting | @@ -1455,7 +1577,7 @@ Minimum checks for implementation milestones: - Rust unit tests for report aggregation and partial coverage. - CLI tests for `/usage` command output. -- Web UI tests for Markdown insertion, non-model-visible report item behavior, and responsive header collapse. +- Web UI tests for Markdown insertion, non-model-visible report item behavior, and responsive Chat-footer collapse. - Locale smoke tests for `en-US`, `zh-CN`, and `zh-TW`. - Theme screenshot/manual checks for dark and light modes. - Regression check that running `/usage` does not trigger a model request. @@ -1486,28 +1608,34 @@ cargo test --workspace - Resolved in the current implementation: monetary cost estimates are hidden entirely; token counts and token coverage are the only supported cost proxy. - Resolved after product review: BitFun startup does not force-run ACP requirement probes, because probing CLIs such as `opencode --version` can create surprising side effects. - Resolved after product review: packaged flashgrep resources are injected per target at build time instead of bundling every platform binary into each installer. -- Resolved after product review: the compact runtime header is a lightweight `/usage` trigger; it does not display live model/tool percentages. +- Resolved after product review: the compact usage entry lives in the Chat input footer as a lightweight `/usage` trigger; it does not display live model/tool percentages and no longer appears in the title/header area. - Resolved after product review: unavailable cache, tool timing, and file metrics include user-facing reasons in hover/help text. -- Resolved after product review: model timing is labeled as recorded model-round time; per-model duration columns are hidden until timing can be reliably linked to each model row. +- Resolved after product review: model timing is labeled as recorded model-round time; per-model duration columns appear only when at least one model row has recorded duration facts. - Resolved after product review: the detail panel shows generated time, session ID, and project path as separate rows with copy controls for long values. -- Should user idle time be computed from wall time minus active spans, or shown only after span data is complete enough? -- Should report generation itself appear in usage metrics as a local command span? +- Resolved in P1: idle gap is computed as wall time minus the union of recorded active turn spans when those spans are available. +- Resolved in the current implementation: report generation itself is a user-visible local command card, but it is excluded from report scope, timing, model/tool/file/error rows, and session activity ordering. - Resolved in the current implementation: `/usage` requires an idle session and returns local feedback while a turn is active. - Should a standard session report include hidden subagents by default when parent linkage is reliable, or should subagent totals be opt-in until the scheduler/event model is complete? - Should visible side sessions such as `/btw` appear only as links in the parent report, or be aggregated into a parent-with-side-sessions mode later? - Resolved in the current implementation: cached tokens are shown as unavailable when the source cannot prove a value; unknown cache metrics are never shown as `0`. - What exact path redaction rule should apply to workspace-relative, absolute local, and remote paths in exported usage reports? +- What stable navigation contract should usage rows use for transcript and tool-card jumps beyond the current turn-level slow-span jump? +- What row cap or virtualization threshold should P2 use for long model, tool, file, error, and slowest lists? +- Should the detail panel stay as one consolidated module, or split into per-tab components once the current P2 hardening work is complete? +- What manual acceptance baseline is required for light theme, dark theme, keyboard navigation, screen-reader labels, and small desktop widths before marking P2 complete? - Resolved in the current implementation: Desktop renders localized reports from DTOs when structured metadata exists, while Markdown remains a fallback and exportable snapshot. ## Recommended First Cut -Start with Milestone P0: +Historical first cut, now complete in the current branch, started with Milestone P0: 1. Lock the report contract first: schema version, workspace identity, report scope, coverage keys, time accounting semantics, token source/cache coverage, and redaction policy. 2. Add shared `SessionUsageReport` aggregation using existing persisted data. 3. Add `/usage` in CLI and Desktop, with explicit idle/active behavior. 4. Render Desktop output as durable Markdown, stored as non-model-visible local command output. 5. Add explicit messaging that recorded runtime spans are approximate and may differ from pure model streaming throughput. -6. Keep the header as a compact entry point until live runtime values have a separate, reliable span contract. +6. Keep one compact Chat-bottom entry point until live runtime values have a separate, reliable span contract and placement review. -This delivers immediate user value while avoiding risky runtime rewrites. It also creates the foundation for later live runtime values and cross-session analytics without implying they are already implemented. +This delivered immediate user value while avoiding risky runtime rewrites. The +remaining work is now concentrated in P2 hardening and explicitly deferred +cross-session analytics, not the P0/P1 report contract. diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 6db8e49eb..03fd65c12 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -520,10 +520,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_session_id: None, status: Some("completed".to_string()), interruption_reason: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(outcome.duration_ms), }], thinking_items: Vec::new(), start_time: started_at, end_time: Some(completed_at), + duration_ms: Some(outcome.duration_ms), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, status: "completed".to_string(), } } @@ -577,10 +591,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_session_id: None, status: Some("error".to_string()), interruption_reason: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, }], thinking_items: Vec::new(), start_time: timestamp, end_time: Some(timestamp), + duration_ms: Some(0), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: Some("context_compression".to_string()), + token_details: None, status: "error".to_string(), } } diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 1c225e97b..e5610cdca 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -1905,6 +1905,16 @@ impl SessionManager { thinking_items: Vec::new(), start_time: completion_timestamp, end_time: Some(completion_timestamp), + duration_ms: Some(0), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, status: "completed".to_string(), }); } @@ -2266,6 +2276,16 @@ impl SessionManager { thinking_items: vec![], start_time: now, end_time: Some(now), + duration_ms: Some(0), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, status: "completed".to_string(), }]; diff --git a/src/crates/core/src/service/session/types.rs b/src/crates/core/src/service/session/types.rs index bc3e75cb9..69eb05a34 100644 --- a/src/crates/core/src/service/session/types.rs +++ b/src/crates/core/src/service/session/types.rs @@ -283,6 +283,62 @@ pub struct ModelRoundData { pub start_time: u64, #[serde(skip_serializing_if = "Option::is_none", alias = "end_time")] pub end_time: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "duration_ms" + )] + pub duration_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "provider_id" + )] + pub provider_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none", alias = "model_id")] + pub model_id: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "model_alias" + )] + pub model_alias: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "first_chunk_ms" + )] + pub first_chunk_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "first_visible_output_ms" + )] + pub first_visible_output_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "stream_duration_ms" + )] + pub stream_duration_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "attempt_count" + )] + pub attempt_count: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "failure_category" + )] + pub failure_category: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "token_details" + )] + pub token_details: Option, pub status: String, } @@ -372,6 +428,30 @@ pub struct ToolItemData { pub end_time: Option, #[serde(skip_serializing_if = "Option::is_none", alias = "duration_ms")] pub duration_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "queue_wait_ms" + )] + pub queue_wait_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "preflight_ms" + )] + pub preflight_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "confirmation_wait_ms" + )] + pub confirmation_wait_ms: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "execution_ms" + )] + pub execution_ms: Option, /// Original order index (to restore the correct insertion order) #[serde(skip_serializing_if = "Option::is_none", alias = "order_index")] @@ -649,7 +729,10 @@ impl DialogTurnData { #[cfg(test)] mod tests { - use super::{DialogTurnData, DialogTurnKind, SessionMetadata, UserMessageData}; + use super::{ + DialogTurnData, DialogTurnKind, ModelRoundData, SessionMetadata, ToolItemData, + UserMessageData, + }; use crate::agentic::core::SessionKind; #[test] @@ -757,4 +840,88 @@ mod tests { assert!(!metadata.is_subagent()); assert!(metadata.is_standard()); } + + #[test] + fn persisted_runtime_span_fields_are_optional_and_round_trip() { + let legacy_round_payload = serde_json::json!({ + "id": "round-legacy", + "turnId": "turn-1", + "roundIndex": 0, + "timestamp": 1, + "textItems": [], + "toolItems": [], + "thinkingItems": [], + "startTime": 1, + "endTime": 2, + "status": "completed" + }); + + let legacy_round: ModelRoundData = + serde_json::from_value(legacy_round_payload).expect("legacy round should deserialize"); + assert_eq!(legacy_round.duration_ms, None); + assert_eq!(legacy_round.model_id, None); + assert_eq!(legacy_round.first_chunk_ms, None); + + let round_payload = serde_json::json!({ + "id": "round-1", + "turnId": "turn-1", + "roundIndex": 0, + "timestamp": 1, + "textItems": [], + "toolItems": [], + "thinkingItems": [], + "startTime": 1, + "endTime": 121, + "durationMs": 120, + "providerId": "provider-a", + "modelId": "model-a", + "modelAlias": "Model A", + "firstChunkMs": 10, + "firstVisibleOutputMs": 12, + "streamDurationMs": 90, + "attemptCount": 2, + "failureCategory": "rate_limit", + "tokenDetails": { "reasoningTokens": 7 }, + "status": "completed" + }); + + let round: ModelRoundData = + serde_json::from_value(round_payload).expect("P1 round should deserialize"); + assert_eq!(round.duration_ms, Some(120)); + assert_eq!(round.provider_id.as_deref(), Some("provider-a")); + assert_eq!(round.model_id.as_deref(), Some("model-a")); + assert_eq!(round.first_visible_output_ms, Some(12)); + assert_eq!(round.attempt_count, Some(2)); + assert_eq!(round.failure_category.as_deref(), Some("rate_limit")); + + let encoded = serde_json::to_value(&round).expect("round should serialize"); + assert_eq!(encoded["durationMs"], 120); + assert_eq!(encoded["modelId"], "model-a"); + assert_eq!(encoded["firstChunkMs"], 10); + + let tool_payload = serde_json::json!({ + "id": "tool-1", + "toolName": "write_file", + "toolCall": { "id": "call-1", "input": { "file_path": "src/main.rs" } }, + "startTime": 5, + "endTime": 105, + "durationMs": 100, + "queueWaitMs": 7, + "preflightMs": 11, + "confirmationWaitMs": 13, + "executionMs": 69, + "status": "completed" + }); + + let tool: ToolItemData = + serde_json::from_value(tool_payload).expect("P1 tool should deserialize"); + assert_eq!(tool.queue_wait_ms, Some(7)); + assert_eq!(tool.preflight_ms, Some(11)); + assert_eq!(tool.confirmation_wait_ms, Some(13)); + assert_eq!(tool.execution_ms, Some(69)); + + let encoded = serde_json::to_value(&tool).expect("tool should serialize"); + assert_eq!(encoded["queueWaitMs"], 7); + assert_eq!(encoded["executionMs"], 69); + } } diff --git a/src/crates/core/src/service/session_usage/render.rs b/src/crates/core/src/service/session_usage/render.rs index 1eb6eac2b..e4476388c 100644 --- a/src/crates/core/src/service/session_usage/render.rs +++ b/src/crates/core/src/service/session_usage/render.rs @@ -191,19 +191,39 @@ pub fn render_usage_report_markdown(report: &SessionUsageReport) -> String { )); if !report.models.is_empty() { + let include_duration = report + .models + .iter() + .any(|model| model.duration_ms.is_some()); out.push_str("## Models\n\n"); - out.push_str( - "| Model | Calls | Input | Output | Total |\n| --- | ---: | ---: | ---: | ---: |\n", - ); + if include_duration { + out.push_str("| Model | Calls | Recorded time | Input | Output | Total |\n| --- | ---: | --- | ---: | ---: | ---: |\n"); + } else { + out.push_str( + "| Model | Calls | Input | Output | Total |\n| --- | ---: | ---: | ---: | ---: |\n", + ); + } for model in &report.models { - out.push_str(&format!( - "| {} | {} | {} | {} | {} |\n", - escape_markdown(&model.model_id), - model.call_count, - format_optional_number(model.input_tokens), - format_optional_number(model.output_tokens), - format_optional_number(model.total_tokens) - )); + if include_duration { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} |\n", + escape_markdown(&model.model_id), + model.call_count, + format_optional_duration(model.duration_ms), + format_optional_number(model.input_tokens), + format_optional_number(model.output_tokens), + format_optional_number(model.total_tokens) + )); + } else { + out.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + escape_markdown(&model.model_id), + model.call_count, + format_optional_number(model.input_tokens), + format_optional_number(model.output_tokens), + format_optional_number(model.total_tokens) + )); + } } out.push('\n'); } @@ -436,6 +456,8 @@ mod tests { kind: UsageSlowSpanKind::Tool, duration_ms: 1200, redacted: true, + turn_id: None, + turn_index: None, }); let rendered = render_usage_report_markdown(&report); diff --git a/src/crates/core/src/service/session_usage/service.rs b/src/crates/core/src/service/session_usage/service.rs index 7cb2e9e82..9c02c63ce 100644 --- a/src/crates/core/src/service/session_usage/service.rs +++ b/src/crates/core/src/service/session_usage/service.rs @@ -1,5 +1,7 @@ use crate::agentic::persistence::PersistenceManager; -use crate::service::session::{DialogTurnData, DialogTurnKind, ToolItemData, TurnStatus}; +use crate::service::session::{ + DialogTurnData, DialogTurnKind, ModelRoundData, ToolItemData, TurnStatus, +}; use crate::service::session_usage::classifier::classify_tool_usage; use crate::service::session_usage::redaction::{ display_workspace_relative_path, redact_usage_label, @@ -93,6 +95,12 @@ pub fn build_session_usage_report_from_sources( snapshot_facts: &UsageSnapshotFacts, generated_at: i64, ) -> SessionUsageReport { + let reportable_turns: Vec = turns + .iter() + .filter(|turn| is_reportable_usage_turn(turn)) + .cloned() + .collect(); + let turns = reportable_turns.as_slice(); let mut report = SessionUsageReport::partial_unavailable(&request.session_id, generated_at); report.report_id = format!("usage-{}-{}", request.session_id, generated_at); report.workspace = build_workspace(&request); @@ -100,7 +108,7 @@ pub fn build_session_usage_report_from_sources( report.coverage = build_coverage(&request, turns, token_records, snapshot_facts); report.time = build_time_breakdown(turns); report.tokens = build_token_breakdown(token_records); - report.models = build_model_breakdown(token_records); + report.models = build_model_breakdown(turns, token_records); report.tools = build_tool_breakdown(turns); report.files = build_file_breakdown(request.workspace_path.as_deref(), turns, snapshot_facts); report.compression = build_compression_breakdown(turns); @@ -117,10 +125,6 @@ pub fn build_session_usage_report_from_sources( } async fn load_snapshot_facts(request: &SessionUsageReportRequest) -> UsageSnapshotFacts { - if request.remote_connection_id.is_some() || request.remote_ssh_host.is_some() { - return UsageSnapshotFacts::default(); - } - let Some(workspace_path) = request.workspace_path.as_deref() else { return UsageSnapshotFacts::default(); }; @@ -142,6 +146,10 @@ async fn load_snapshot_facts(request: &SessionUsageReportRequest) -> UsageSnapsh } } +fn is_reportable_usage_turn(turn: &DialogTurnData) -> bool { + turn.kind != DialogTurnKind::LocalCommand +} + fn snapshot_operation_from_file_operation( operation: FileOperation, ) -> UsageSnapshotOperationSummary { @@ -197,10 +205,13 @@ fn build_coverage( if turns .iter() .flat_map(|turn| turn.model_rounds.iter()) - .any(|round| round.end_time.is_some()) + .any(has_model_timing_fact) { available.push(UsageCoverageKey::ModelRoundTiming); } + if iter_tools(turns).any(has_tool_phase_timing_fact) { + available.push(UsageCoverageKey::ToolPhaseTiming); + } if token_records .iter() .any(|record| record.cached_tokens_available) @@ -297,39 +308,51 @@ fn build_time_breakdown(turns: &[DialogTurnData]) -> UsageTimeBreakdown { .max() .unwrap_or(start); let wall_time_ms = end.saturating_sub(start); - let active_turn_ms: u64 = turns + let active_intervals = turns .iter() - .map(|turn| { - turn.duration_ms - .or_else(|| turn.end_time.map(|end| end.saturating_sub(turn.start_time))) - .unwrap_or(0) - }) - .sum(); - let tool_ms: u64 = turns + .filter_map(|turn| turn.end_time.map(|end| (turn.start_time, end))) + .collect::>(); + let active_turn_ms = (!active_intervals.is_empty()) + .then(|| duration_union_ms(&active_intervals)) + .or_else(|| { + let summed: u64 = turns.iter().filter_map(|turn| turn.duration_ms).sum(); + (summed > 0).then_some(summed) + }); + let tool_durations = turns .iter() .flat_map(|turn| turn.model_rounds.iter()) .flat_map(|round| round.tool_items.iter()) - .map(tool_duration_ms) - .sum(); + .filter_map(tool_duration_ms) + .collect::>(); + let tool_ms = Some(tool_durations.iter().sum()); let model_round_durations: Vec = turns .iter() .flat_map(|turn| turn.model_rounds.iter()) - .filter_map(|round| { - round - .end_time - .map(|end| end.saturating_sub(round.start_time)) - }) + .filter_map(model_round_duration_ms) .collect(); let model_ms = (!model_round_durations.is_empty()).then(|| model_round_durations.iter().sum()); + let has_incomplete_turn_span = turns.iter().any(|turn| turn.end_time.is_none()); + let has_legacy_model_span = turns + .iter() + .flat_map(|turn| turn.model_rounds.iter()) + .any(|round| round.duration_ms.is_none() && round.end_time.is_some()); UsageTimeBreakdown { - accounting: UsageTimeAccounting::Approximate, - denominator: UsageTimeDenominator::SessionWallTime, + accounting: if has_incomplete_turn_span || has_legacy_model_span { + UsageTimeAccounting::Approximate + } else { + UsageTimeAccounting::Exact + }, + denominator: if active_turn_ms.is_some() { + UsageTimeDenominator::ActiveTurnTime + } else { + UsageTimeDenominator::SessionWallTime + }, wall_time_ms: Some(wall_time_ms), - active_turn_ms: Some(active_turn_ms), + active_turn_ms, model_ms, - tool_ms: Some(tool_ms), - idle_gap_ms: Some(wall_time_ms.saturating_sub(active_turn_ms)), + tool_ms, + idle_gap_ms: active_turn_ms.map(|active| wall_time_ms.saturating_sub(active)), } } @@ -391,8 +414,12 @@ fn build_token_breakdown(token_records: &[TokenUsageRecord]) -> UsageTokenBreakd } } -fn build_model_breakdown(token_records: &[TokenUsageRecord]) -> Vec { +fn build_model_breakdown( + turns: &[DialogTurnData], + token_records: &[TokenUsageRecord], +) -> Vec { let mut by_model: HashMap = HashMap::new(); + let mut span_counts_by_model: HashMap = HashMap::new(); for record in token_records { let row = by_model .entry(record.model_id.clone()) @@ -415,6 +442,33 @@ fn build_model_breakdown(token_records: &[TokenUsageRecord]) -> Vec = by_model.into_values().collect(); rows.sort_by(|a, b| a.model_id.cmp(&b.model_id)); rows @@ -448,7 +502,7 @@ fn build_tool_breakdown(turns: &[DialogTurnData]) -> Vec { Some(false) => row.error_count += 1, None => {} } - let duration_ms = tool_duration_ms(tool); + let duration_ms = tool_duration_ms(tool).unwrap_or(0); row.duration_ms = Some(row.duration_ms.unwrap_or(0) + duration_ms); if duration_ms > 0 { durations_by_tool @@ -456,6 +510,10 @@ fn build_tool_breakdown(turns: &[DialogTurnData]) -> Vec { .or_default() .push(duration_ms); } + add_optional_duration(&mut row.queue_wait_ms, tool.queue_wait_ms); + add_optional_duration(&mut row.preflight_ms, tool.preflight_ms); + add_optional_duration(&mut row.confirmation_wait_ms, tool.confirmation_wait_ms); + add_optional_duration(&mut row.execution_ms, tool.execution_ms); row.redacted |= label.redacted; } @@ -571,33 +629,59 @@ fn build_file_breakdown_from_tool_inputs( turns: &[DialogTurnData], ) -> UsageFileBreakdown { let mut files: HashMap = HashMap::new(); + let mut turn_indexes_by_path: HashMap> = HashMap::new(); + let mut operation_ids_by_path: HashMap> = HashMap::new(); - for tool in iter_tools(turns) { - if !is_file_modification_tool(&tool.tool_name) { - continue; + for turn in turns { + for tool in iter_turn_tools(turn) { + if !is_file_modification_tool(&tool.tool_name) { + continue; + } + + let Some(path) = extract_file_path(tool) else { + continue; + }; + let label = display_workspace_relative_path(workspace_root, &path); + let row = files + .entry(label.value.clone()) + .or_insert_with(|| UsageFileRow { + path_label: label.value.clone(), + operation_count: 0, + added_lines: None, + deleted_lines: None, + session_id: None, + turn_indexes: vec![], + operation_ids: vec![], + redacted: label.redacted, + }); + row.operation_count += 1; + row.redacted |= label.redacted; + + turn_indexes_by_path + .entry(label.value.clone()) + .or_default() + .insert(turn.turn_index); + operation_ids_by_path + .entry(label.value) + .or_default() + .insert(tool.id.clone()); } - - let Some(path) = extract_file_path(tool) else { - continue; - }; - let label = display_workspace_relative_path(workspace_root, &path); - let row = files - .entry(label.value.clone()) - .or_insert_with(|| UsageFileRow { - path_label: label.value.clone(), - operation_count: 0, - added_lines: None, - deleted_lines: None, - session_id: None, - turn_indexes: vec![], - operation_ids: vec![], - redacted: label.redacted, - }); - row.operation_count += 1; - row.redacted |= label.redacted; } - let mut rows: Vec<_> = files.into_values().collect(); + let mut rows: Vec<_> = files + .into_iter() + .map(|(path_label, mut row)| { + row.turn_indexes = turn_indexes_by_path + .remove(&path_label) + .map(|values| values.into_iter().collect()) + .unwrap_or_default(); + row.operation_ids = operation_ids_by_path + .remove(&path_label) + .map(|values| values.into_iter().collect()) + .unwrap_or_default(); + row + }) + .collect(); rows.sort_by(|a, b| a.path_label.cmp(&b.path_label)); UsageFileBreakdown { scope: if rows.is_empty() { @@ -645,12 +729,43 @@ fn build_error_breakdown(turns: &[DialogTurnData]) -> UsageErrorBreakdown { .is_some_and(|result| !result.success) }) .count() as u64; + let mut examples = Vec::new(); + + if model_errors > 0 { + examples.push(UsageErrorExample { + label: "Model/runtime turn errors".to_string(), + count: model_errors, + redacted: false, + }); + } + + let mut tool_error_counts: HashMap = HashMap::new(); + for tool in iter_tools(turns).filter(|tool| { + tool.tool_result + .as_ref() + .is_some_and(|result| !result.success) + }) { + let label = redact_usage_label(&tool.tool_name, 80); + let row = tool_error_counts + .entry(label.value.clone()) + .or_insert_with(|| UsageErrorExample { + label: label.value.clone(), + count: 0, + redacted: label.redacted, + }); + row.count += 1; + row.redacted |= label.redacted; + } + + let mut tool_examples: Vec<_> = tool_error_counts.into_values().collect(); + tool_examples.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.label.cmp(&b.label))); + examples.extend(tool_examples.into_iter().take(4)); UsageErrorBreakdown { total_errors: model_errors + tool_errors, tool_errors, model_errors, - examples: vec![], + examples, } } @@ -667,17 +782,36 @@ fn build_slowest_spans(turns: &[DialogTurnData]) -> Vec { kind: UsageSlowSpanKind::Turn, duration_ms, redacted: false, + turn_id: Some(turn.turn_id.clone()), + turn_index: Some(turn.turn_index), }); } + for round in &turn.model_rounds { + if let Some(duration_ms) = model_round_duration_ms(round) { + spans.push(UsageSlowSpan { + label: model_round_label(round), + kind: UsageSlowSpanKind::Model, + duration_ms, + redacted: false, + turn_id: Some(turn.turn_id.clone()), + turn_index: Some(turn.turn_index), + }); + } + } + for tool in iter_turn_tools(turn) { let label = redact_usage_label(&tool.tool_name, 80); - spans.push(UsageSlowSpan { - label: label.value, - kind: UsageSlowSpanKind::Tool, - duration_ms: tool_duration_ms(tool), - redacted: label.redacted, - }); + if let Some(duration_ms) = tool_duration_ms(tool) { + spans.push(UsageSlowSpan { + label: label.value, + kind: UsageSlowSpanKind::Tool, + duration_ms, + redacted: label.redacted, + turn_id: Some(turn.turn_id.clone()), + turn_index: Some(turn.turn_index), + }); + } } } @@ -713,7 +847,40 @@ fn iter_turn_tools(turn: &DialogTurnData) -> impl Iterator .flat_map(|round| round.tool_items.iter()) } -fn tool_duration_ms(tool: &ToolItemData) -> u64 { +fn model_round_duration_ms(round: &ModelRoundData) -> Option { + round.duration_ms.or_else(|| { + round + .end_time + .map(|end| end.saturating_sub(round.start_time)) + }) +} + +fn model_round_label(round: &ModelRoundData) -> String { + round + .model_id + .as_deref() + .or(round.model_alias.as_deref()) + .map(|value| redact_usage_label(value, 80).value) + .unwrap_or_else(|| "unknown_model".to_string()) +} + +fn has_model_timing_fact(round: &ModelRoundData) -> bool { + model_round_duration_ms(round).is_some() + || round.first_chunk_ms.is_some() + || round.first_visible_output_ms.is_some() + || round.stream_duration_ms.is_some() + || round.attempt_count.is_some() + || round.failure_category.is_some() +} + +fn has_tool_phase_timing_fact(tool: &ToolItemData) -> bool { + tool.queue_wait_ms.is_some() + || tool.preflight_ms.is_some() + || tool.confirmation_wait_ms.is_some() + || tool.execution_ms.is_some() +} + +fn tool_duration_ms(tool: &ToolItemData) -> Option { tool.duration_ms .or_else(|| { tool.tool_result @@ -721,19 +888,59 @@ fn tool_duration_ms(tool: &ToolItemData) -> u64 { .and_then(|result| result.duration_ms) }) .or_else(|| tool.end_time.map(|end| end.saturating_sub(tool.start_time))) - .unwrap_or(0) +} + +fn add_optional_duration(total: &mut Option, value: Option) { + if let Some(value) = value { + *total = Some(total.unwrap_or(0) + value); + } +} + +fn duration_union_ms(intervals: &[(u64, u64)]) -> u64 { + let mut normalized = intervals + .iter() + .filter_map(|(start, end)| (end > start).then_some((*start, *end))) + .collect::>(); + if normalized.is_empty() { + return 0; + } + + normalized.sort_unstable_by_key(|(start, end)| (*start, *end)); + let mut total = 0; + let (mut current_start, mut current_end) = normalized[0]; + + for (start, end) in normalized.into_iter().skip(1) { + if start <= current_end { + current_end = current_end.max(end); + } else { + total += current_end.saturating_sub(current_start); + current_start = start; + current_end = end; + } + } + + total + current_end.saturating_sub(current_start) } fn is_file_modification_tool(tool_name: &str) -> bool { matches!( tool_name, - "write_file" | "edit_file" | "create_file" | "delete_file" + "Write" + | "Edit" + | "Delete" + | "write_file" + | "edit_file" + | "create_file" + | "delete_file" + | "rename_file" + | "move_file" + | "search_replace" ) } fn extract_file_path(tool: &ToolItemData) -> Option { let input = tool.tool_call.input.as_object()?; - ["file_path", "path", "filePath"] + ["file_path", "path", "filePath", "target_file", "filename"] .into_iter() .find_map(|key| input.get(key).and_then(|value| value.as_str())) .map(ToOwned::to_owned) @@ -831,24 +1038,174 @@ mod tests { } #[test] - fn report_active_runtime_is_approximate_in_p0() { + fn report_active_runtime_uses_active_span_union() { let request = test_request(None); + let mut first = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + first.start_time = 1_000; + first.end_time = Some(1_300); + first.duration_ms = Some(300); + first.model_rounds[0].start_time = 1_010; + first.model_rounds[0].end_time = Some(1_110); + first.model_rounds[0].duration_ms = Some(100); + + let mut second = test_turn("turn-2", 1, DialogTurnKind::ManualCompaction); + second.start_time = 1_200; + second.end_time = Some(1_500); + second.duration_ms = Some(300); + second.model_rounds[0].start_time = 1_220; + second.model_rounds[0].end_time = Some(1_340); + second.model_rounds[0].duration_ms = Some(120); let report = build_session_usage_report_from_turns( request, - &[ - test_turn("turn-1", 0, DialogTurnKind::UserDialog), - test_turn("turn-2", 1, DialogTurnKind::ManualCompaction), - ], + &[first, second], &[], 1_778_347_200_000, ); - assert_eq!(report.time.accounting, UsageTimeAccounting::Approximate); - assert_eq!(report.time.model_ms, Some(400)); + assert_eq!(report.time.accounting, UsageTimeAccounting::Exact); + assert_eq!( + report.time.denominator, + UsageTimeDenominator::ActiveTurnTime + ); + assert_eq!(report.time.wall_time_ms, Some(500)); + assert_eq!(report.time.active_turn_ms, Some(500)); + assert_eq!(report.time.model_ms, Some(220)); + assert_eq!(report.time.idle_gap_ms, Some(0)); assert_eq!(report.compression.manual_compaction_count, 1); } + #[test] + fn report_excludes_local_command_turns_from_usage_metrics() { + let request = test_request(None); + let mut user_turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + user_turn.start_time = 1_000; + user_turn.end_time = Some(1_300); + user_turn.duration_ms = Some(300); + user_turn.model_rounds[0].duration_ms = Some(200); + + let mut local_usage_turn = test_turn("local-usage-1", 1, DialogTurnKind::LocalCommand); + local_usage_turn.start_time = 50_000; + local_usage_turn.end_time = Some(50_000); + local_usage_turn.duration_ms = Some(0); + local_usage_turn.model_rounds[0].duration_ms = Some(9_000); + + let report = build_session_usage_report_from_turns( + request, + &[user_turn, local_usage_turn], + &[], + 1_778_347_200_000, + ); + + assert_eq!(report.scope.turn_count, 1); + assert_eq!(report.scope.from_turn_id.as_deref(), Some("turn-1")); + assert_eq!(report.scope.to_turn_id.as_deref(), Some("turn-1")); + assert_eq!(report.time.wall_time_ms, Some(300)); + assert_eq!(report.time.active_turn_ms, Some(300)); + assert_eq!(report.time.model_ms, Some(200)); + assert_eq!(report.models[0].duration_ms, Some(200)); + assert_eq!(report.tools[0].call_count, 1); + assert_eq!(report.files.files[0].operation_count, 1); + } + + #[test] + fn report_uses_persisted_model_span_facts_without_token_records() { + let request = test_request(None); + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + turn.model_rounds = vec![ + test_model_round("round-a", "turn-1", 0, "model-a", 90), + test_model_round("round-b", "turn-1", 1, "model-b", 140), + ]; + + let report = + build_session_usage_report_from_turns(request, &[turn], &[], 1_778_347_200_000); + + assert!(report + .coverage + .available + .contains(&UsageCoverageKey::ModelRoundTiming)); + assert!(!report + .coverage + .missing + .contains(&UsageCoverageKey::ModelRoundTiming)); + assert_eq!( + report + .models + .iter() + .map(|model| ( + model.model_id.as_str(), + model.call_count, + model.duration_ms, + model.total_tokens + )) + .collect::>(), + vec![ + ("model-a", 1, Some(90), None), + ("model-b", 1, Some(140), None), + ] + ); + assert!(report.slowest.iter().any(|span| { + span.kind == UsageSlowSpanKind::Model + && span.label == "model-b" + && span.duration_ms == 140 + })); + } + + #[test] + fn report_uses_clear_label_when_model_identity_is_missing() { + let request = test_request(None); + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + turn.model_rounds[0].model_id = None; + turn.model_rounds[0].model_alias = None; + turn.model_rounds[0].duration_ms = Some(180); + + let report = + build_session_usage_report_from_turns(request, &[turn], &[], 1_778_347_200_000); + + assert_eq!(report.models[0].model_id, "unknown_model"); + assert!(report.slowest.iter().any(|span| { + span.kind == UsageSlowSpanKind::Model + && span.label == "unknown_model" + && span.duration_ms == 180 + })); + } + + #[test] + fn report_adds_turn_anchors_to_slowest_spans() { + let request = test_request(None); + let mut turn = test_turn_with_tools( + "turn-7", + 7, + DialogTurnKind::UserDialog, + vec![test_tool_item( + "tool-7", + "write_file", + Some(true), + 500, + "D:/workspace/bitfun/src/main.rs", + )], + ); + turn.duration_ms = Some(900); + turn.model_rounds[0].duration_ms = Some(700); + + let report = + build_session_usage_report_from_turns(request, &[turn], &[], 1_778_347_200_000); + + for kind in [ + UsageSlowSpanKind::Turn, + UsageSlowSpanKind::Model, + UsageSlowSpanKind::Tool, + ] { + let span = report + .slowest + .iter() + .find(|span| span.kind == kind) + .expect("anchored slow span"); + assert_eq!(span.turn_id.as_deref(), Some("turn-7")); + assert_eq!(span.turn_index, Some(7)); + } + } + #[test] fn report_counts_failed_and_cancelled_tool_duration_when_available() { let request = test_request(None); @@ -952,6 +1309,59 @@ mod tests { assert_eq!(edit.p95_duration_ms, None); } + #[test] + fn report_sums_tool_phase_timings_and_marks_phase_coverage_available() { + let request = test_request(None); + let mut first = test_tool_item( + "tool-1", + "write_file", + Some(true), + 100, + "D:/workspace/bitfun/src/a.rs", + ); + first.queue_wait_ms = Some(7); + first.preflight_ms = Some(11); + first.confirmation_wait_ms = Some(13); + first.execution_ms = Some(69); + + let mut second = test_tool_item( + "tool-2", + "write_file", + Some(true), + 80, + "D:/workspace/bitfun/src/b.rs", + ); + second.queue_wait_ms = Some(3); + second.preflight_ms = Some(5); + second.confirmation_wait_ms = Some(0); + second.execution_ms = Some(72); + + let turn = + test_turn_with_tools("turn-1", 0, DialogTurnKind::UserDialog, vec![first, second]); + + let report = + build_session_usage_report_from_turns(request, &[turn], &[], 1_778_347_200_000); + + let write = report + .tools + .iter() + .find(|tool| tool.tool_name == "write_file") + .expect("write tool row"); + assert_eq!(write.duration_ms, Some(180)); + assert_eq!(write.queue_wait_ms, Some(10)); + assert_eq!(write.preflight_ms, Some(16)); + assert_eq!(write.confirmation_wait_ms, Some(13)); + assert_eq!(write.execution_ms, Some(141)); + assert!(report + .coverage + .available + .contains(&UsageCoverageKey::ToolPhaseTiming)); + assert!(!report + .coverage + .missing + .contains(&UsageCoverageKey::ToolPhaseTiming)); + } + #[test] fn aggregates_operation_summary_file_stats_without_reading_file_bodies() { let request = test_request(None); @@ -1019,6 +1429,90 @@ mod tests { .contains(&UsageCoverageKey::RemoteSnapshotStats)); } + #[test] + fn remote_workspace_uses_wrapped_tool_inputs_for_file_rows() { + let request = test_request(Some("ssh-1")); + let turn = test_turn_with_tools( + "turn-1", + 0, + DialogTurnKind::UserDialog, + vec![ + test_tool_item_with_input( + "tool-1", + "Write", + Some(true), + 100, + serde_json::json!({ "file_path": "D:/workspace/bitfun/src/main.rs" }), + ), + test_tool_item_with_input( + "tool-2", + "Edit", + Some(true), + 80, + serde_json::json!({ "target_file": "D:/workspace/bitfun/src/lib.rs" }), + ), + ], + ); + + let report = build_session_usage_report_from_sources( + request, + &[turn], + &[], + &UsageSnapshotFacts::default(), + 1_778_347_200_000, + ); + + assert_eq!(report.workspace.kind, UsageWorkspaceKind::RemoteSsh); + assert_eq!(report.files.scope, UsageFileScope::ToolInputsOnly); + assert_eq!(report.files.changed_files, Some(2)); + assert_eq!( + report + .files + .files + .iter() + .map(|row| row.path_label.as_str()) + .collect::>(), + vec!["src/lib.rs", "src/main.rs"] + ); + } + + #[test] + fn report_includes_error_examples_for_failed_turns_and_tools() { + let request = test_request(None); + let mut failed_turn = test_turn_with_tools( + "turn-1", + 0, + DialogTurnKind::UserDialog, + vec![ + test_tool_item( + "tool-1", + "Write", + Some(false), + 100, + "D:/workspace/bitfun/src/main.rs", + ), + test_tool_item("tool-2", "Bash", Some(false), 120, "D:/workspace/bitfun"), + ], + ); + failed_turn.status = TurnStatus::Error; + + let report = + build_session_usage_report_from_turns(request, &[failed_turn], &[], 1_778_347_200_000); + + assert_eq!(report.errors.total_errors, 3); + assert_eq!(report.errors.tool_errors, 2); + assert_eq!(report.errors.model_errors, 1); + assert_eq!( + report + .errors + .examples + .iter() + .map(|example| (example.label.as_str(), example.count)) + .collect::>(), + vec![("Model/runtime turn errors", 1), ("Bash", 1), ("Write", 1),] + ); + } + #[test] fn file_rows_preserve_operation_turn_and_session_scopes() { let request = test_request(None); @@ -1124,6 +1618,16 @@ mod tests { thinking_items: vec![], start_time: 1_000 + turn_index as u64, end_time: Some(1_200 + turn_index as u64), + duration_ms: Some(200), + provider_id: None, + model_id: Some("model-a".to_string()), + model_alias: Some("model-a".to_string()), + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, status: "completed".to_string(), }], start_time: 1_000 + turn_index as u64, @@ -1133,20 +1637,67 @@ mod tests { } } + fn test_model_round( + id: &str, + turn_id: &str, + round_index: usize, + model_id: &str, + duration_ms: u64, + ) -> ModelRoundData { + ModelRoundData { + id: id.to_string(), + turn_id: turn_id.to_string(), + round_index, + timestamp: 1_000 + round_index as u64, + text_items: vec![], + tool_items: vec![], + thinking_items: vec![], + start_time: 1_000 + round_index as u64, + end_time: Some(1_000 + round_index as u64 + duration_ms), + duration_ms: Some(duration_ms), + provider_id: Some("test-provider".to_string()), + model_id: Some(model_id.to_string()), + model_alias: Some(model_id.to_string()), + first_chunk_ms: Some(5), + first_visible_output_ms: Some(8), + stream_duration_ms: Some(duration_ms.saturating_sub(10)), + attempt_count: Some(1), + failure_category: None, + token_details: None, + status: "completed".to_string(), + } + } + fn test_tool_item( id: &str, tool_name: &str, success: Option, duration_ms: u64, file_path: &str, + ) -> ToolItemData { + test_tool_item_with_input( + id, + tool_name, + success, + duration_ms, + serde_json::json!({ + "file_path": file_path + }), + ) + } + + fn test_tool_item_with_input( + id: &str, + tool_name: &str, + success: Option, + duration_ms: u64, + input: serde_json::Value, ) -> ToolItemData { ToolItemData { id: id.to_string(), tool_name: tool_name.to_string(), tool_call: ToolCallData { - input: serde_json::json!({ - "file_path": file_path - }), + input, id: format!("call-{}", id), }, tool_result: success.map(|success| ToolResultData { @@ -1173,6 +1724,10 @@ mod tests { .to_string(), ), interruption_reason: success.is_none().then(|| "cancelled".to_string()), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, } } diff --git a/src/crates/core/src/service/session_usage/types.rs b/src/crates/core/src/service/session_usage/types.rs index bd37040de..9e4b4c7a7 100644 --- a/src/crates/core/src/service/session_usage/types.rs +++ b/src/crates/core/src/service/session_usage/types.rs @@ -282,6 +282,10 @@ pub struct UsageSlowSpan { pub kind: UsageSlowSpanKind, pub duration_ms: u64, pub redacted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_index: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 6c076e39b..3a712fac5 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -15,6 +15,7 @@ import type { SessionTitleGeneratedEvent, SessionModelAutoMigratedEvent, ImageAnalysisEvent, + ModelRoundCompletedEvent, UserSteeringInjectedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; import { createLogger } from '@/shared/utils/logger'; @@ -31,6 +32,7 @@ export interface AgenticEventCallbacks { onImageAnalysisCompleted?: (event: ImageAnalysisEvent) => void; onDialogTurnStarted?: (event: AgenticEvent) => void; onModelRoundStarted?: (event: AgenticEvent) => void; + onModelRoundCompleted?: (event: ModelRoundCompletedEvent) => void; onTextChunk?: (event: TextChunkEvent) => void; onToolEvent?: (event: ToolEvent) => void; onDialogTurnCompleted?: (event: AgenticEvent) => void; @@ -114,6 +116,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onModelRoundCompleted) { + const unlisten = agentAPI.onModelRoundCompleted((event) => { + logger.debug('Model round completed:', event); + callbacks.onModelRoundCompleted?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onTextChunk) { const unlisten = agentAPI.onTextChunk((event) => { callbacks.onTextChunk?.(event); diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 09fee04fb..c5f58ac57 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -252,14 +252,28 @@ export interface CompletedToolEvent extends BaseToolEvent<'Completed'> { result: unknown; result_for_assistant?: string; duration_ms: number; + queue_wait_ms?: number; + preflight_ms?: number; + confirmation_wait_ms?: number; + execution_ms?: number; } export interface FailedToolEvent extends BaseToolEvent<'Failed'> { error: string; + duration_ms?: number; + queue_wait_ms?: number; + preflight_ms?: number; + confirmation_wait_ms?: number; + execution_ms?: number; } export interface CancelledToolEvent extends BaseToolEvent<'Cancelled'> { reason: string; + duration_ms?: number; + queue_wait_ms?: number; + preflight_ms?: number; + confirmation_wait_ms?: number; + execution_ms?: number; } export type FlowToolEvent = diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 5487147f6..638cdc83c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -22,6 +22,7 @@ import type { NotificationAction } from '../../../shared/notification-system/typ import { createLogger } from '@/shared/utils/logger'; import type { ImageAnalysisEvent, + ModelRoundCompletedEvent, SessionModelAutoMigratedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; @@ -394,6 +395,9 @@ export async function initializeEventListeners( onModelRoundStarted: (event) => { handleModelRoundStart(context, event); }, + onModelRoundCompleted: (event) => { + handleModelRoundComplete(context, event); + }, onDialogTurnCompleted: (event) => { handleDialogTurnComplete(context, event, onTodoWriteResult); }, @@ -1548,6 +1552,68 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { immediateSaveDialogTurn(context, sessionId, turnId); } +function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** + * Handle model round completed event. + */ +function handleModelRoundComplete(context: FlowChatContext, event: ModelRoundCompletedEvent): void { + const sessionId = event?.sessionId ?? (event as any)?.session_id; + const turnId = event?.turnId ?? (event as any)?.turn_id; + const roundId = event?.roundId ?? (event as any)?.round_id; + const subagentParentInfo = normalizeSubagentParentInfo(event); + + if (subagentParentInfo) { + attachSubagentSessionToParentTool(subagentParentInfo, sessionId); + } + + if (!sessionId || !turnId || !roundId) { + log.warn('ModelRoundCompleted missing identity fields', { event }); + return; + } + + if (!shouldProcessEvent(sessionId, turnId, 'data', 'ModelRoundCompleted')) { + return; + } + + const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + const dialogTurn = session?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); + const round = dialogTurn?.modelRounds.find(modelRound => modelRound.id === roundId); + if (!round) { + log.debug('Model round not found (model round complete)', { sessionId, turnId, roundId }); + return; + } + + const durationMs = optionalNumber(event.durationMs ?? (event as any).duration_ms); + const completedAt = Date.now(); + const endTime = round.endTime ?? (durationMs !== undefined ? round.startTime + durationMs : completedAt); + + context.flowChatStore.updateModelRound(sessionId, turnId, roundId, current => ({ + ...current, + isStreaming: false, + isComplete: true, + status: current.status === 'error' || current.status === 'cancelled' + ? current.status + : 'completed', + endTime, + durationMs, + providerId: event.providerId ?? (event as any).provider_id, + modelId: event.modelId ?? (event as any).model_id, + modelAlias: event.modelAlias ?? (event as any).model_alias, + firstChunkMs: optionalNumber(event.firstChunkMs ?? (event as any).first_chunk_ms), + firstVisibleOutputMs: optionalNumber(event.firstVisibleOutputMs ?? (event as any).first_visible_output_ms), + streamDurationMs: optionalNumber(event.streamDurationMs ?? (event as any).stream_duration_ms), + attemptCount: optionalNumber(event.attemptCount ?? (event as any).attempt_count), + failureCategory: event.failureCategory ?? (event as any).failure_category, + tokenDetails: event.tokenDetails ?? (event as any).token_details, + })); + + immediateSaveDialogTurn(context, sessionId, turnId); +} + /** * Handle token usage update event */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 55a988967..9bd185736 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -412,7 +412,12 @@ function handleCompleted( requiresConfirmation: false, acpPermission: undefined, isParamsStreaming: false, - endTime: Date.now() + endTime: Date.now(), + durationMs: toolEvent.duration_ms, + queueWaitMs: toolEvent.queue_wait_ms, + preflightMs: toolEvent.preflight_ms, + confirmationWaitMs: toolEvent.confirmation_wait_ms, + executionMs: toolEvent.execution_ms }; store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, updates as any); @@ -436,12 +441,18 @@ function handleFailed( toolResult: { result: null, success: false, - error: toolEvent.error + error: toolEvent.error, + duration_ms: toolEvent.duration_ms }, status: 'error', requiresConfirmation: false, acpPermission: undefined, - endTime: Date.now() + endTime: Date.now(), + durationMs: toolEvent.duration_ms, + queueWaitMs: toolEvent.queue_wait_ms, + preflightMs: toolEvent.preflight_ms, + confirmationWaitMs: toolEvent.confirmation_wait_ms, + executionMs: toolEvent.execution_ms } as any); store.clearSessionNeedsAttention(sessionId); @@ -467,12 +478,18 @@ function handleCancelled( toolResult: { result: null, success: false, - error: toolEvent.reason || 'User cancelled operation' + error: toolEvent.reason || 'User cancelled operation', + duration_ms: toolEvent.duration_ms }, status: finalStatus, requiresConfirmation: false, acpPermission: undefined, - endTime: Date.now() + endTime: Date.now(), + durationMs: toolEvent.duration_ms, + queueWaitMs: toolEvent.queue_wait_ms, + preflightMs: toolEvent.preflight_ms, + confirmationWaitMs: toolEvent.confirmation_wait_ms, + executionMs: toolEvent.execution_ms } as any); store.clearSessionNeedsAttention(sessionId); diff --git a/src/web-ui/src/flow_chat/services/usageReportService.test.ts b/src/web-ui/src/flow_chat/services/usageReportService.test.ts index 50baf2de8..57a46f078 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.test.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.test.ts @@ -19,7 +19,7 @@ vi.mock('@/shared/notification-system', () => ({ }, })); -const createSession = (): Session => ({ +const createSession = (overrides: Partial = {}): Session => ({ sessionId: 'session-1', title: 'Session 1', dialogTurns: [], @@ -34,9 +34,10 @@ const createSession = (): Session => ({ mode: 'agentic', workspacePath: 'D:/workspace/BitFun', isTransient: false, + ...overrides, }); -const usageReport = (): SessionUsageReport => ({ +const usageReport = (overrides: Partial = {}): SessionUsageReport => ({ schemaVersion: 1, reportId: 'usage-report-1', sessionId: 'session-1', @@ -93,6 +94,7 @@ const usageReport = (): SessionUsageReport => ({ fileContentsIncluded: false, redactedFields: [], }, + ...overrides, }); describe('runUsageReportCommand', () => { @@ -156,4 +158,154 @@ describe('runUsageReportCommand', () => { }); expect(sessionApiMocks.saveSessionTurn).toHaveBeenCalledTimes(1); }); + + it('infers legacy model rows from the session model without showing raw missing-model copy', async () => { + const session = createSession({ + config: { agentType: 'agentic', modelName: 'gpt-5.4' }, + }); + flowChatStore.setState((): FlowChatState => ({ + sessions: new Map([['session-1', session]]), + activeSessionId: 'session-1', + })); + sessionApiMocks.getSessionUsageReport.mockResolvedValue(usageReport({ + models: [{ + modelId: 'unknown_model', + callCount: 2, + durationMs: 420, + }], + slowest: [{ + label: 'unknown_model', + kind: 'model', + durationMs: 420, + redacted: false, + }], + })); + const { runUsageReportCommand } = await import('./usageReportService'); + + const result = await runUsageReportCommand({ + session, + isProcessing: false, + busyMessage: 'busy', + noWorkspaceMessage: 'missing workspace', + failedTitle: 'failed', + unknownErrorMessage: 'unknown', + loadingMarkdown: 'Generating usage report...', + }); + + expect(result.report?.models[0]).toMatchObject({ + modelId: 'gpt-5.4', + modelIdSource: 'inferred_session_model', + }); + expect(result.report?.slowest[0]).toMatchObject({ + label: 'gpt-5.4', + modelIdSource: 'inferred_session_model', + }); + expect(result.report?.slowest[0].label).not.toBe('unknown_model'); + + const finalTurn = flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0]; + expect(finalTurn?.userMessage.metadata?.usageReport).toMatchObject({ + models: [expect.objectContaining({ + modelId: 'gpt-5.4', + modelIdSource: 'inferred_session_model', + })], + }); + expect(finalTurn?.userMessage.content).toContain('gpt-5.4 (inferred)'); + expect(finalTurn?.userMessage.content).not.toContain('Model not recorded'); + }); + + it('does not infer legacy model rows from opaque session model identifiers', async () => { + const opaqueModelId = '019e0c07-c7bc-73f1-b1d6-5260ed215fe0'; + const session = createSession({ + config: { agentType: 'agentic', modelName: opaqueModelId }, + }); + flowChatStore.setState((): FlowChatState => ({ + sessions: new Map([['session-1', session]]), + activeSessionId: 'session-1', + })); + sessionApiMocks.getSessionUsageReport.mockResolvedValue(usageReport({ + models: [{ + modelId: 'unknown_model', + callCount: 1, + durationMs: 120, + }], + slowest: [{ + label: 'unknown_model', + kind: 'model', + durationMs: 120, + redacted: false, + }], + })); + const { runUsageReportCommand } = await import('./usageReportService'); + + const result = await runUsageReportCommand({ + session, + isProcessing: false, + busyMessage: 'busy', + noWorkspaceMessage: 'missing workspace', + failedTitle: 'failed', + unknownErrorMessage: 'unknown', + loadingMarkdown: 'Generating usage report...', + }); + + expect(result.report?.models[0]).toMatchObject({ + modelId: 'unknown_model', + modelIdSource: 'legacy_missing', + }); + expect(result.report?.slowest[0]).toMatchObject({ + label: 'unknown_model', + modelIdSource: 'legacy_missing', + }); + + const finalTurn = flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0]; + expect(finalTurn?.userMessage.content).toContain('Legacy model not tracked'); + expect(finalTurn?.userMessage.content).not.toContain(opaqueModelId); + expect(finalTurn?.userMessage.content).not.toContain('(inferred)'); + }); + + it('treats legacy model round placeholders as missing model identity', async () => { + const session = createSession({ + config: { agentType: 'agentic', modelName: '019e0c07-c7bc-73f1-b1d6-5260ed215fe0' }, + }); + flowChatStore.setState((): FlowChatState => ({ + sessions: new Map([['session-1', session]]), + activeSessionId: 'session-1', + })); + sessionApiMocks.getSessionUsageReport.mockResolvedValue(usageReport({ + models: [{ + modelId: 'model round 0', + callCount: 1, + durationMs: 120, + }], + slowest: [{ + label: 'model round 0', + kind: 'model', + durationMs: 120, + redacted: false, + }], + })); + const { runUsageReportCommand } = await import('./usageReportService'); + + const result = await runUsageReportCommand({ + session, + isProcessing: false, + busyMessage: 'busy', + noWorkspaceMessage: 'missing workspace', + failedTitle: 'failed', + unknownErrorMessage: 'unknown', + loadingMarkdown: 'Generating usage report...', + }); + + expect(result.report?.models[0]).toMatchObject({ + modelId: 'unknown_model', + modelIdSource: 'legacy_missing', + }); + expect(result.report?.slowest[0]).toMatchObject({ + label: 'unknown_model', + modelIdSource: 'legacy_missing', + }); + + const finalTurn = flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0]; + expect(finalTurn?.userMessage.content).toContain('Legacy model not tracked'); + expect(finalTurn?.userMessage.content).not.toContain('model round'); + }); }); diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index 209c682f5..5e83b1aba 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -5,6 +5,11 @@ import type { DialogTurnData } from '@/shared/types/session-history'; import { flowChatStore } from '../store/FlowChatStore'; import type { DialogTurn, Session } from '../types/flow-chat'; +const UNKNOWN_MODEL_ID = 'unknown_model'; +const LEGACY_MODEL_LABEL = 'Legacy model not tracked'; +const LEGACY_MODEL_ROUND_LABEL_PATTERN = /^model\s+round\s+\d+$/i; +type UsageModelIdentitySource = NonNullable; + export interface UsageReportCommandParams { session: Session; isProcessing: boolean; @@ -47,12 +52,13 @@ export async function runUsageReportCommand( let finalizedPendingTurn = false; try { - const report = await sessionAPI.getSessionUsageReport({ + const rawReport = await sessionAPI.getSessionUsageReport({ sessionId: params.session.sessionId, workspacePath: params.session.workspacePath, remoteConnectionId: params.session.remoteConnectionId, remoteSshHost: params.session.remoteSshHost, }); + const report = enrichUsageReportModelIdentity(rawReport, params.session); const markdown = renderUsageReportMarkdown(report); const turn = pendingTurn ? updatePendingUsageReportTurn({ @@ -96,31 +102,66 @@ export async function runUsageReportCommand( } } +export function enrichUsageReportModelIdentity( + report: SessionUsageReport, + session: Session +): SessionUsageReport { + const inferredModelId = getInferableSessionModelId(session); + + return { + ...report, + models: report.models.map(model => { + const identity = resolveModelIdentity(model.modelId, model.modelIdSource, inferredModelId); + return { + ...model, + modelId: identity.modelId, + modelIdSource: identity.source, + }; + }), + slowest: report.slowest.map(span => { + if (span.kind !== 'model') { + return span; + } + const identity = resolveModelIdentity(span.label, span.modelIdSource, inferredModelId); + return { + ...span, + label: identity.modelId, + modelIdSource: identity.source, + }; + }), + }; +} + function updatePendingUsageReportTurn(params: { sessionId: string; dialogTurnId: string; markdown: string; report: SessionUsageReport; }): DialogTurn | null { - flowChatStore.updateDialogTurn(params.sessionId, params.dialogTurnId, turn => ({ - ...turn, - status: 'completed', - userMessage: { - ...turn.userMessage, - content: params.markdown, - timestamp: params.report.generatedAt, - metadata: { - ...turn.userMessage.metadata, - reportId: params.report.reportId, - schemaVersion: params.report.schemaVersion, - generatedAt: params.report.generatedAt, - usageReportStatus: 'completed', - usageReport: params.report as unknown as Record, + flowChatStore.updateDialogTurn( + params.sessionId, + params.dialogTurnId, + turn => ({ + ...turn, + status: 'completed', + userMessage: { + ...turn.userMessage, + content: params.markdown, + timestamp: params.report.generatedAt, + metadata: { + ...turn.userMessage.metadata, + reportId: params.report.reportId, + schemaVersion: params.report.schemaVersion, + generatedAt: params.report.generatedAt, + usageReportStatus: 'completed', + usageReport: params.report as unknown as Record, + }, }, - }, - startTime: params.report.generatedAt, - endTime: params.report.generatedAt, - })); + startTime: params.report.generatedAt, + endTime: params.report.generatedAt, + }), + { touchActivity: false }, + ); return flowChatStore.getState().sessions .get(params.sessionId) @@ -195,7 +236,7 @@ export function renderUsageReportMarkdown(report: SessionUsageReport): string { '| Label | Kind | Duration |', '| --- | --- | --- |', ...report.slowest.map(span => - `| ${span.redacted ? 'redacted' : escapeMarkdown(span.label)} | ${span.kind} | ${formatDuration(span.durationMs)} |` + `| ${span.redacted ? 'redacted' : escapeMarkdown(formatUsageMarkdownLabel(span.label, span.modelIdSource))} | ${span.kind} | ${formatDuration(span.durationMs)} |` ), '', ); @@ -268,6 +309,71 @@ function formatDuration(value: number | undefined): string { return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`; } +function getInferableSessionModelId(session: Session): string | undefined { + const modelId = session.config.modelName?.trim(); + if (!modelId || isMissingModelId(modelId)) { + return undefined; + } + const normalizedModelId = modelId.toLowerCase(); + if ( + normalizedModelId === 'auto' || + normalizedModelId === 'default' || + normalizedModelId === 'primary' || + normalizedModelId === 'fast' || + isOpaqueModelIdentifier(modelId) + ) { + return undefined; + } + return modelId; +} + +function isOpaqueModelIdentifier(modelId: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(modelId) || + /^[0-9a-f]{32}$/i.test(modelId); +} + +function resolveModelIdentity( + modelId: string | undefined, + source: UsageModelIdentitySource | undefined, + inferredModelId: string | undefined +): { + modelId: string; + source: UsageModelIdentitySource; +} { + if (modelId && !isMissingModelId(modelId)) { + return { + modelId, + source: source ?? 'recorded', + }; + } + + if (inferredModelId) { + return { + modelId: inferredModelId, + source: 'inferred_session_model', + }; + } + + return { + modelId: UNKNOWN_MODEL_ID, + source: source ?? 'legacy_missing', + }; +} + +function isMissingModelId(modelId: string | undefined): boolean { + return !modelId || modelId === UNKNOWN_MODEL_ID || LEGACY_MODEL_ROUND_LABEL_PATTERN.test(modelId.trim()); +} + +function formatUsageMarkdownLabel( + value: string, + source?: UsageModelIdentitySource +): string { + if (source === 'inferred_session_model' && value && !isMissingModelId(value)) { + return `${value} (inferred)`; + } + return isMissingModelId(value) || source === 'legacy_missing' ? LEGACY_MODEL_LABEL : value; +} + function escapeMarkdown(value: string): string { return value.replace(/\|/g, '\\|'); } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 1c8eb75c7..466fef2c5 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -74,7 +74,7 @@ describe('FlowChatStore local usage reports', () => { }); it('inserts a local usage report as user-visible content', () => { - const session = createSession(); + const session = createSession({ lastActiveAt: 1234 }); flowChatStore.setState(() => ({ sessions: new Map([[session.sessionId, session]]), activeSessionId: session.sessionId, @@ -96,6 +96,44 @@ describe('FlowChatStore local usage reports', () => { localCommandKind: 'usage_report', modelVisible: false, }); + expect(flowChatStore.getState().sessions.get(session.sessionId)?.lastActiveAt) + .toBe(1234); + }); + + it('can update local usage reports without touching session activity', () => { + const session = createSession({ lastActiveAt: 4321 }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + const turn = flowChatStore.addLocalUsageReportTurn({ + sessionId: session.sessionId, + markdown: '# Loading', + reportId: 'usage-1', + schemaVersion: 1, + generatedAt: 10, + status: 'loading', + }); + + expect(turn).not.toBeNull(); + flowChatStore.updateDialogTurn( + session.sessionId, + turn!.id, + current => ({ + ...current, + status: 'completed', + userMessage: { + ...current.userMessage, + content: '# Complete', + }, + }), + { touchActivity: false }, + ); + + const stored = flowChatStore.getState().sessions.get(session.sessionId); + expect(stored?.dialogTurns[0].userMessage.content).toBe('# Complete'); + expect(stored?.lastActiveAt).toBe(4321); }); it('appends repeated usage reports as separate snapshots', () => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index fd0ac3eb9..192f3a7c1 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -842,7 +842,25 @@ export class FlowChatStore { backendTurnIndex: turnIndex, }; - this.addDialogTurn(params.sessionId, dialogTurn); + this.setState(prev => { + const currentSession = prev.sessions.get(params.sessionId); + if (!currentSession) return prev; + + if (currentSession.dialogTurns.some(turn => turn.id === dialogTurn.id)) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(params.sessionId, { + ...currentSession, + dialogTurns: [...currentSession.dialogTurns, dialogTurn], + }); + + return { + ...prev, + sessions: newSessions, + }; + }); return dialogTurn; } @@ -896,7 +914,12 @@ export class FlowChatStore { }); } - public updateDialogTurn(sessionId: string, dialogTurnId: string, updater: (turn: DialogTurn) => DialogTurn): void { + public updateDialogTurn( + sessionId: string, + dialogTurnId: string, + updater: (turn: DialogTurn) => DialogTurn, + options?: { touchActivity?: boolean } + ): void { this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; @@ -908,7 +931,9 @@ export class FlowChatStore { const updatedSession = { ...session, dialogTurns: updatedDialogTurns, - lastActiveAt: Date.now() + lastActiveAt: options?.touchActivity === false + ? session.lastActiveAt + : Date.now() }; const newSessions = new Map(prev.sessions); @@ -1579,9 +1604,13 @@ export class FlowChatStore { startTime: (item as any).startTime || item.timestamp, endTime: (item as any).endTime, status: item.status, - durationMs: (item as any).endTime - ? (item as any).endTime - (item as any).startTime - : undefined + durationMs: (item as any).durationMs ?? ((item as any).endTime + ? (item as any).endTime - (item as any).startTime + : undefined), + queueWaitMs: (item as any).queueWaitMs, + preflightMs: (item as any).preflightMs, + confirmationWaitMs: (item as any).confirmationWaitMs, + executionMs: (item as any).executionMs, })); const thinkingItems = round.items @@ -1606,6 +1635,16 @@ export class FlowChatStore { thinkingItems, startTime: round.startTime, endTime: round.endTime || Date.now(), + durationMs: round.durationMs, + providerId: round.providerId, + modelId: round.modelId, + modelAlias: round.modelAlias, + firstChunkMs: round.firstChunkMs, + firstVisibleOutputMs: round.firstVisibleOutputMs, + streamDurationMs: round.streamDurationMs, + attemptCount: round.attemptCount, + failureCategory: round.failureCategory, + tokenDetails: round.tokenDetails, status: round.status }; }), @@ -1902,6 +1941,11 @@ export class FlowChatStore { aiIntent: tool.aiIntent, startTime: tool.startTime, endTime: tool.endTime, + durationMs: tool.durationMs, + queueWaitMs: tool.queueWaitMs, + preflightMs: tool.preflightMs, + confirmationWaitMs: tool.confirmationWaitMs, + executionMs: tool.executionMs, timestamp: tool.startTime, status: normalizeRecoveredToolStatus( tool.status, @@ -1938,6 +1982,16 @@ export class FlowChatStore { status: normalizedRoundStatus, startTime: round.startTime ?? round.timestamp, endTime: round.endTime, + durationMs: round.durationMs, + providerId: round.providerId, + modelId: round.modelId, + modelAlias: round.modelAlias, + firstChunkMs: round.firstChunkMs, + firstVisibleOutputMs: round.firstVisibleOutputMs, + streamDurationMs: round.streamDurationMs, + attemptCount: round.attemptCount, + failureCategory: round.failureCategory, + tokenDetails: round.tokenDetails, timestamp: round.timestamp, }; }), diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 984dd1c8c..67776545a 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -84,6 +84,11 @@ export interface FlowToolItem extends FlowItem { aiIntent?: string; // AI rationale for calling the tool. startTime?: number; // Tool start time. endTime?: number; // Tool end time. + durationMs?: number; + queueWaitMs?: number; + preflightMs?: number; + confirmationWaitMs?: number; + executionMs?: number; // Streaming parameter buffering. isParamsStreaming?: boolean; // Params are streaming in. @@ -145,6 +150,16 @@ export interface ModelRound { status: 'pending' | 'streaming' | 'completed' | 'cancelled' | 'error' | 'pending_confirmation'; startTime: number; endTime?: number; + durationMs?: number; + providerId?: string; + modelId?: string; + modelAlias?: string; + firstChunkMs?: number; + firstVisibleOutputMs?: number; + streamDurationMs?: number; + attemptCount?: number; + failureCategory?: string; + tokenDetails?: unknown; error?: string; renderHints?: ModelRoundRenderHints; } diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 57355781b..3e86d3bc9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -171,6 +171,22 @@ export interface UserSteeringInjectedEvent extends AgenticEvent { displayContent: string; } +export interface ModelRoundCompletedEvent extends AgenticEvent { + turnId: string; + roundId: string; + hasToolCalls?: boolean; + durationMs?: number; + providerId?: string; + modelId?: string; + modelAlias?: string; + firstChunkMs?: number; + firstVisibleOutputMs?: number; + streamDurationMs?: number; + attemptCount?: number; + failureCategory?: string; + tokenDetails?: unknown; +} + export interface CompressionEvent extends AgenticEvent { compressionId: string; @@ -409,6 +425,10 @@ export class AgentAPI { return api.listen('agentic://model-round-started', callback); } + onModelRoundCompleted(callback: (event: ModelRoundCompletedEvent) => void): () => void { + return api.listen('agentic://model-round-completed', callback); + } + onTextChunk(callback: (event: TextChunkEvent) => void): () => void { return api.listen('agentic://text-chunk', callback); diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index e34abebfe..4630d8de3 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -10,6 +10,8 @@ export interface SessionUsageReportRequest { remoteSshHost?: string; } +export type UsageModelIdentitySource = 'recorded' | 'inferred_session_model' | 'legacy_missing'; + export interface SessionUsageReport { schemaVersion: number; reportId: string; @@ -55,6 +57,7 @@ export interface SessionUsageReport { }; models: Array<{ modelId: string; + modelIdSource?: UsageModelIdentitySource; callCount: number; inputTokens?: number; outputTokens?: number; @@ -113,6 +116,9 @@ export interface SessionUsageReport { kind: 'model' | 'tool' | 'turn'; durationMs: number; redacted: boolean; + turnId?: string; + turnIndex?: number; + modelIdSource?: UsageModelIdentitySource; }>; privacy: { promptContentIncluded: boolean; diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 4444aa6f4..1db855337 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -125,6 +125,16 @@ export interface ModelRoundData { thinkingItems?: ThinkingItemData[]; startTime: number; endTime?: number; + durationMs?: number; + providerId?: string; + modelId?: string; + modelAlias?: string; + firstChunkMs?: number; + firstVisibleOutputMs?: number; + streamDurationMs?: number; + attemptCount?: number; + failureCategory?: string; + tokenDetails?: unknown; status: string; } @@ -167,6 +177,10 @@ export interface ToolItemData { startTime: number; endTime?: number; durationMs?: number; + queueWaitMs?: number; + preflightMs?: number; + confirmationWaitMs?: number; + executionMs?: number; orderIndex?: number; status?: string; interruptionReason?: 'app_restart'; From 8501026856b31a5306d6bad0015ebb2f50a258db Mon Sep 17 00:00:00 2001 From: limit_yan Date: Mon, 11 May 2026 17:08:01 +0800 Subject: [PATCH 2/3] feat(usage): add report details and diff actions --- .../components/panels/base/FlexiblePanel.tsx | 1 + .../components/ChatInputWorkspaceStrip.scss | 22 +- .../components/ChatInputWorkspaceStrip.tsx | 2 +- .../ChatInputWorkspaceStripLayout.test.ts | 26 + .../components/modern/SessionFilesBadge.tsx | 49 +- .../components/modern/UserMessageItem.tsx | 4 +- .../usage/SessionUsageComponents.test.tsx | 647 +++++++++++++++++- .../components/usage/SessionUsagePanel.scss | 151 ++++ .../components/usage/SessionUsagePanel.tsx | 434 ++++++++++-- .../usage/SessionUsageReportCard.scss | 128 +++- .../usage/SessionUsageReportCard.tsx | 259 +++++-- .../usage/sessionUsagePanelTypes.ts | 1 + .../components/usage/usageReportUtils.test.ts | 62 ++ .../components/usage/usageReportUtils.ts | 116 ++++ .../flow_chat/events/flowchatNavigation.ts | 2 +- .../services/openSessionUsageReport.ts | 4 +- src/web-ui/src/locales/en-US/flow-chat.json | 82 ++- src/web-ui/src/locales/zh-CN/flow-chat.json | 82 ++- src/web-ui/src/locales/zh-TW/flow-chat.json | 82 ++- 19 files changed, 1968 insertions(+), 186 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts create mode 100644 src/web-ui/src/flow_chat/components/usage/sessionUsagePanelTypes.ts diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index f516222e9..a046bced2 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -738,6 +738,7 @@ const FlexiblePanel: React.FC = memo(({ markdown={content.data?.markdown} sessionId={content.data?.sessionId} workspacePath={content.data?.workspacePath || workspacePath} + initialTab={content.data?.initialTab} /> ); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 8f02ba8d1..36acf646a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -64,7 +64,7 @@ &--split &__main { flex: 1 1 auto; - max-width: calc(100% - 22px); + max-width: calc(100% - 24px); } &__usage { @@ -74,19 +74,19 @@ justify-content: flex-end; } - /* Match strip text cap height (--flowchat-font-size-xxs = 10px); tight hit target ≈ line box */ + /* Keep the usage action compact, but large enough to read beside the strip text. */ &__usage-btn.icon-btn { - width: 14px; - height: 14px; - min-width: 14px; + width: 16px; + height: 16px; + min-width: 16px; border-radius: 4px; - opacity: 0.72; - color: var(--color-text-secondary); + opacity: 0.86; + color: color-mix(in srgb, var(--color-accent-500) 62%, var(--color-text-secondary)); &:hover:not(:disabled) { transform: none; - background: var(--element-bg-medium); - color: var(--color-text-primary); + background: color-mix(in srgb, var(--color-accent-500) 10%, var(--element-bg-medium)); + color: color-mix(in srgb, var(--color-accent-500) 86%, var(--color-text-primary)); opacity: 1; } @@ -96,8 +96,8 @@ svg { flex-shrink: 0; - width: 10px; - height: 10px; + width: 14px; + height: 14px; } } diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 540d0b1da..fb4b66f95 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -109,7 +109,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( usageReport.onOpen(); }} > - + diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts new file mode 100644 index 000000000..2990c4dfb --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readWorkspaceStripStylesheet(): string { + const stylesheet = readFileSync( + fileURLToPath(new URL('./ChatInputWorkspaceStrip.scss', import.meta.url)), + 'utf8', + ); + return stylesheet.replace(/\r\n/g, '\n'); +} + +describe('ChatInputWorkspaceStrip layout styles', () => { + it('keeps the session usage action visible without overpowering the strip', () => { + const stylesheet = readWorkspaceStripStylesheet(); + + expect(stylesheet).toContain('max-width: calc(100% - 24px);'); + expect(stylesheet).toContain('width: 16px;'); + expect(stylesheet).toContain('height: 16px;'); + expect(stylesheet).toContain('min-width: 16px;'); + expect(stylesheet).toContain('width: 14px;'); + expect(stylesheet).toContain('height: 14px;'); + expect(stylesheet).toContain('color: color-mix(in srgb, var(--color-accent-500) 62%, var(--color-text-secondary));'); + expect(stylesheet).toContain('color: color-mix(in srgb, var(--color-accent-500) 86%, var(--color-text-primary));'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx index 19b09cc87..b60acb156 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFilesBadge.tsx @@ -47,6 +47,7 @@ import { DEFAULT_QUICK_ACTIONS, type QuickAction, } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { resolveQuickActionText } from '@/infrastructure/config/services/quickActionLocalization'; import './SessionFilesBadge.scss'; const log = createLogger('SessionFilesBadge'); @@ -736,17 +737,18 @@ export const SessionFilesBadge: React.FC = ({ const handleQuickActionClick = useCallback(async (action: QuickAction) => { if (!sessionId || isSessionProcessing) return; setIsReviewMenuOpen(false); + const actionText = resolveQuickActionText(action, t); try { const { FlowChatManager } = await import('../../services/FlowChatManager'); await FlowChatManager.getInstance().sendMessage( - action.prompt, + actionText.prompt, sessionId, - action.label, + actionText.label, ); } catch (error) { log.error('Failed to trigger quick action', { actionId: action.id, error }); } - }, [sessionId, isSessionProcessing]); + }, [sessionId, isSessionProcessing, t]); const getOperationIcon = (operationType: 'write' | 'edit' | 'delete') => { switch (operationType) { @@ -851,25 +853,28 @@ export const SessionFilesBadge: React.FC = ({
)} - {quickActions.filter(a => a.enabled).map(action => ( - - ))} + {quickActions.filter(a => a.enabled).map(action => { + const actionText = resolveQuickActionText(action, t); + return ( + + ); + })}
)} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 8377af026..cddd3337f 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -17,6 +17,7 @@ import { ReproductionStepsBlock, Tooltip, confirmDanger } from '@/component-libr import { createLogger } from '@/shared/utils/logger'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; import { SessionUsageReportCard } from '../usage/SessionUsageReportCard'; +import type { SessionUsagePanelTab } from '../usage/sessionUsagePanelTypes'; import { coerceSessionUsageReport } from '../usage/usageReportUtils'; import './UserMessageItem.scss'; @@ -188,13 +189,14 @@ export const UserMessageItem = React.memo( }); }, [messageContent]); - const handleOpenUsageReport = useCallback((report: SessionUsageReport) => { + const handleOpenUsageReport = useCallback((report: SessionUsageReport, initialTab?: SessionUsagePanelTab) => { void import('../../services/openSessionUsageReport').then(({ openSessionUsagePanel }) => { openSessionUsagePanel({ report, markdown: messageContent, sessionId: activeSession?.sessionId ?? sessionId, workspacePath: activeSession?.workspacePath, + initialTab, title: t('usage.title'), expand: true, }); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index eb3eea130..f8640c43a 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -6,15 +6,35 @@ import fs from 'node:fs'; import path from 'node:path'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; +import { globalEventBus } from '@/infrastructure/event-bus'; import enFlowChat from '@/locales/en-US/flow-chat.json'; import zhCnFlowChat from '@/locales/zh-CN/flow-chat.json'; import zhTwFlowChat from '@/locales/zh-TW/flow-chat.json'; +import { FLOWCHAT_PIN_TURN_TO_TOP_EVENT, type FlowChatPinTurnToTopRequest } from '../../events/flowchatNavigation'; import { SessionRuntimeStatusEntry } from './SessionRuntimeStatusEntry'; import { SessionUsagePanel } from './SessionUsagePanel'; import { SessionUsageReportCard } from './SessionUsageReportCard'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const snapshotApiMocks = vi.hoisted(() => ({ + getOperationDiff: vi.fn(), +})); + +const tabUtilsMocks = vi.hoisted(() => ({ + createDiffEditorTab: vi.fn(), +})); + +vi.mock('@/infrastructure/api', () => ({ + snapshotAPI: { + getOperationDiff: snapshotApiMocks.getOperationDiff, + }, +})); + +vi.mock('@/shared/utils/tabUtils', () => ({ + createDiffEditorTab: tabUtilsMocks.createDiffEditorTab, +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => { @@ -34,9 +54,14 @@ vi.mock('react-i18next', () => ({ 'usage.actions.copySessionId': 'Copy session ID', 'usage.actions.copyWorkspacePath': 'Copy project path', 'usage.actions.openDetails': 'Open details', - 'usage.coverage.complete': 'Complete', - 'usage.coverage.partial': 'Partial', - 'usage.coverage.minimal': 'Minimal', + 'usage.actions.openFileDiff': 'Open diff', + 'usage.actions.openSectionDetails': 'Open {{section}} details', + 'usage.actions.jumpToTurn': 'Jump to this turn', + 'usage.actions.viewDetails': 'Details', + 'usage.actions.viewAllSection': 'View all {{count}}', + 'usage.coverage.complete': 'Complete data', + 'usage.coverage.partial': 'Partial data', + 'usage.coverage.minimal': 'Minimal data', 'usage.coverage.partialNotice': 'Some metrics were not reported by this session or provider. Hover underlined values for the specific reason.', 'usage.toolCategories.git': 'Git', 'usage.toolCategories.shell': 'Shell', @@ -55,10 +80,15 @@ vi.mock('react-i18next', () => ({ 'usage.status.cacheNotReported': 'Cache not reported', 'usage.status.noFileChanges': 'No file changes', 'usage.status.notRecorded': 'Not recorded', + 'usage.status.modelNotRecorded': 'Model not recorded', + 'usage.status.legacyModel': 'Legacy model not tracked', + 'usage.status.inferredModel': '{{model}} (inferred)', 'usage.card.heading': 'Session statistics', + 'usage.card.eyebrow': 'Local report', 'usage.card.turns': '{{count}} turns', 'usage.card.calls': '{{count}} calls', 'usage.card.operations': '{{count}} ops', + 'usage.card.tokens': '{{value}} tokens', 'usage.loading.title': 'Generating usage report', 'usage.loading.description': 'Reading local session records and preparing a privacy-safe summary.', 'usage.loading.steps.collecting': 'Reading session records', @@ -72,9 +102,11 @@ vi.mock('react-i18next', () => ({ 'usage.metrics.cached': 'Cached', 'usage.metrics.files': 'Files', 'usage.metrics.errors': 'Errors', + 'usage.metrics.errorRate': 'Error rate', 'usage.sections.models': 'Models', 'usage.sections.tools': 'Tools', 'usage.sections.files': 'Files', + 'usage.sections.slowest': 'Slowest spans', 'usage.empty.models': 'No model metrics', 'usage.empty.modelsDescription': 'Model rows appear after calls report token usage.', 'usage.empty.tools': 'No tool metrics', @@ -84,16 +116,27 @@ vi.mock('react-i18next', () => ({ 'usage.empty.errors': 'No error examples', 'usage.empty.errorsDescription': 'No sampled tool or model errors were recorded.', 'usage.help.wall': 'Span from the first recorded turn start to the last recorded turn end. Idle gaps can be included.', - 'usage.help.active': 'Sum of recorded turn durations that produced reportable activity. It can include orchestration or waiting inside a turn.', + 'usage.help.active': 'Union of recorded turn spans that produced reportable activity. It can include orchestration or waiting inside a turn.', 'usage.help.timeShare': 'Share of recorded turn time. Model and tool spans may overlap, so this is only an approximate indicator.', - 'usage.help.modelRoundTime': 'Recorded model-round duration from persisted start and end timestamps, not pure model streaming or throughput time. The percentage uses recorded turn time and is approximate.', + 'usage.help.modelRoundTime': 'Recorded model-round duration from persisted runtime metadata or start/end timestamps, not pure provider streaming or throughput time. The percentage uses recorded turn time and is approximate.', 'usage.help.toolTime': 'Recorded tool-call duration. The percentage uses recorded turn time and is approximate.', 'usage.help.cachedTokens': 'The provider did not report cache-read token metadata for this session. Total token counts are still shown when available.', 'usage.help.cachedTokensPartial': 'Only some calls reported cache-read token metadata, so the cached-token total covers those calls only.', + 'usage.help.legacyModel': 'Older sessions did not store per-round model names.', + 'usage.help.inferredModel': 'Inferred from the session model setting.', 'usage.help.filesUnavailable': 'No file snapshot or file-edit tool record was found for this session.', 'usage.help.filesNoRecordedChanges': 'BitFun did not detect file changes in this session. This is expected when the agent did not edit files.', - 'usage.help.filesRemoteUnavailable': 'Remote session file snapshots are not included in this report yet. File rows appear only when tool records identify edited files.', + 'usage.help.filesRemoteUnavailable': 'No remote snapshot summary was found for this session. File rows can still appear from recognized file-edit tool records.', 'usage.help.filesNotTracked': 'No local snapshot or identifiable file-edit tool record was found for this session.', + 'usage.help.fileDiffUnavailable': 'Diff links require a snapshot-backed file row and a visible file path.', + 'usage.help.errors': 'Counts model turns that ended in error plus tool calls whose result was unsuccessful.', + 'usage.help.toolErrors': 'Tool errors count unsuccessful tool calls.', + 'usage.help.modelErrors': 'Model errors count dialog turns that ended in the error state.', + 'usage.help.errorExamples': 'Examples are grouped by safe labels only; raw provider responses, tool inputs, and command output stay out of the report.', + 'usage.help.errorExampleRow': 'Safe grouped label for an error type.', + 'usage.help.errorExampleCount': 'Number of matching errors.', + 'usage.help.slowestSpans': 'Slow spans are derived from recorded turn, model, and tool timestamps. They help identify time sinks, not exact provider latency.', + 'usage.help.slowestModelCall': 'Model call in this turn. Model: {{model}}.', 'usage.meta.generatedAt': 'Generated', 'usage.meta.sessionId': 'Session ID', 'usage.meta.workspacePath': 'Project path', @@ -108,6 +151,7 @@ vi.mock('react-i18next', () => ({ 'usage.panel.fileScope': 'File scope', 'usage.panel.toolErrors': 'Tool errors', 'usage.panel.modelErrors': 'Model errors', + 'usage.panel.errorScope': 'Error scope', 'usage.privacy.title': 'Privacy-safe report', 'usage.privacy.summary': 'Prompts, tool inputs, command outputs, and file contents are not included.', 'usage.tabs.overview': 'Overview', @@ -115,6 +159,7 @@ vi.mock('react-i18next', () => ({ 'usage.tabs.tools': 'Tools', 'usage.tabs.files': 'Files', 'usage.tabs.errors': 'Errors', + 'usage.tabs.slowest': 'Slowest', 'usage.table.model': 'Model', 'usage.table.tool': 'Tool', 'usage.table.category': 'Category', @@ -133,8 +178,18 @@ vi.mock('react-i18next', () => ({ 'usage.table.deleted': 'Deleted', 'usage.table.turns': 'Turns', 'usage.table.operationIds': 'Operation IDs', + 'usage.table.actions': 'Actions', 'usage.table.label': 'Label', 'usage.table.count': 'Count', + 'usage.table.kind': 'Kind', + 'usage.empty.slowest': 'No slow spans', + 'usage.empty.slowestDescription': 'No timed spans were recorded.', + 'usage.slowestKinds.model': 'Model', + 'usage.slowestKinds.modelCall': 'Model call', + 'usage.slowestKinds.tool': 'Tool', + 'usage.slowestKinds.turn': 'Turn', + 'usage.slowestLabels.modelCall': 'Turn {{turn}} model call', + 'usage.slowestLabels.modelCallUnknown': 'Model call', }; return interpolate(labels[key] ?? key, options); }, @@ -302,6 +357,8 @@ describe('Session usage report UI components', () => { let root: Root; beforeEach(() => { + snapshotApiMocks.getOperationDiff.mockReset(); + tabUtilsMocks.createDiffEditorTab.mockReset(); dom = new JSDOM('
', { pretendToBeVisual: true, }); @@ -345,8 +402,9 @@ describe('Session usage report UI components', () => { const cachedMetric = Array.from(container.querySelectorAll('.session-usage-report-card__metric')) .find(metric => metric.textContent?.includes('Cached')); - expect(container.textContent).toContain('Partial'); + expect(container.textContent).toContain('Partial data'); const partialCoverageBadge = container.querySelector('.session-usage-report-card__coverage'); + expect(partialCoverageBadge).not.toBeNull(); expect(partialCoverageBadge?.parentElement?.getAttribute('data-tooltip')).toContain('Hover underlined values'); expect(cachedMetric?.textContent).toContain('Cache not reported'); expect(cachedMetric?.textContent).not.toMatch(/Cached\s*0/); @@ -356,12 +414,212 @@ describe('Session usage report UI components', () => { .toBe(false); const openButton = container.querySelector('button[aria-label="Open details"]'); + expect(openButton?.textContent).toBe('Details'); + expect(openButton?.className).toContain('session-usage-report-card__details-button'); + expect(container.querySelector('.session-usage-report-card__action-group')).toBeNull(); act(() => { openButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); expect(onOpenDetails).toHaveBeenCalledWith(report); }); + it('explains error totals on the chat card', () => { + const report = usageReport({ + errors: { + totalErrors: 2, + toolErrors: 1, + modelErrors: 1, + examples: [ + { label: 'Write', count: 1, redacted: false }, + { label: 'Model/runtime turn errors', count: 1, redacted: false }, + ], + }, + }); + + render( + + ); + + const errorsMetric = Array.from(container.querySelectorAll('.session-usage-report-card__metric')) + .find(metric => metric.textContent?.includes('Errors')); + expect(errorsMetric?.textContent).toContain('2'); + expect(errorsMetric?.querySelector('[data-tooltip]')?.getAttribute('data-tooltip')) + .toContain('tool calls whose result was unsuccessful'); + }); + + it('offers section detail jumps when summary lists are truncated', () => { + const onOpenDetails = vi.fn(); + const report = usageReport({ + tools: Array.from({ length: 4 }, (_, index) => ({ + toolName: `Tool ${index + 1}`, + category: 'file', + callCount: index + 1, + successCount: index + 1, + errorCount: 0, + durationMs: 1000 + index, + redacted: false, + })), + files: { + scope: 'snapshot_summary', + changedFiles: 5, + addedLines: 18, + deletedLines: 2, + files: Array.from({ length: 5 }, (_, index) => ({ + pathLabel: `src/file-${index + 1}.ts`, + operationCount: index + 1, + addedLines: index + 1, + deletedLines: 0, + redacted: false, + })), + }, + }); + + render( + + ); + + expect(container.textContent).toContain('View all 4'); + expect(container.textContent).toContain('View all 5'); + + const toolsButton = container.querySelector('button[aria-label="Open Tools details"]'); + act(() => { + toolsButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + expect(onOpenDetails).toHaveBeenCalledWith(report, 'tools'); + + const filesButton = container.querySelector('button[aria-label="Open Files details"]'); + act(() => { + filesButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + expect(onOpenDetails).toHaveBeenCalledWith(report, 'files'); + }); + + it('uses semantic file diff colors on the chat card summary', () => { + render( + + ); + + expect(container.querySelector('.session-usage-report-card__file-stat--added')?.textContent).toBe('+4'); + expect(container.querySelector('.session-usage-report-card__file-stat--deleted')?.textContent).toBe('-2'); + }); + + it('keeps chat card file names visible and labels model tokens', () => { + const longPath = 'src/features/session-usage/reports/components/very/deeply/nested/UsageReportCardFilePathThatWouldNormallyOverflow.tsx'; + const fileName = 'UsageReportCardFilePathThatWouldNormallyOverflow.tsx'; + const report = usageReport({ + models: [ + { + modelId: 'gpt-5.4', + callCount: 2, + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + durationMs: 40_000, + }, + ], + files: { + scope: 'snapshot_summary', + changedFiles: 1, + addedLines: 4, + deletedLines: 2, + files: [ + { + pathLabel: longPath, + operationCount: 2, + addedLines: 4, + deletedLines: 2, + redacted: false, + }, + ], + }, + }); + + render( + + ); + + const fileNameLabel = container.querySelector('.session-usage-report-card__mini-list-file-name'); + expect(fileNameLabel?.textContent).toBe(fileName); + expect(container.textContent).not.toContain('src/features'); + expect(container.textContent).not.toContain('/.../'); + expect(container.querySelector(`[data-tooltip="${longPath}"]`)).not.toBeNull(); + expect(container.textContent).toContain('1,500 tokens'); + }); + + it('does not append a token unit when chat card model tokens are unavailable', () => { + const report = usageReport({ + models: [ + { + modelId: 'legacy-model', + callCount: 1, + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + durationMs: 12_000, + }, + ], + }); + + render( + + ); + + expect(container.textContent).toContain('Unavailable'); + expect(container.textContent).not.toContain('Unavailable tokens'); + expect(container.textContent).not.toContain('Unavailable Token'); + }); + + it('keeps the detail coverage badge in the header action area', () => { + render(); + + const headerActions = container.querySelector('.session-usage-panel__header-actions'); + expect(headerActions?.querySelector('.session-usage-panel__badge')?.textContent).toBe('Partial data'); + expect(container.querySelector('.session-usage-panel__title-wrap .session-usage-panel__badge')).toBeNull(); + }); + + it('explains error examples on the detail panel', () => { + const report = usageReport({ + errors: { + totalErrors: 2, + toolErrors: 1, + modelErrors: 1, + examples: [ + { label: 'Write', count: 1, redacted: false }, + { label: 'Model/runtime turn errors', count: 1, redacted: false }, + ], + }, + }); + + render(); + + const errorsTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Errors'); + act(() => { + errorsTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Tool errors'); + expect(container.textContent).toContain('Model errors'); + expect(container.querySelector('[data-tooltip="Safe grouped label for an error type."]')).not.toBeNull(); + expect(container.querySelector('[data-tooltip="Number of matching errors."]')).not.toBeNull(); + }); + it('shows an immediate usage loading card before report data exists', () => { render( { expect(container.textContent).not.toContain('raw provider error with secret payload'); }); + it('shows model duration only when model span facts exist', () => { + render(); + + const modelTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Models'); + act(() => { + modelTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Recorded time'); + expect(container.textContent).toContain('40s'); + + const reportWithoutModelDuration = usageReport({ + models: [ + { + modelId: 'gpt-5.4', + callCount: 1, + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + durationMs: undefined, + }, + ], + }); + + render(); + const modelTabWithoutDuration = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Models'); + act(() => { + modelTabWithoutDuration?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).not.toContain('Recorded time'); + expect(container.textContent).not.toContain('Timing not recorded'); + }); + + it('renders missing tool timings as subdued values', () => { + const report = usageReport({ + tools: [ + { + toolName: 'read_file', + category: 'file', + callCount: 3, + successCount: 3, + errorCount: 0, + durationMs: undefined, + p95DurationMs: undefined, + executionMs: undefined, + redacted: false, + }, + ], + }); + + render(); + + const toolsTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Tools'); + act(() => { + toolsTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const missingValues = container.querySelectorAll('.session-usage-panel__missing-value'); + expect(missingValues).toHaveLength(3); + expect(Array.from(missingValues).every(value => value.textContent === 'Timing not recorded')).toBe(true); + }); + + it('opens the detail panel on a requested usage tab', () => { + render(); + + const filesTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Files'); + expect(filesTab?.className).toContain('session-usage-panel__tab--active'); + expect(container.textContent).toContain('File scope'); + }); + + it('explains inferred legacy model labels on the card and detail panel', () => { + const report = usageReport({ + models: [ + { + modelId: 'gpt-5.4', + modelIdSource: 'inferred_session_model', + callCount: 2, + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + durationMs: 40_000, + }, + ], + }); + + render( + + ); + + expect(container.textContent).toContain('gpt-5.4 (inferred)'); + expect(container.querySelector('[data-tooltip="Inferred from the session model setting."]')).not.toBeNull(); + + render(); + const modelTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Models'); + act(() => { + modelTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('gpt-5.4 (inferred)'); + expect(container.querySelector('[data-tooltip="Inferred from the session model setting."]')).not.toBeNull(); + }); + + it('hides legacy model round placeholders in the detail panel', () => { + const report = usageReport({ + models: [ + { + modelId: 'model round 0', + callCount: 1, + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + durationMs: 12_000, + }, + ], + }); + + render(); + + const modelTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Models'); + act(() => { + modelTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Legacy model not tracked'); + expect(container.textContent).not.toContain('model round 0'); + expect(container.querySelector('[data-tooltip="Older sessions did not store per-round model names."]')).not.toBeNull(); + }); + it('renders the runtime status entry as a lightweight usage trigger', () => { const onOpen = vi.fn(); @@ -475,6 +871,236 @@ describe('Session usage report UI components', () => { .find(node => node.getAttribute('data-tooltip')?.includes('did not detect file changes')); expect(fileUnavailableHelp).toBeTruthy(); }); + + it('keeps file diff actions visible and exposes full paths for long file rows', () => { + const longPath = 'src/web-ui/src/component-library/components/Markdown/Markdown.tsx'; + const report = usageReport({ + files: { + scope: 'snapshot_summary', + changedFiles: 1, + addedLines: 20, + deletedLines: 4, + files: [ + { + pathLabel: longPath, + operationCount: 2, + addedLines: 20, + deletedLines: 4, + turnIndexes: [1], + operationIds: ['operation-1'], + redacted: false, + }, + ], + }, + }); + + render( + + ); + + const filesTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Files'); + act(() => { + filesTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelector('.session-usage-panel__table--files')).not.toBeNull(); + expect(container.querySelector(`[data-tooltip="${longPath}"]`)).not.toBeNull(); + const pathCell = container.querySelector('.session-usage-panel__file-path-cell'); + expect(pathCell?.textContent).toBe('.../Markdown/Markdown.tsx'); + expect(pathCell?.textContent).not.toContain('component-library/components'); + expect(pathCell?.textContent).not.toBe(longPath); + expect(container.textContent).not.toContain('Operation IDs'); + expect(container.textContent).not.toContain('operation-1'); + expect(container.querySelector('.session-usage-panel__sticky-action-cell')).not.toBeNull(); + expect(container.querySelector('.session-usage-panel__table-action')).not.toBeNull(); + }); + + it('opens snapshot-backed file diffs from the detail panel', async () => { + snapshotApiMocks.getOperationDiff.mockResolvedValue({ + filePath: 'D:/workspace/bitfun/src/main.rs', + originalContent: 'before', + modifiedContent: 'after', + anchorLine: 42, + }); + const report = usageReport({ + files: { + scope: 'snapshot_summary', + changedFiles: 1, + addedLines: 2, + deletedLines: 1, + files: [ + { + pathLabel: 'src/main.rs', + operationCount: 1, + addedLines: 2, + deletedLines: 1, + turnIndexes: [1], + operationIds: ['operation-1'], + redacted: false, + }, + ], + }, + }); + + render( + + ); + + const filesTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Files'); + act(() => { + filesTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + const openDiffButton = container.querySelector('button[aria-label="Open diff"]'); + expect(openDiffButton).not.toBeNull(); + + await act(async () => { + openDiffButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(snapshotApiMocks.getOperationDiff).toHaveBeenCalledWith( + 'session-1', + 'D:/workspace/bitfun/src/main.rs', + 'operation-1', + 'D:/workspace/bitfun', + ); + expect(tabUtilsMocks.createDiffEditorTab).toHaveBeenCalledWith( + 'D:/workspace/bitfun/src/main.rs', + 'main.rs', + 'before', + 'after', + true, + 'agent', + 'D:/workspace/bitfun', + 42, + undefined, + { + titleKind: 'diff', + duplicateKeyPrefix: 'diff', + }, + ); + }); + + it('shows slowest spans in the detail panel', () => { + const report = usageReport({ + slowest: [ + { + label: 'turn 2', + kind: 'turn', + durationMs: 95_000, + redacted: false, + turnId: 'turn-2', + turnIndex: 2, + }, + { + label: 'secret shell command', + kind: 'tool', + durationMs: 30_000, + redacted: true, + }, + ], + }); + + const pinEvents: FlowChatPinTurnToTopRequest[] = []; + const unsubscribe = globalEventBus.on( + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + event => pinEvents.push(event), + ); + + render(); + + const slowestTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Slowest'); + act(() => { + slowestTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Slow spans are derived from recorded turn, model, and tool timestamps'); + expect(container.textContent).toContain('turn 2'); + expect(container.textContent).toContain('1m 35s'); + expect(container.textContent).toContain('Redacted'); + expect(container.textContent).not.toContain('secret shell command'); + + const turnLink = container.querySelector('.session-usage-panel__turn-link'); + expect(turnLink?.textContent).toBe('turn 2'); + act(() => { + turnLink?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(pinEvents).toEqual([ + { + sessionId: 'session-1', + turnId: 'turn-2', + behavior: 'smooth', + pinMode: 'transient', + source: 'usage-report', + }, + ]); + unsubscribe(); + }); + + it('labels slow model spans by turn and jumps to that turn', () => { + const report = usageReport({ + slowest: [ + { + label: 'gpt-5.4', + kind: 'model', + durationMs: 42_000, + redacted: false, + turnId: 'turn-4', + turnIndex: 4, + }, + ], + }); + + const pinEvents: FlowChatPinTurnToTopRequest[] = []; + const unsubscribe = globalEventBus.on( + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + event => pinEvents.push(event), + ); + + render(); + + const slowestTab = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + .find(button => button.textContent === 'Slowest'); + act(() => { + slowestTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('Turn 4 model call'); + expect(container.textContent).toContain('Model call'); + expect(container.textContent).not.toContain('gpt-5.4'); + expect(container.querySelector('[data-tooltip="Model call in this turn. Model: gpt-5.4. Jump to this turn"]')) + .not.toBeNull(); + + const turnLink = container.querySelector('.session-usage-panel__turn-link'); + act(() => { + turnLink?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(pinEvents).toEqual([ + { + sessionId: 'session-1', + turnId: 'turn-4', + behavior: 'smooth', + pinMode: 'transient', + source: 'usage-report', + }, + ]); + unsubscribe(); + }); }); describe('Session usage report i18n and theme guards', () => { @@ -534,6 +1160,13 @@ describe('Session usage report i18n and theme guards', () => { expect(styleText).toContain('width: auto;'); expect(styleText).toContain('margin: 0.12rem 3rem'); expect(styleText).toContain('border: 1px solid color-mix(in srgb, var(--border-base)'); + expect(styleText).toContain('grid-template-columns: repeat(3, minmax(116px, 1fr));'); + expect(styleText).toContain('width: clamp(180px, 26vw, 280px);'); + expect(styleText).toContain('max-width: 280px;'); + expect(styleText).toContain('text-overflow: ellipsis;'); + expect(styleText).not.toContain('grid-template-columns: repeat(4, minmax(116px, 1fr));'); + expect(styleText).not.toContain('grid-template-columns: minmax(0, 1fr) auto max-content;'); + expect(styleText).not.toContain('max-width: 72%;'); expect(styleText).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i); }); }); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss index 76e1c8516..6a41450c4 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss @@ -38,6 +38,13 @@ min-width: 0; } + &__header-actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + } + &__badge { flex: 0 0 auto; padding: 2px 7px; @@ -59,6 +66,10 @@ color: var(--color-text-secondary); background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); } + + &--hint { + cursor: help; + } } &__title-main { @@ -315,6 +326,13 @@ } } + &__section-help { + margin: -4px 0 0; + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.45; + } + &__table-wrap { min-width: 0; overflow: auto; @@ -358,6 +376,139 @@ tbody tr:last-child td { border-bottom: 0; } + + &--files { + min-width: 620px; + table-layout: fixed; + + th:nth-child(1), + td:nth-child(1) { + width: clamp(180px, 26vw, 280px); + max-width: 280px; + } + + th:nth-child(2), + td:nth-child(2), + th:nth-child(3), + td:nth-child(3), + th:nth-child(4), + td:nth-child(4), + th:nth-child(5), + td:nth-child(5) { + width: 72px; + } + + th:last-child, + td:last-child { + position: sticky; + right: 0; + z-index: 1; + width: 48px; + min-width: 48px; + max-width: 48px; + padding-right: 6px; + padding-left: 6px; + text-align: center; + background: color-mix(in srgb, var(--color-bg-flowchat) 92%, var(--element-bg-soft)); + box-shadow: -10px 0 14px -14px color-mix(in srgb, var(--color-text-primary) 54%, transparent); + } + + th:last-child { + z-index: 2; + background: color-mix(in srgb, var(--element-bg-soft) 72%, var(--color-bg-flowchat)); + } + } + } + + &__file-path-cell { + max-width: 280px; + } + + &__file-path-display { + display: block; + max-width: 100%; + overflow: hidden; + color: var(--color-text-primary); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__table td &__file-path-display { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__sticky-action-cell { + white-space: nowrap; + } + + &__missing-cell { + color: var(--color-text-muted); + } + + &__table td &__missing-value { + display: inline-flex; + align-items: center; + max-width: none; + min-height: 20px; + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--border-base) 54%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-soft) 58%, transparent); + color: var(--color-text-muted); + font-size: 11px; + line-height: 1; + white-space: nowrap; + + &--help { + cursor: help; + } + } + + &__table-action { + width: 24px; + height: 24px; + } + + &__table-action-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--color-text-muted); + } + + &__turn-link { + display: block; + max-width: 260px; + padding: 0; + border: 0; + background: transparent; + color: var(--accent-primary); + font: inherit; + text-align: left; + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + color: var(--accent-primary-hover, var(--accent-primary)); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-primary) 70%, transparent); + outline-offset: 2px; + border-radius: 3px; + } } &__empty { diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx index 175d52411..aa70bb597 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Activity, @@ -8,11 +8,20 @@ import { Clock3, Database, FileText, + GitCompare, ShieldCheck, Wrench, } from 'lucide-react'; import { IconButton, MarkdownRenderer, Tooltip } from '@/component-library'; +import { snapshotAPI } from '@/infrastructure/api'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { createDiffEditorTab } from '@/shared/utils/tabUtils'; +import { createLogger } from '@/shared/utils/logger'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; import { calculateShare, formatUsageDuration, @@ -25,33 +34,45 @@ import { getFileScopeHelp, getFileScopeLabel, getFileSummaryLabel, + getModelHelp, + getModelLabel, getRedactedLabel, + getSlowSpanHelp, + getSlowSpanLabel, getToolCategoryLabel, } from './usageReportUtils'; +import type { SessionUsagePanelTab } from './sessionUsagePanelTypes'; import './SessionUsagePanel.scss'; -type UsagePanelTab = 'overview' | 'models' | 'tools' | 'files' | 'errors'; - +const log = createLogger('SessionUsagePanel'); interface SessionUsagePanelProps { report?: SessionUsageReport; markdown?: string; sessionId?: string; workspacePath?: string; + initialTab?: SessionUsagePanelTab; } -const TABS: UsagePanelTab[] = ['overview', 'models', 'tools', 'files', 'errors']; +const TABS: SessionUsagePanelTab[] = ['overview', 'models', 'tools', 'files', 'errors', 'slowest']; export const SessionUsagePanel: React.FC = ({ report, markdown = '', sessionId, workspacePath, + initialTab, }) => { const { t } = useTranslation('flow-chat'); - const [activeTab, setActiveTab] = useState('overview'); + const [activeTab, setActiveTab] = useState(initialTab ?? 'overview'); const [copied, setCopied] = useState(false); const [copiedMeta, setCopiedMeta] = useState<'session' | 'workspace' | null>(null); + useEffect(() => { + if (initialTab) { + setActiveTab(initialTab); + } + }, [initialTab]); + const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(markdown); @@ -98,14 +119,19 @@ export const SessionUsagePanel: React.FC = ({ const coverageTone = getCoverageTone(report.coverage.level); const effectiveSessionId = sessionId ?? report.sessionId; const effectiveWorkspacePath = workspacePath ?? report.workspace.pathLabel ?? t('usage.unavailable'); + const coverageBadgeClassName = + `session-usage-panel__badge session-usage-panel__badge--${coverageTone}` + + (report.coverage.level !== 'complete' ? ' session-usage-panel__badge--hint' : ''); + const coverageBadge = ( + + {getCoverageLabel(report.coverage.level, t)} + + ); return (
- - {getCoverageLabel(report.coverage.level, t)} -

{t('usage.title')}

@@ -130,17 +156,24 @@ export const SessionUsagePanel: React.FC = ({
- - - {copied ? : } - - +
+ {report.coverage.level !== 'complete' ? ( + + {coverageBadge} + + ) : coverageBadge} + + + {copied ? : } + + +
); @@ -201,11 +241,19 @@ function UsageMetaRow({ ); } -type UsageTableCell = string | { +type UsageTableValueCell = { value: string; help?: string; + className?: string; }; +type UsageTableNodeCell = { + node: React.ReactNode; + className?: string; +}; + +type UsageTableCell = string | UsageTableValueCell | UsageTableNodeCell; + function UsageValue({ value, help, @@ -223,6 +271,44 @@ function UsageValue({ return help ? {node} : node; } +function UsageMissingValue({ value, help }: { value: string; help?: string }) { + const node = ( + + {value} + + ); + + return help ? {node} : node; +} + +function missingUsageValue(value: string, help?: string): UsageTableCell { + return { + className: 'session-usage-panel__missing-cell', + node: , + }; +} + +function UsageFilePathValue({ pathLabel }: { pathLabel: string }) { + const node = ( + + {getCompactFilePathLabel(pathLabel)} + + ); + + return {node}; +} + +function getCompactFilePathLabel(pathLabel: string): string { + const normalizedPath = pathLabel.replace(/\\/g, '/'); + const segments = normalizedPath.split('/').filter(Boolean); + + if (segments.length <= 2) { + return normalizedPath; + } + + return `.../${segments.slice(-2).join('/')}`; +} + function UsageOverview({ report }: { report: SessionUsageReport }) { const { t } = useTranslation('flow-chat'); const denominator = report.time.activeTurnMs ?? report.time.wallTimeMs; @@ -341,28 +427,47 @@ function UsageOverview({ report }: { report: SessionUsageReport }) { function UsageModels({ report }: { report: SessionUsageReport }) { const { t } = useTranslation('flow-chat'); + const hasModelDuration = report.models.some(model => model.durationMs !== undefined); + const headers = [ + t('usage.table.model'), + t('usage.table.calls'), + ...(hasModelDuration ? [t('usage.table.duration')] : []), + t('usage.table.input'), + t('usage.table.output'), + t('usage.table.cached'), + ]; return ( { const cached = formatUsageNumber(model.cachedTokens, t); - return [ - model.modelId, + const source = model.modelIdSource ?? (model.modelId === 'unknown_model' ? 'legacy_missing' : undefined); + const modelHelp = getModelHelp(source, t, model.modelId); + const row: UsageTableCell[] = [ + modelHelp + ? { value: getModelLabel(model.modelId, t, source), help: modelHelp } + : getModelLabel(model.modelId, t, source), formatUsageNumber(model.callCount, t), + ]; + if (hasModelDuration) { + row.push( + model.durationMs === undefined + ? missingUsageValue(t('usage.status.timingNotRecorded'), t('usage.help.modelRoundTime')) + : formatUsageDuration(model.durationMs, t) + ); + } + row.push( formatUsageNumber(model.inputTokens, t), formatUsageNumber(model.outputTokens, t), report.tokens.cacheCoverage === 'unavailable' ? { value: t('usage.status.cacheNotReported'), help: t('usage.help.cachedTokens') } : cached, + ); + return [ + ...row, ]; })} /> @@ -396,26 +501,120 @@ function UsageTools({ report }: { report: SessionUsageReport }) { formatUsageNumber(tool.callCount, t), formatUsageNumber(tool.successCount, t), formatUsageNumber(tool.errorCount, t), - tool.durationMs === undefined ? { value: t('usage.status.timingNotRecorded'), help: t('usage.help.toolDuration') } : duration, - tool.p95DurationMs === undefined ? { value: t('usage.status.timingNotRecorded'), help: t('usage.help.toolP95') } : p95, - tool.executionMs === undefined ? { value: t('usage.status.timingNotRecorded'), help: t('usage.help.toolExecution') } : execution, + tool.durationMs === undefined + ? missingUsageValue(t('usage.status.timingNotRecorded'), t('usage.help.toolDuration')) + : duration, + tool.p95DurationMs === undefined + ? missingUsageValue(t('usage.status.timingNotRecorded'), t('usage.help.toolP95')) + : p95, + tool.executionMs === undefined + ? missingUsageValue(t('usage.status.timingNotRecorded'), t('usage.help.toolExecution')) + : execution, ]; })} /> ); } -function UsageFiles({ report }: { report: SessionUsageReport }) { +function UsageFiles({ + report, + sessionId, + workspacePath, +}: { + report: SessionUsageReport; + sessionId?: string; + workspacePath?: string; +}) { const { t } = useTranslation('flow-chat'); const fileScopeHelp = getFileScopeHelp(report, t); - const rows = useMemo(() => report.files.files.map(file => [ - file.redacted ? getRedactedLabel(t) : file.pathLabel, - formatUsageNumber(file.operationCount, t), - formatUsageNumber(file.addedLines, t), - formatUsageNumber(file.deletedLines, t), - (file.turnIndexes ?? []).join(', ') || { value: t('usage.status.notRecorded'), help: t('usage.help.fileTurnIndexes') }, - (file.operationIds ?? []).slice(0, 3).join(', ') || { value: t('usage.status.notRecorded'), help: t('usage.help.fileOperationIds') }, - ]), [report.files.files, t]); + const [openingDiffKey, setOpeningDiffKey] = useState(null); + + const handleOpenFileDiff = useCallback(async (file: SessionUsageReport['files']['files'][number]) => { + if (!sessionId || file.redacted || report.files.scope !== 'snapshot_summary') { + return; + } + + const resolvedPath = resolveUsageFilePath(file.pathLabel, workspacePath); + const operationId = file.operationIds?.[0]; + const diffKey = `${resolvedPath}:${operationId ?? ''}`; + setOpeningDiffKey(diffKey); + + try { + const diff = await snapshotAPI.getOperationDiff( + sessionId, + resolvedPath, + operationId, + workspacePath, + ); + const diffPath = diff.filePath || resolvedPath; + createDiffEditorTab( + diffPath, + getUsageFileName(diffPath), + diff.originalContent || '', + diff.modifiedContent || '', + true, + 'agent', + workspacePath, + diff.anchorLine ? Number(diff.anchorLine) : undefined, + undefined, + { + titleKind: 'diff', + duplicateKeyPrefix: 'diff', + }, + ); + } catch (error) { + log.warn('Failed to open usage report file diff', { + sessionId, + filePath: resolvedPath, + operationId, + error, + }); + } finally { + setOpeningDiffKey(null); + } + }, [report.files.scope, sessionId, workspacePath]); + + const rows = useMemo(() => report.files.files.map(file => { + const operationId = file.operationIds?.[0]; + const resolvedPath = resolveUsageFilePath(file.pathLabel, workspacePath); + const diffKey = `${resolvedPath}:${operationId ?? ''}`; + const canOpenDiff = canOpenUsageFileDiff(report.files.scope, file, sessionId); + const actionCell: UsageTableNodeCell = { + className: 'session-usage-panel__sticky-action-cell', + node: canOpenDiff ? ( + + void handleOpenFileDiff(file)} + disabled={openingDiffKey === diffKey} + aria-label={t('usage.actions.openFileDiff')} + > + + + + ) : ( + + - + + ), + }; + + return [ + file.redacted + ? getRedactedLabel(t) + : { + node: , + className: 'session-usage-panel__file-path-cell', + }, + formatUsageNumber(file.operationCount, t), + formatUsageNumber(file.addedLines, t), + formatUsageNumber(file.deletedLines, t), + (file.turnIndexes ?? []).join(', ') || { value: t('usage.status.notRecorded'), help: t('usage.help.fileTurnIndexes') }, + actionCell, + ]; + }), [handleOpenFileDiff, openingDiffKey, report.files.files, report.files.scope, sessionId, t, workspacePath]); return (
@@ -437,9 +636,10 @@ function UsageFiles({ report }: { report: SessionUsageReport }) { t('usage.table.added'), t('usage.table.deleted'), t('usage.table.turns'), - t('usage.table.operationIds'), + t('usage.table.actions'), ]} rows={rows} + tableClassName="session-usage-panel__table--files" />
); @@ -449,34 +649,158 @@ function UsageErrors({ report }: { report: SessionUsageReport }) { const { t } = useTranslation('flow-chat'); return (
+
+ {t('usage.panel.errorScope')} + +
+

{t('usage.help.errors')}

{t('usage.metrics.errors')}
-
{formatUsageNumber(report.errors.totalErrors, t)}
+
+ +
{t('usage.panel.toolErrors')}
-
{formatUsageNumber(report.errors.toolErrors, t)}
+
+ +
{t('usage.panel.modelErrors')}
-
{formatUsageNumber(report.errors.modelErrors, t)}
+
+ +
[ - example.redacted ? getRedactedLabel(t) : example.label, - formatUsageNumber(example.count, t), + { + value: example.redacted ? getRedactedLabel(t) : example.label, + help: t('usage.help.errorExampleRow'), + }, + { + value: formatUsageNumber(example.count, t), + help: t('usage.help.errorExampleCount'), + }, ])} />
); } +function canOpenUsageFileDiff( + scope: SessionUsageReport['files']['scope'], + file: SessionUsageReport['files']['files'][number], + sessionId?: string, +): boolean { + return Boolean(sessionId && scope === 'snapshot_summary' && !file.redacted && file.pathLabel); +} + +function resolveUsageFilePath(pathLabel: string, workspacePath?: string): string { + if (!workspacePath || isAbsolutePathLike(pathLabel)) { + return pathLabel; + } + return `${workspacePath.replace(/[\\/]+$/, '')}/${pathLabel.replace(/^[\\/]+/, '')}`; +} + +function isAbsolutePathLike(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/') || value.startsWith('\\\\'); +} + +function getUsageFileName(filePath: string): string { + return filePath.split(/[\\/]/).pop() || filePath; +} + +function UsageSlowest({ report, sessionId }: { report: SessionUsageReport; sessionId?: string }) { + const { t } = useTranslation('flow-chat'); + const handleJumpToTurn = useCallback((span: SessionUsageReport['slowest'][number]) => { + if (!sessionId || !span.turnId) return; + + const request: FlowChatPinTurnToTopRequest = { + sessionId, + turnId: span.turnId, + behavior: 'smooth', + pinMode: 'transient', + source: 'usage-report', + }; + globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, request, 'SessionUsagePanel'); + }, [sessionId]); + + return ( +
+
+ {t('usage.sections.slowest')} + +
+

{t('usage.help.slowestSpans')}

+ { + const spanHelp = getSlowSpanHelp(span, t); + const spanLabel = getSlowSpanLabel(span, t); + const canJumpToTurn = Boolean(sessionId && span.turnId); + const jumpHelp = t('usage.actions.jumpToTurn'); + const labelCell: UsageTableCell = canJumpToTurn + ? { + node: ( + + + + ), + } + : spanHelp + ? { value: spanLabel, help: spanHelp } + : spanLabel; + return [ + labelCell, + t(`usage.slowestKinds.${span.kind === 'model' ? 'modelCall' : span.kind}`), + formatUsageDuration(span.durationMs, t), + ]; + })} + /> +
+ ); +} + interface UsageTableProps { empty: boolean; emptyLabel: string; @@ -484,9 +808,10 @@ interface UsageTableProps { emptyHelp?: string; headers: string[]; rows: UsageTableCell[][]; + tableClassName?: string; } -function UsageTable({ empty, emptyLabel, emptyDescription, emptyHelp, headers, rows }: UsageTableProps) { +function UsageTable({ empty, emptyLabel, emptyDescription, emptyHelp, headers, rows, tableClassName }: UsageTableProps) { if (empty) { return (
@@ -498,7 +823,7 @@ function UsageTable({ empty, emptyLabel, emptyDescription, emptyHelp, headers, r return (
- +
{headers.map(header => )} @@ -508,10 +833,15 @@ function UsageTable({ empty, emptyLabel, emptyDescription, emptyHelp, headers, r {rows.map((row, rowIndex) => ( {row.map((cell, cellIndex) => ( - ))} diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss index 410529063..2f68241d5 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.scss @@ -108,6 +108,55 @@ gap: 4px; } + &__header-actions { + display: inline-flex; + align-items: center; + gap: 4px; + } + + &__copy-action { + width: 26px; + height: 26px; + } + + &__details-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 26px; + padding: 0 7px 0 8px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-xs); + font-weight: 650; + line-height: 1; + white-space: nowrap; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 78%, transparent); + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent-primary) 70%, transparent); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.52; + cursor: default; + } + + svg { + flex: 0 0 auto; + } + } + &__coverage { display: inline-flex; align-items: center; @@ -157,7 +206,7 @@ &__metrics { display: grid; - grid-template-columns: repeat(4, minmax(116px, 1fr)); + grid-template-columns: repeat(3, minmax(116px, 1fr)); gap: 6px; } @@ -220,13 +269,49 @@ background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); } - &__mini-list-title { - margin-bottom: 4px; + &__mini-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; color: var(--color-text-secondary); + } + + &__mini-list-title { + min-width: 0; font-size: var(--flowchat-font-size-xs); font-weight: 600; } + &__mini-list-more { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; + min-width: 0; + height: 20px; + padding: 0 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-xs); + cursor: pointer; + + &:hover { + color: var(--color-text-primary); + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + } + + span { + white-space: nowrap; + } + + svg { + flex: 0 0 auto; + } + } + &__mini-list-empty { display: flex; flex-direction: column; @@ -269,6 +354,24 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + &--help { + text-decoration: underline; + text-decoration-style: dashed; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + cursor: help; + } + } + + &__mini-list-file-name { + grid-area: label; + min-width: 0; + overflow: hidden; + color: var(--color-text-primary); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; } &__mini-list-value { @@ -330,4 +433,23 @@ text-overflow: ellipsis; } } + + &__file-stat { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; + } + + &__file-stat--added { + color: var(--color-success); + } + + &__file-stat--deleted { + color: var(--color-error); + } + + &__file-stat-separator { + color: var(--color-text-muted); + } } diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx index bc57d4c40..8b674f69d 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx @@ -4,11 +4,11 @@ import { Activity, AlertTriangle, Check, + ChevronRight, Copy, Clock3, Database, FileText, - Info, } from 'lucide-react'; import { IconButton, MarkdownRenderer, ToolProcessingDots, Tooltip } from '@/component-library'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; @@ -20,20 +20,26 @@ import { getCoverageTone, getFileScopeHelp, getFileSummaryLabel, + getModelHelp, + getModelLabel, getRedactedLabel, getTopFiles, getTopModels, getTopTools, getToolCategoryLabel, + getUsageFileNameFromPath, } from './usageReportUtils'; +import type { SessionUsagePanelTab } from './sessionUsagePanelTypes'; import './SessionUsageReportCard.scss'; +const SUMMARY_LIST_LIMIT = 3; + interface SessionUsageReportCardProps { report?: SessionUsageReport; markdown?: string; generatedAt?: number; isLoading?: boolean; - onOpenDetails?: (report: SessionUsageReport) => void; + onOpenDetails?: (report: SessionUsageReport, initialTab?: SessionUsagePanelTab) => void; } export const SessionUsageReportCard: React.FC = ({ @@ -65,9 +71,16 @@ export const SessionUsageReportCard: React.FC = ({ } }, [onOpenDetails, report]); - const topModels = useMemo(() => report ? getTopModels(report, 3) : [], [report]); - const topTools = useMemo(() => report ? getTopTools(report, 3) : [], [report]); - const topFiles = useMemo(() => report ? getTopFiles(report, 3) : [], [report]); + const handleOpenSectionDetails = useCallback((initialTab: SessionUsagePanelTab) => (event: React.MouseEvent) => { + event.stopPropagation(); + if (report) { + onOpenDetails?.(report, initialTab); + } + }, [onOpenDetails, report]); + + const topModels = useMemo(() => report ? getTopModels(report, SUMMARY_LIST_LIMIT) : [], [report]); + const topTools = useMemo(() => report ? getTopTools(report, SUMMARY_LIST_LIMIT) : [], [report]); + const topFiles = useMemo(() => report ? getTopFiles(report, SUMMARY_LIST_LIMIT) : [], [report]); const loadingHints = useMemo(() => [ t('usage.loading.steps.collecting'), t('usage.loading.steps.tokens'), @@ -176,6 +189,7 @@ export const SessionUsageReportCard: React.FC = ({ value: formatUsageNumber(report.errors.totalErrors, t), icon: AlertTriangle, tone: report.errors.totalErrors > 0 ? 'warning' : undefined, + help: t('usage.help.errors'), }, ]; @@ -206,27 +220,31 @@ export const SessionUsageReportCard: React.FC = ({ {getCoverageLabel(report.coverage.level, t)} )} - - - {copied ? : } - - - - - - - +
+ + + {copied ? : } + + + + + +
@@ -249,16 +267,38 @@ export const SessionUsageReportCard: React.FC = ({
({ - label: model.modelId, - value: formatUsageNumber(model.totalTokens, t), - detail: t('usage.card.calls', { count: model.callCount }), - }))} + showAll={buildShowAllAction({ + totalCount: report.models.length, + visibleCount: topModels.length, + sectionLabel: t('usage.sections.models'), + t, + onClick: onOpenDetails ? handleOpenSectionDetails('models') : undefined, + })} + items={topModels.map(model => { + const source = model.modelIdSource ?? (model.modelId === 'unknown_model' ? 'legacy_missing' : undefined); + const help = getModelHelp(source, t, model.modelId); + const label = getModelLabel(model.modelId, t, source); + const tokenValue = typeof model.totalTokens === 'number' && Number.isFinite(model.totalTokens) + ? t('usage.card.tokens', { value: formatUsageNumber(model.totalTokens, t) }) + : formatUsageNumber(model.totalTokens, t); + return { + label: help ? { value: label, help } : label, + value: tokenValue, + detail: t('usage.card.calls', { count: model.callCount }), + }; + })} emptyLabel={t('usage.empty.models')} emptyDescription={t('usage.empty.modelsDescription')} /> ({ label: tool.redacted ? getRedactedLabel(t) : tool.toolName, value: t('usage.card.calls', { count: tool.callCount }), @@ -269,10 +309,29 @@ export const SessionUsageReportCard: React.FC = ({ /> ({ - label: file.redacted ? getRedactedLabel(t) : file.pathLabel, + label: file.redacted + ? getRedactedLabel(t) + : { + node: , + text: file.pathLabel, + help: file.pathLabel, + }, value: t('usage.card.operations', { count: file.operationCount }), - detail: `${formatUsageNumber(file.addedLines, t)} / ${formatUsageNumber(file.deletedLines, t)}`, + detail: ( + + ), }))} emptyLabel={getFileSummaryLabel(report, t)} emptyDescription={fileMetricHelp ?? t('usage.empty.filesDescription')} @@ -292,21 +351,143 @@ function UsageMetricValue({ value, help }: { value: string; help?: string }) { return help ? {node} : node; } +type UsageMiniListLabel = string | { + value: string; + help?: string; +} | { + node: React.ReactElement; + text: string; + help?: string; +}; + +type UsageMiniListShowAll = { + label: string; + ariaLabel: string; + onClick: (event: React.MouseEvent) => void; +}; + interface UsageMiniListProps { title: string; + showAll?: UsageMiniListShowAll; items: Array<{ - label: string; + label: UsageMiniListLabel; value: string; - detail: string; + detail: React.ReactNode; }>; emptyLabel: string; emptyDescription?: string; } -function UsageMiniList({ title, items, emptyLabel, emptyDescription }: UsageMiniListProps) { +function buildShowAllAction({ + totalCount, + visibleCount, + sectionLabel, + t, + onClick, +}: { + totalCount: number; + visibleCount: number; + sectionLabel: string; + t: (key: string, options?: Record) => string; + onClick?: (event: React.MouseEvent) => void; +}): UsageMiniListShowAll | undefined { + if (!onClick || totalCount <= visibleCount) { + return undefined; + } + return { + label: t('usage.actions.viewAllSection', { count: totalCount }), + ariaLabel: t('usage.actions.openSectionDetails', { section: sectionLabel }), + onClick, + }; +} + +function getMiniListLabelText(label: UsageMiniListLabel): string { + if (typeof label !== 'string' && 'node' in label) { + return label.text; + } + return typeof label === 'string' ? label : label.value; +} + +function UsageMiniListLabelView({ label }: { label: UsageMiniListLabel }) { + if (typeof label !== 'string' && 'node' in label) { + return label.help + ? {label.node} + : label.node; + } + + const labelText = getMiniListLabelText(label); + const node = ( + + {labelText} + + ); + + return typeof label !== 'string' && label.help + ? {node} + : node; +} + +function UsageMiniListFilePathLabel({ pathLabel }: { pathLabel: string }) { + return ( + + {getUsageFileNameFromPath(pathLabel)} + + ); +} + +function UsageFileChangeDetail({ + addedLines, + deletedLines, + t, +}: { + addedLines?: number; + deletedLines?: number; + t: (key: string, options?: Record) => string; +}) { + return ( + + + {formatSignedFileLineCount(addedLines, '+', t)} + + / + + {formatSignedFileLineCount(deletedLines, '-', t)} + + + ); +} + +function formatSignedFileLineCount( + value: number | undefined, + sign: '+' | '-', + t: (key: string, options?: Record) => string +): string { + const formatted = formatUsageNumber(value, t); + return typeof value === 'number' && Number.isFinite(value) ? `${sign}${formatted}` : formatted; +} + +function UsageMiniList({ title, showAll, items, emptyLabel, emptyDescription }: UsageMiniListProps) { return (
-
{title}
+
+
{title}
+ {showAll && ( + + + + )} +
{items.length === 0 ? (
{emptyLabel} @@ -314,8 +495,8 @@ function UsageMiniList({ title, items, emptyLabel, emptyDescription }: UsageMini
) : ( items.map(item => ( -
- {item.label} +
+ {item.value} {item.detail}
diff --git a/src/web-ui/src/flow_chat/components/usage/sessionUsagePanelTypes.ts b/src/web-ui/src/flow_chat/components/usage/sessionUsagePanelTypes.ts new file mode 100644 index 000000000..30ca2fcbe --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/sessionUsagePanelTypes.ts @@ -0,0 +1 @@ +export type SessionUsagePanelTab = 'overview' | 'models' | 'tools' | 'files' | 'errors' | 'slowest'; diff --git a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.test.ts b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.test.ts index 28dececf1..18195e763 100644 --- a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.test.ts +++ b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.test.ts @@ -4,6 +4,10 @@ import { calculateShare, coerceSessionUsageReport, getFileSummaryLabel, + getModelHelp, + getModelLabel, + getSlowSpanHelp, + getSlowSpanLabel, getTopFiles, } from './usageReportUtils'; @@ -12,6 +16,15 @@ const t = (key: string, options?: Record): string => { if (key === 'usage.percent') return `${options?.value}%`; if (key === 'usage.duration.seconds') return `${options?.value}s`; if (key === 'usage.status.noFileChanges') return 'No file changes'; + if (key === 'usage.status.modelNotRecorded') return 'Model not recorded'; + if (key === 'usage.status.legacyModel') return 'Legacy model not tracked'; + if (key === 'usage.status.inferredModel') return `${options?.model} (inferred)`; + if (key === 'usage.help.legacyModel') return 'Older sessions did not store per-round model names.'; + if (key === 'usage.help.inferredModel') return 'Inferred from the session model setting.'; + if (key === 'usage.help.slowestModelCall') return `Model call: ${options?.model}`; + if (key === 'usage.slowestLabels.modelCall') return `Turn ${options?.turn} model call`; + if (key === 'usage.slowestLabels.modelCallUnknown') return 'Model call'; + if (key === 'usage.redacted') return 'Redacted'; return key; }; @@ -132,4 +145,53 @@ describe('usageReportUtils', () => { 'src/small.ts', ]); }); + + it('labels legacy and inferred model identities with helpful copy', () => { + expect(getModelLabel('unknown_model', t, 'legacy_missing')).toBe('Legacy model not tracked'); + expect(getModelLabel('model round 0', t)).toBe('Legacy model not tracked'); + expect(getModelLabel('gpt-5.4', t, 'inferred_session_model')).toBe('gpt-5.4 (inferred)'); + expect(getModelLabel('019e0c07-c7bc-73f1-b1d6-5260ed215fe0', t, 'inferred_session_model')) + .toBe('Legacy model not tracked'); + expect(getSlowSpanLabel({ + label: 'gpt-5.4', + kind: 'model', + durationMs: 100, + redacted: false, + turnIndex: 3, + modelIdSource: 'inferred_session_model', + }, t)).toBe('Turn 3 model call'); + }); + + it('returns model identity tooltip copy when the source is inferred or legacy', () => { + expect(getModelHelp('inferred_session_model', t)).toBe('Inferred from the session model setting.'); + expect(getModelHelp('inferred_session_model', t, '019e0c07-c7bc-73f1-b1d6-5260ed215fe0')) + .toBe('Older sessions did not store per-round model names.'); + expect(getModelHelp('legacy_missing', t)).toBe('Older sessions did not store per-round model names.'); + expect(getModelHelp(undefined, t, 'model round 0')).toBe('Older sessions did not store per-round model names.'); + expect(getSlowSpanHelp({ + label: 'unknown_model', + kind: 'model', + durationMs: 100, + redacted: false, + modelIdSource: 'legacy_missing', + }, t)).toBe('Model call: Legacy model not tracked Older sessions did not store per-round model names.'); + expect(getSlowSpanHelp({ + label: 'model round 1', + kind: 'model', + durationMs: 100, + redacted: false, + }, t)).toBe('Model call: Legacy model not tracked Older sessions did not store per-round model names.'); + expect(getSlowSpanHelp({ + label: 'gpt-5.4', + kind: 'model', + durationMs: 100, + redacted: false, + }, t)).toBe('Model call: gpt-5.4'); + expect(getSlowSpanLabel({ + label: 'secret', + kind: 'tool', + durationMs: 100, + redacted: true, + }, t)).toBe('Redacted'); + }); }); diff --git a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts index b4758c119..f6569787c 100644 --- a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts +++ b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts @@ -1,6 +1,11 @@ import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; type Translator = (key: string, options?: Record) => string; +type ModelIdentitySource = SessionUsageReport['models'][number]['modelIdSource']; + +const UNKNOWN_MODEL_ID = 'unknown_model'; +const LEGACY_MODEL_ROUND_LABEL_PATTERN = /^model\s+round\s+\d+$/i; +const FILE_PATH_MIDDLE_ELLIPSIS_THRESHOLD = 48; export function hasNoRecordedFileChanges(report: SessionUsageReport): boolean { return report.files.files.length === 0 && @@ -123,6 +128,97 @@ export function getFileSummaryLabel(report: SessionUsageReport, t: Translator): return formatUsageNumber(report.files.changedFiles, t); } +export function getModelLabel( + modelId: string | undefined, + t: Translator, + source?: ModelIdentitySource +): string { + if ( + source === 'inferred_session_model' && + modelId && + modelId !== UNKNOWN_MODEL_ID && + !isLegacyModelRoundLabel(modelId) && + !isOpaqueModelIdentifier(modelId) + ) { + return t('usage.status.inferredModel', { model: modelId }); + } + if ( + !modelId || + modelId === UNKNOWN_MODEL_ID || + isLegacyModelRoundLabel(modelId) || + source === 'legacy_missing' || + source === 'inferred_session_model' + ) { + return t('usage.status.legacyModel'); + } + return modelId; +} + +export function getModelHelp( + source: ModelIdentitySource | undefined, + t: Translator, + modelId?: string +): string | undefined { + if (source === 'inferred_session_model') { + if (modelId && (isOpaqueModelIdentifier(modelId) || isLegacyModelRoundLabel(modelId))) { + return t('usage.help.legacyModel'); + } + return t('usage.help.inferredModel'); + } + if (source === 'legacy_missing') { + return t('usage.help.legacyModel'); + } + if (isLegacyModelRoundLabel(modelId)) { + return t('usage.help.legacyModel'); + } + return undefined; +} + +export function getSlowSpanLabel( + span: SessionUsageReport['slowest'][number], + t: Translator +): string { + if (span.redacted) { + return getRedactedLabel(t); + } + if (span.kind === 'model') { + return typeof span.turnIndex === 'number' && Number.isFinite(span.turnIndex) + ? t('usage.slowestLabels.modelCall', { turn: span.turnIndex }) + : t('usage.slowestLabels.modelCallUnknown'); + } + return span.label; +} + +export function getSlowSpanHelp( + span: SessionUsageReport['slowest'][number], + t: Translator +): string | undefined { + if (span.redacted) { + return undefined; + } + if (span.kind === 'model') { + const modelLabel = getModelLabel(span.label, t, span.modelIdSource); + const modelHelp = getModelHelp(span.modelIdSource, t, span.label); + const callHelp = t('usage.help.slowestModelCall', { model: modelLabel }); + return modelHelp ? `${callHelp} ${modelHelp}` : callHelp; + } + if (span.modelIdSource) { + return getModelHelp(span.modelIdSource, t, span.label); + } + return span.label === UNKNOWN_MODEL_ID || isLegacyModelRoundLabel(span.label) + ? t('usage.help.legacyModel') + : undefined; +} + +function isOpaqueModelIdentifier(modelId: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(modelId) || + /^[0-9a-f]{32}$/i.test(modelId); +} + +function isLegacyModelRoundLabel(modelId: string | undefined): boolean { + return Boolean(modelId && LEGACY_MODEL_ROUND_LABEL_PATTERN.test(modelId.trim())); +} + export function getFileScopeHelp(report: SessionUsageReport, t: Translator): string | undefined { if (report.files.scope !== 'unavailable') { return undefined; @@ -161,6 +257,26 @@ export function getTopFiles(report: SessionUsageReport, limit: number): SessionU .slice(0, limit); } +export function getUsageFilePathDisplayParts(pathLabel: string): { prefix: string; fileName: string } | null { + if (pathLabel.length <= FILE_PATH_MIDDLE_ELLIPSIS_THRESHOLD) { + return null; + } + + const segments = pathLabel.split(/[\\/]+/).filter(Boolean); + if (segments.length <= 1) { + return null; + } + + const fileName = segments.at(-1) ?? pathLabel; + const prefix = segments.slice(0, -1).join('/'); + return prefix ? { prefix, fileName } : null; +} + +export function getUsageFileNameFromPath(pathLabel: string): string { + const segments = pathLabel.split(/[\\/]+/).filter(Boolean); + return segments.at(-1) ?? pathLabel; +} + export function getRedactedLabel(t: Translator): string { return t('usage.redacted'); } diff --git a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts index 87c99fdc0..126d13505 100644 --- a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts +++ b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts @@ -6,7 +6,7 @@ export const FLOWCHAT_FOCUS_ITEM_EVENT = 'flowchat:focus-item'; export const FLOWCHAT_PIN_TURN_TO_TOP_EVENT = 'flowchat:pin-turn-to-top'; export type FlowChatFocusItemSource = 'btw-back'; -export type FlowChatPinTurnToTopSource = 'send-message'; +export type FlowChatPinTurnToTopSource = 'send-message' | 'usage-report'; export type FlowChatPinTurnToTopMode = 'transient' | 'sticky-latest'; export interface FlowChatFocusItemRequest { diff --git a/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts b/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts index a6305d8d7..5f0ddd0aa 100644 --- a/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts +++ b/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts @@ -1,6 +1,7 @@ import { createTab } from '@/shared/utils/tabUtils'; import type { PanelContent } from '@/app/components/panels/base/types'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; +import type { SessionUsagePanelTab } from '../components/usage/sessionUsagePanelTypes'; export const SESSION_USAGE_PANEL_TYPE = 'session-usage' as const; @@ -10,6 +11,7 @@ export interface SessionUsagePanelData { sessionId?: string; workspacePath?: string; title?: string; + initialTab?: SessionUsagePanelTab; } export interface SessionUsagePanelMetadata { @@ -51,7 +53,7 @@ export function openSessionUsagePanel(params: SessionUsagePanelData & { expand?: metadata: content.metadata, checkDuplicate: true, duplicateCheckKey: content.metadata?.duplicateCheckKey, - replaceExisting: false, + replaceExisting: !!params.initialTab, mode: 'agent', }); } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 42abe1ef0..d4a4d7923 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -57,12 +57,17 @@ "copied": "Copied", "copySessionId": "Copy session ID", "copyWorkspacePath": "Copy project path", - "openDetails": "Open details" + "openDetails": "Open details", + "openFileDiff": "Open diff", + "openSectionDetails": "Open {{section}} details", + "jumpToTurn": "Jump to this turn", + "viewDetails": "Details", + "viewAllSection": "View all {{count}}" }, "coverage": { - "complete": "Complete", - "partial": "Partial", - "minimal": "Minimal", + "complete": "Complete data", + "partial": "Partial data", + "minimal": "Minimal data", "partialNotice": "Some metrics were not reported by this session or provider. Hover underlined values for the specific reason." }, "toolCategories": { @@ -85,7 +90,10 @@ "timingNotRecorded": "Timing not recorded", "cacheNotReported": "Cache not reported", "noFileChanges": "No file changes", - "notRecorded": "Not recorded" + "notRecorded": "Not recorded", + "modelNotRecorded": "Model not recorded", + "legacyModel": "Legacy model not tracked", + "inferredModel": "{{model}} (inferred)" }, "cacheCoverage": { "available": "Reported", @@ -96,7 +104,8 @@ "heading": "Session statistics", "turns": "{{count}} turns", "calls": "{{count}} calls", - "operations": "{{count}} ops" + "operations": "{{count}} ops", + "tokens": "{{value}} tokens" }, "loading": { "markdown": "Generating usage report...", @@ -116,12 +125,14 @@ "tokens": "Tokens", "cached": "Cached", "files": "Files", - "errors": "Errors" + "errors": "Errors", + "errorRate": "Error rate" }, "sections": { "models": "Models", "tools": "Tools", - "files": "Files" + "files": "Files", + "slowest": "Slowest spans" }, "empty": { "models": "No model metrics", @@ -131,25 +142,38 @@ "files": "No file changes", "filesDescription": "No file-edit records were found for this session.", "errors": "No error examples", - "errorsDescription": "No sampled tool or model errors were recorded." + "errorsDescription": "No sampled tool or model errors were recorded.", + "slowest": "No slow spans", + "slowestDescription": "No timed turn, model-call, or tool spans were recorded." }, "help": { "wall": "Span from the first recorded turn start to the last recorded turn end. Idle gaps can be included.", - "active": "Sum of recorded turn durations that produced reportable activity. It can include orchestration or waiting inside a turn.", + "active": "Union of recorded turn spans that produced reportable activity. It can include orchestration or waiting inside a turn.", "timeShare": "Share of recorded turn time. Model and tool spans may overlap, so this is only an approximate indicator.", - "modelRoundTime": "Recorded model-round duration from persisted start and end timestamps, not pure model streaming or throughput time. The percentage uses recorded turn time and is approximate.", + "modelRoundTime": "Recorded model-round duration from persisted runtime metadata or start/end timestamps, not pure provider streaming or throughput time. The percentage uses recorded turn time and is approximate.", "toolTime": "Recorded tool-call duration. The percentage uses recorded turn time and is approximate.", "cachedTokens": "The provider did not report cache-read token metadata for this session. Total token counts are still shown when available.", "cachedTokensPartial": "Only some calls reported cache-read token metadata, so the cached-token total covers those calls only.", "toolDuration": "This tool call did not report timing metadata.", "toolP95": "P95 appears after at least two timed calls are recorded for this tool.", "toolExecution": "This tool did not report a separate execution-duration field.", + "legacyModel": "Older sessions did not store per-round model names, so this report cannot identify the exact model.", + "inferredModel": "Inferred from the session model setting because older turns did not store per-round model names.", "filesUnavailable": "No file snapshot or file-edit tool record was found for this session.", "filesNoRecordedChanges": "BitFun did not detect file changes in this session. This is expected when the agent did not edit files.", - "filesRemoteUnavailable": "Remote session file snapshots are not included in this report yet. File rows appear only when tool records identify edited files.", + "filesRemoteUnavailable": "No remote snapshot summary was found for this session. File rows can still appear from recognized file-edit tool records.", "filesNotTracked": "No local snapshot or identifiable file-edit tool record was found for this session.", "fileTurnIndexes": "Turn indexes are not recorded when file activity comes from a summary instead of per-turn operation records.", - "fileOperationIds": "Operation IDs are not recorded when the report cannot link this row back to individual tool operations." + "fileOperationIds": "Operation IDs are not recorded when the report cannot link this row back to individual tool operations.", + "fileDiffUnavailable": "Diff links require a snapshot-backed file row and a visible file path.", + "errors": "Counts model turns that ended in error plus tool calls whose result was unsuccessful. It does not include raw provider responses, tool inputs, or command output.", + "toolErrors": "Tool errors count tool calls whose persisted result is marked unsuccessful.", + "modelErrors": "Model errors count dialog turns that ended in the error state.", + "errorExamples": "Examples are grouped by safe labels only; raw provider responses, tool inputs, and command output stay out of the report.", + "errorExampleRow": "Safe grouped label for a model/runtime error or failing tool type.", + "errorExampleCount": "Number of matching errors in this report scope.", + "slowestSpans": "Slow spans are derived from recorded turn, model-call, and tool timestamps. They help identify time sinks, not exact provider latency.", + "slowestModelCall": "Model call in this turn. Model: {{model}}." }, "meta": { "generatedAt": "Generated", @@ -170,7 +194,8 @@ "compressions": "Compressions", "fileScope": "File scope", "toolErrors": "Tool errors", - "modelErrors": "Model errors" + "modelErrors": "Model errors", + "errorScope": "Error scope" }, "privacy": { "title": "Privacy-safe report", @@ -181,7 +206,8 @@ "models": "Models", "tools": "Tools", "files": "Files", - "errors": "Errors" + "errors": "Errors", + "slowest": "Slowest" }, "table": { "model": "Model", @@ -202,8 +228,32 @@ "deleted": "Deleted", "turns": "Turns", "operationIds": "Operation IDs", + "actions": "Actions", "label": "Label", - "count": "Count" + "count": "Count", + "kind": "Kind" + }, + "slowestKinds": { + "model": "Model call", + "modelCall": "Model call", + "tool": "Tool", + "turn": "Turn" + }, + "slowestLabels": { + "modelCall": "Turn {{turn}} model call", + "modelCallUnknown": "Model call" + } + }, + "quickActions": { + "defaults": { + "commit": { + "label": "Commit", + "prompt": "Commit all current code changes" + }, + "createPr": { + "label": "Create PR", + "prompt": "Create a Pull Request for the current branch" + } } }, "agentCompanion": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 91f787e2f..89c39507f 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -56,13 +56,18 @@ "copyMarkdown": "复制 Markdown", "copied": "已复制", "openDetails": "打开详情", + "openFileDiff": "打开 diff", "copySessionId": "复制会话 ID", - "copyWorkspacePath": "复制项目路径" + "copyWorkspacePath": "复制项目路径", + "openSectionDetails": "打开{{section}}详情", + "jumpToTurn": "跳转到该轮次", + "viewDetails": "详情", + "viewAllSection": "查看全部 {{count}} 项" }, "coverage": { - "complete": "完整", - "partial": "部分", - "minimal": "最小", + "complete": "数据完整", + "partial": "部分数据可用", + "minimal": "仅基础数据", "partialNotice": "部分指标没有被当前会话或服务商上报。悬浮带虚线下划线的数值可查看具体原因。" }, "toolCategories": { @@ -85,7 +90,10 @@ "timingNotRecorded": "未记录耗时", "cacheNotReported": "未上报缓存", "noFileChanges": "无文件变更", - "notRecorded": "未记录" + "notRecorded": "未记录", + "modelNotRecorded": "未记录模型名称", + "legacyModel": "旧会话模型未统计", + "inferredModel": "{{model}}(推测)" }, "cacheCoverage": { "available": "已上报", @@ -96,7 +104,8 @@ "heading": "会话统计", "turns": "{{count}} 轮", "calls": "{{count}} 次调用", - "operations": "{{count}} 次操作" + "operations": "{{count}} 次操作", + "tokens": "{{value}} Tokens" }, "loading": { "markdown": "正在生成用量报告...", @@ -113,15 +122,17 @@ "active": "已记录轮次耗时", "modelTime": "模型轮次耗时", "toolTime": "工具调用耗时", - "tokens": "Token", + "tokens": "Tokens", "cached": "缓存", "files": "文件", - "errors": "错误" + "errors": "错误", + "errorRate": "错误率" }, "sections": { "models": "模型", "tools": "工具", - "files": "文件" + "files": "文件", + "slowest": "最慢耗时" }, "empty": { "models": "暂无模型指标", @@ -131,7 +142,9 @@ "modelsDescription": "当模型调用上报 Token 用量后,这里会显示模型统计。", "toolsDescription": "当会话执行工具后,这里会显示工具调用统计。", "filesDescription": "当前会话没有找到文件编辑记录。", - "errorsDescription": "本次报告没有记录到可展示的工具或模型错误样例。" + "errorsDescription": "本次报告没有记录到可展示的工具或模型错误样例。", + "slowest": "无慢耗时记录", + "slowestDescription": "没有记录到带耗时的轮次、模型调用或工具片段。" }, "runtime": { "open": "生成会话用量", @@ -147,6 +160,7 @@ "fileScope": "文件范围", "toolErrors": "工具错误", "modelErrors": "模型错误", + "errorScope": "错误范围", "metadataLabel": "用量报告元信息" }, "privacy": { @@ -158,7 +172,8 @@ "models": "模型", "tools": "工具", "files": "文件", - "errors": "错误" + "errors": "错误", + "slowest": "慢耗时" }, "table": { "model": "模型", @@ -179,26 +194,49 @@ "deleted": "删除", "turns": "轮次", "operationIds": "操作 ID", + "actions": "操作", "label": "标签", - "count": "次数" + "count": "次数", + "kind": "类型" + }, + "slowestKinds": { + "model": "模型调用", + "modelCall": "模型调用", + "tool": "工具", + "turn": "轮次" + }, + "slowestLabels": { + "modelCall": "第 {{turn}} 轮模型调用", + "modelCallUnknown": "模型调用" }, "help": { "wall": "从第一轮开始到最后一轮结束的记录跨度,可能包含轮次之间的空闲间隔。", - "active": "产生可统计活动的轮次记录耗时之和,可能包含轮次内的编排或等待时间。", + "active": "产生可统计活动的轮次记录时间段并集,可能包含轮次内的编排或等待时间。", "timeShare": "占已记录轮次耗时的比例。模型与工具耗时可能重叠,因此仅作为近似参考。", - "modelRoundTime": "基于会话中保存的模型轮次起止时间计算,不等同于纯模型流式输出或吞吐耗时;占比按已记录轮次耗时近似计算。", + "modelRoundTime": "基于会话中保存的模型轮次运行时元数据或起止时间计算,不等同于纯服务商流式输出或吞吐耗时;占比按已记录轮次耗时近似计算。", "toolTime": "基于已记录工具调用耗时计算;占比按已记录轮次耗时近似计算。", "cachedTokens": "当前服务商没有为这个会话上报缓存读取 Token 元数据;可用时仍会显示总 Token。", "cachedTokensPartial": "只有部分调用上报了缓存读取 Token 元数据,因此缓存 Token 合计只覆盖这些调用。", "toolDuration": "该工具调用没有上报耗时元数据。", "toolP95": "同一工具至少记录两次带耗时调用后才会显示 P95。", "toolExecution": "该工具没有单独上报执行耗时字段。", + "legacyModel": "这个会话创建时尚未记录逐轮模型名称,因此报告无法确认精确模型。", + "inferredModel": "旧会话缺少逐轮模型名称,已根据会话的模型设置推测。", "filesUnavailable": "当前会话没有找到本地快照或可识别的文件编辑工具记录。", "filesNoRecordedChanges": "BitFun 没有检测到当前会话中的文件变更。如果本次会话没有编辑文件,这是正常状态。", - "filesRemoteUnavailable": "远程会话的文件快照暂未纳入此报告;只有工具记录能识别到编辑文件时才会显示文件行。", + "filesRemoteUnavailable": "没有找到这个远程会话的快照汇总;如果工具记录能识别到写入、编辑或删除文件,仍会显示文件行。", "filesNotTracked": "当前会话没有找到本地快照或可识别的文件编辑工具记录。", "fileTurnIndexes": "当文件活动来自汇总而不是逐轮操作记录时,不会记录轮次。", - "fileOperationIds": "当报告无法关联到具体工具操作时,不会记录操作 ID。" + "fileOperationIds": "当报告无法关联到具体工具操作时,不会记录操作 ID。", + "fileDiffUnavailable": "Diff 链接需要基于快照的文件行和可见文件路径。", + "errors": "统计结束为错误状态的模型轮次,以及持久化结果标记为未成功的工具调用;不包含原始服务商响应、工具输入或命令输出。", + "toolErrors": "工具错误统计持久化结果标记为未成功的工具调用。", + "modelErrors": "模型错误统计最终进入错误状态的对话轮次。", + "errorExamples": "示例只按安全标签分组;原始服务商响应、工具输入和命令输出不会进入报告。", + "errorExampleRow": "模型/运行时错误或失败工具类型的安全分组标签。", + "errorExampleCount": "当前报告范围内匹配该标签的错误次数。", + "slowestSpans": "慢耗时来自已记录的轮次、模型调用与工具时间戳,用于定位耗时来源,不代表精确服务商延迟。", + "slowestModelCall": "该行是当前轮次内的一次模型调用。模型:{{model}}。" }, "meta": { "generatedAt": "生成时间", @@ -206,6 +244,18 @@ "workspacePath": "项目路径" } }, + "quickActions": { + "defaults": { + "commit": { + "label": "提交", + "prompt": "提交当前所有代码变更" + }, + "createPr": { + "label": "创建 PR", + "prompt": "为当前分支创建 Pull Request" + } + } + }, "agentCompanion": { "activity": { "starting": "启动中", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index a930815e3..1c78d834b 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -56,13 +56,18 @@ "copyMarkdown": "複製 Markdown", "copied": "已複製", "openDetails": "開啟詳情", + "openFileDiff": "開啟 diff", "copySessionId": "複製工作階段 ID", - "copyWorkspacePath": "複製專案路徑" + "copyWorkspacePath": "複製專案路徑", + "openSectionDetails": "開啟{{section}}詳情", + "jumpToTurn": "跳轉到該回合", + "viewDetails": "詳情", + "viewAllSection": "查看全部 {{count}} 項" }, "coverage": { - "complete": "完整", - "partial": "部分", - "minimal": "最小", + "complete": "資料完整", + "partial": "部分資料可用", + "minimal": "僅基礎資料", "partialNotice": "部分指標沒有被目前工作階段或服務商回報。懸浮帶虛線底線的數值可查看具體原因。" }, "toolCategories": { @@ -85,7 +90,10 @@ "timingNotRecorded": "未記錄耗時", "cacheNotReported": "未回報快取", "noFileChanges": "無檔案變更", - "notRecorded": "未記錄" + "notRecorded": "未記錄", + "modelNotRecorded": "未記錄模型名稱", + "legacyModel": "舊會話模型未統計", + "inferredModel": "{{model}}(推測)" }, "cacheCoverage": { "available": "已回報", @@ -96,7 +104,8 @@ "heading": "會話統計", "turns": "{{count}} 輪", "calls": "{{count}} 次呼叫", - "operations": "{{count}} 次操作" + "operations": "{{count}} 次操作", + "tokens": "{{value}} Tokens" }, "loading": { "markdown": "正在產生用量報告...", @@ -113,15 +122,17 @@ "active": "已記錄回合耗時", "modelTime": "模型回合耗時", "toolTime": "工具呼叫耗時", - "tokens": "Token", + "tokens": "Tokens", "cached": "快取", "files": "檔案", - "errors": "錯誤" + "errors": "錯誤", + "errorRate": "錯誤率" }, "sections": { "models": "模型", "tools": "工具", - "files": "檔案" + "files": "檔案", + "slowest": "最慢耗時" }, "empty": { "models": "暫無模型指標", @@ -131,7 +142,9 @@ "modelsDescription": "當模型呼叫上報 Token 用量後,這裡會顯示模型統計。", "toolsDescription": "當工作階段執行工具後,這裡會顯示工具呼叫統計。", "filesDescription": "目前工作階段沒有找到檔案編輯記錄。", - "errorsDescription": "本次報告沒有記錄到可顯示的工具或模型錯誤範例。" + "errorsDescription": "本次報告沒有記錄到可顯示的工具或模型錯誤範例。", + "slowest": "無慢耗時記錄", + "slowestDescription": "沒有記錄到帶耗時的回合、模型呼叫或工具片段。" }, "runtime": { "open": "產生工作階段用量", @@ -147,6 +160,7 @@ "fileScope": "檔案範圍", "toolErrors": "工具錯誤", "modelErrors": "模型錯誤", + "errorScope": "錯誤範圍", "metadataLabel": "用量報告中繼資訊" }, "privacy": { @@ -158,7 +172,8 @@ "models": "模型", "tools": "工具", "files": "檔案", - "errors": "錯誤" + "errors": "錯誤", + "slowest": "慢耗時" }, "table": { "model": "模型", @@ -179,26 +194,49 @@ "deleted": "刪除", "turns": "輪次", "operationIds": "操作 ID", + "actions": "操作", "label": "標籤", - "count": "次數" + "count": "次數", + "kind": "類型" + }, + "slowestKinds": { + "model": "模型呼叫", + "modelCall": "模型呼叫", + "tool": "工具", + "turn": "回合" + }, + "slowestLabels": { + "modelCall": "第 {{turn}} 回合模型呼叫", + "modelCallUnknown": "模型呼叫" }, "help": { "wall": "從第一回合開始到最後一回合結束的記錄跨度,可能包含回合之間的閒置間隔。", - "active": "產生可統計活動的回合記錄耗時總和,可能包含回合內的編排或等待時間。", + "active": "產生可統計活動的回合記錄時間段聯集,可能包含回合內的編排或等待時間。", "timeShare": "占已記錄回合耗時的比例。模型與工具耗時可能重疊,因此僅作為近似參考。", - "modelRoundTime": "基於工作階段中保存的模型回合起止時間計算,不等同於純模型串流輸出或吞吐耗時;占比按已記錄回合耗時近似計算。", + "modelRoundTime": "基於工作階段中保存的模型回合執行期中繼資料或起止時間計算,不等同於純服務商串流輸出或吞吐耗時;占比按已記錄回合耗時近似計算。", "toolTime": "基於已記錄工具呼叫耗時計算;占比按已記錄回合耗時近似計算。", "cachedTokens": "目前服務商沒有為這個工作階段回報快取讀取 Token 中繼資料;可用時仍會顯示總 Token。", "cachedTokensPartial": "只有部分呼叫回報了快取讀取 Token 中繼資料,因此快取 Token 合計只涵蓋這些呼叫。", "toolDuration": "該工具呼叫沒有回報耗時中繼資料。", "toolP95": "同一工具至少記錄兩次帶耗時呼叫後才會顯示 P95。", "toolExecution": "該工具沒有單獨回報執行耗時欄位。", + "legacyModel": "這個工作階段建立時尚未記錄逐回合模型名稱,因此報告無法確認精確模型。", + "inferredModel": "舊工作階段缺少逐回合模型名稱,已根據工作階段的模型設定推測。", "filesUnavailable": "目前工作階段沒有找到本機快照或可識別的檔案編輯工具記錄。", "filesNoRecordedChanges": "BitFun 沒有偵測到目前工作階段中的檔案變更。如果本次工作階段沒有編輯檔案,這是正常狀態。", - "filesRemoteUnavailable": "遠端工作階段的檔案快照暫未納入此報告;只有工具記錄能識別到編輯檔案時才會顯示檔案列。", + "filesRemoteUnavailable": "沒有找到這個遠端工作階段的快照彙總;如果工具記錄能識別到寫入、編輯或刪除檔案,仍會顯示檔案列。", "filesNotTracked": "目前工作階段沒有找到本機快照或可識別的檔案編輯工具記錄。", "fileTurnIndexes": "當檔案活動來自彙總而不是逐回合操作記錄時,不會記錄回合。", - "fileOperationIds": "當報告無法關聯到具體工具操作時,不會記錄操作 ID。" + "fileOperationIds": "當報告無法關聯到具體工具操作時,不會記錄操作 ID。", + "fileDiffUnavailable": "Diff 連結需要基於快照的檔案列和可見檔案路徑。", + "errors": "統計結束為錯誤狀態的模型回合,以及持久化結果標記為未成功的工具呼叫;不包含原始服務商回應、工具輸入或命令輸出。", + "toolErrors": "工具錯誤統計持久化結果標記為未成功的工具呼叫。", + "modelErrors": "模型錯誤統計最終進入錯誤狀態的對話回合。", + "errorExamples": "範例只按安全標籤分組;原始服務商回應、工具輸入和命令輸出不會進入報告。", + "errorExampleRow": "模型/執行期錯誤或失敗工具類型的安全分組標籤。", + "errorExampleCount": "目前報告範圍內符合該標籤的錯誤次數。", + "slowestSpans": "慢耗時來自已記錄的回合、模型呼叫與工具時間戳,用於定位耗時來源,不代表精確服務商延遲。", + "slowestModelCall": "該列是目前回合內的一次模型呼叫。模型:{{model}}。" }, "meta": { "generatedAt": "產生時間", @@ -206,6 +244,18 @@ "workspacePath": "專案路徑" } }, + "quickActions": { + "defaults": { + "commit": { + "label": "提交", + "prompt": "提交目前所有程式碼變更" + }, + "createPr": { + "label": "建立 PR", + "prompt": "為目前分支建立 Pull Request" + } + } + }, "agentCompanion": { "activity": { "starting": "啟動中", From 76a8ea046c04233b68f4c7f20150fd6b5ffb372b Mon Sep 17 00:00:00 2001 From: limit_yan Date: Mon, 11 May 2026 17:08:26 +0800 Subject: [PATCH 3/3] feat(i18n): localize quick action labels --- .../config/components/QuickActionsConfig.tsx | 94 +++++++++++-------- .../services/quickActionLocalization.test.ts | 86 +++++++++++++++++ .../services/quickActionLocalization.ts | 65 +++++++++++++ .../locales/en-US/settings/quick-actions.json | 12 +++ .../locales/zh-CN/settings/quick-actions.json | 12 +++ .../locales/zh-TW/settings/quick-actions.json | 55 +++++++++++ 6 files changed, 283 insertions(+), 41 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/services/quickActionLocalization.test.ts create mode 100644 src/web-ui/src/infrastructure/config/services/quickActionLocalization.ts create mode 100644 src/web-ui/src/locales/zh-TW/settings/quick-actions.json diff --git a/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx b/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx index c482b4382..5a15bf696 100644 --- a/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx @@ -21,6 +21,10 @@ import { DEFAULT_QUICK_ACTIONS, type QuickAction, } from '../services/AIExperienceConfigService'; +import { + normalizeQuickActionTextForStorage, + resolveQuickActionText, +} from '../services/quickActionLocalization'; import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import './QuickActionsConfig.scss'; @@ -29,6 +33,8 @@ const log = createLogger('QuickActionsConfig'); const BUILTIN_IDS = new Set(['commit', 'create_pr']); +type TranslationFn = (key: string, options?: Record) => string; + function getActionIcon(id: string, size = 15) { if (id === 'commit') return ; if (id === 'create_pr') return ; @@ -43,7 +49,7 @@ interface ActionFormModalProps { target: QuickAction | undefined; onClose: () => void; onSubmit: (label: string, prompt: string) => void; - t: (key: string) => string; + t: TranslationFn; } const ActionFormModal: React.FC = ({ isOpen, target, onClose, onSubmit, t }) => { @@ -54,12 +60,13 @@ const ActionFormModal: React.FC = ({ isOpen, target, onClo // Sync form when target changes or modal opens. useEffect(() => { if (isOpen) { - setLabel(target?.label ?? ''); - setPrompt(target?.prompt ?? ''); + const targetText = target ? resolveQuickActionText(target, t) : undefined; + setLabel(targetText?.label ?? ''); + setPrompt(targetText?.prompt ?? ''); // Delay focus so the modal animation completes first. setTimeout(() => labelInputRef.current?.focus(), 80); } - }, [isOpen, target]); + }, [isOpen, t, target]); const canSubmit = label.trim().length > 0 && prompt.trim().length > 0; @@ -145,52 +152,56 @@ interface ActionRowProps { onEdit: (action: QuickAction) => void; onDelete: (id: string) => void; canDelete: boolean; - t: (key: string) => string; + t: TranslationFn; } -const ActionRow: React.FC = ({ action, onToggle, onEdit, onDelete, canDelete, t }) => ( -
-
- {getActionIcon(action.id)} -
+const ActionRow: React.FC = ({ action, onToggle, onEdit, onDelete, canDelete, t }) => { + const actionText = resolveQuickActionText(action, t); -
-
{action.label}
-
{action.prompt}
-
+ return ( +
+
+ {getActionIcon(action.id)} +
-
- onToggle(action.id)} - size="small" - /> - onEdit(action)} - > - - - {canDelete && ( +
+
{actionText.label}
+
{actionText.prompt}
+
+ +
+ onToggle(action.id)} + size="small" + /> onDelete(action.id)} - className="quick-actions-config__delete-btn" + aria-label={t('edit.button')} + tooltip={t('edit.button')} + onClick={() => onEdit(action)} > - + - )} + {canDelete && ( + onDelete(action.id)} + className="quick-actions-config__delete-btn" + > + + + )} +
-
-); + ); +}; // ── Main page ─────────────────────────────────────────────────────────────── @@ -252,10 +263,11 @@ const QuickActionsConfig: React.FC = () => { void persist([...actions, newAction]); } else if (modalTarget) { // Edit mode - void persist(actions.map(a => a.id === modalTarget.id ? { ...a, label, prompt } : a)); + const normalizedText = normalizeQuickActionTextForStorage(modalTarget, label, prompt, t); + void persist(actions.map(a => a.id === modalTarget.id ? { ...a, ...normalizedText } : a)); } setModalTarget(undefined); - }, [actions, modalTarget, persist]); + }, [actions, modalTarget, persist, t]); if (loading) { return ( diff --git a/src/web-ui/src/infrastructure/config/services/quickActionLocalization.test.ts b/src/web-ui/src/infrastructure/config/services/quickActionLocalization.test.ts new file mode 100644 index 000000000..e1ae2f6aa --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/quickActionLocalization.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import type { QuickAction } from './AIExperienceConfigService'; +import { + normalizeQuickActionTextForStorage, + resolveQuickActionText, +} from './quickActionLocalization'; + +const labels: Record = { + 'quickActions.defaults.commit.label': '提交', + 'quickActions.defaults.commit.prompt': '提交当前所有代码变更', + 'quickActions.defaults.createPr.label': '创建 PR', + 'quickActions.defaults.createPr.prompt': '为当前分支创建 Pull Request', +}; + +const t = (key: string, options?: Record) => + labels[key] ?? String(options?.defaultValue ?? key); + +describe('quick action localization', () => { + it('localizes stored default built-in quick actions', () => { + const commit: QuickAction = { + id: 'commit', + label: 'Commit', + prompt: 'Commit all current code changes', + enabled: true, + }; + const createPr: QuickAction = { + id: 'create_pr', + label: 'Create PR', + prompt: 'Create a Pull Request for the current branch', + enabled: true, + }; + + expect(resolveQuickActionText(commit, t)).toEqual({ + label: '提交', + prompt: '提交当前所有代码变更', + }); + expect(resolveQuickActionText(createPr, t)).toEqual({ + label: '创建 PR', + prompt: '为当前分支创建 Pull Request', + }); + }); + + it('preserves customized built-in quick action text', () => { + const customized: QuickAction = { + id: 'commit', + label: 'Ship it', + prompt: 'Commit only the staged files', + enabled: true, + }; + + expect(resolveQuickActionText(customized, t)).toEqual({ + label: 'Ship it', + prompt: 'Commit only the staged files', + }); + }); + + it('stores unchanged localized built-in quick action text as canonical defaults', () => { + const commit: QuickAction = { + id: 'commit', + label: 'Commit', + prompt: 'Commit all current code changes', + enabled: true, + }; + const localized = resolveQuickActionText(commit, t); + + expect(normalizeQuickActionTextForStorage(commit, localized.label, localized.prompt, t)).toEqual({ + label: 'Commit', + prompt: 'Commit all current code changes', + }); + }); + + it('keeps edited localized built-in quick action text customized', () => { + const commit: QuickAction = { + id: 'commit', + label: 'Commit', + prompt: 'Commit all current code changes', + enabled: true, + }; + const localized = resolveQuickActionText(commit, t); + + expect(normalizeQuickActionTextForStorage(commit, localized.label, 'Commit staged changes only', t)).toEqual({ + label: 'Commit', + prompt: 'Commit staged changes only', + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/quickActionLocalization.ts b/src/web-ui/src/infrastructure/config/services/quickActionLocalization.ts new file mode 100644 index 000000000..7349e6447 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/services/quickActionLocalization.ts @@ -0,0 +1,65 @@ +import type { QuickAction } from './AIExperienceConfigService'; + +type Translator = (key: string, options?: Record) => string; + +const BUILTIN_QUICK_ACTION_TEXT: Record = { + commit: { + defaultLabel: 'Commit', + defaultPrompt: 'Commit all current code changes', + labelKey: 'quickActions.defaults.commit.label', + promptKey: 'quickActions.defaults.commit.prompt', + }, + create_pr: { + defaultLabel: 'Create PR', + defaultPrompt: 'Create a Pull Request for the current branch', + labelKey: 'quickActions.defaults.createPr.label', + promptKey: 'quickActions.defaults.createPr.prompt', + }, +}; + +export function resolveQuickActionText( + action: Pick, + t: Translator, +): { label: string; prompt: string } { + const builtin = BUILTIN_QUICK_ACTION_TEXT[action.id]; + if (!builtin) { + return { + label: action.label, + prompt: action.prompt, + }; + } + + return { + label: action.label === builtin.defaultLabel + ? t(builtin.labelKey, { defaultValue: builtin.defaultLabel }) + : action.label, + prompt: action.prompt === builtin.defaultPrompt + ? t(builtin.promptKey, { defaultValue: builtin.defaultPrompt }) + : action.prompt, + }; +} + +export function normalizeQuickActionTextForStorage( + action: Pick, + label: string, + prompt: string, + t: Translator, +): { label: string; prompt: string } { + const builtin = BUILTIN_QUICK_ACTION_TEXT[action.id]; + if (!builtin) { + return { label, prompt }; + } + + const localizedLabel = t(builtin.labelKey, { defaultValue: builtin.defaultLabel }); + const localizedPrompt = t(builtin.promptKey, { defaultValue: builtin.defaultPrompt }); + + return { + label: label === localizedLabel ? builtin.defaultLabel : label, + prompt: prompt === localizedPrompt ? builtin.defaultPrompt : prompt, + }; +} diff --git a/src/web-ui/src/locales/en-US/settings/quick-actions.json b/src/web-ui/src/locales/en-US/settings/quick-actions.json index ca067d4e7..4068d55ef 100644 --- a/src/web-ui/src/locales/en-US/settings/quick-actions.json +++ b/src/web-ui/src/locales/en-US/settings/quick-actions.json @@ -4,6 +4,18 @@ "subtitle": "One-click AI actions after coding. Triggered from the lightning bolt icon in the chat header. AI completes them autonomously end-to-end." }, "loading": "Loading...", + "quickActions": { + "defaults": { + "commit": { + "label": "Commit", + "prompt": "Commit all current code changes" + }, + "createPr": { + "label": "Create PR", + "prompt": "Create a Pull Request for the current branch" + } + } + }, "sections": { "builtin": { "title": "Built-in actions", diff --git a/src/web-ui/src/locales/zh-CN/settings/quick-actions.json b/src/web-ui/src/locales/zh-CN/settings/quick-actions.json index 4c898dc21..e5d35e32e 100644 --- a/src/web-ui/src/locales/zh-CN/settings/quick-actions.json +++ b/src/web-ui/src/locales/zh-CN/settings/quick-actions.json @@ -4,6 +4,18 @@ "subtitle": "代码完成后的一键 AI 动作,从聊天窗口左上角的闪电图标触发,AI 自主端到端完成。" }, "loading": "加载中...", + "quickActions": { + "defaults": { + "commit": { + "label": "提交", + "prompt": "提交当前所有代码变更" + }, + "createPr": { + "label": "创建 PR", + "prompt": "为当前分支创建 Pull Request" + } + } + }, "sections": { "builtin": { "title": "内置动作", diff --git a/src/web-ui/src/locales/zh-TW/settings/quick-actions.json b/src/web-ui/src/locales/zh-TW/settings/quick-actions.json new file mode 100644 index 000000000..7987f6f63 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/quick-actions.json @@ -0,0 +1,55 @@ +{ + "page": { + "title": "快捷動作", + "subtitle": "程式碼完成後的一鍵 AI 動作,從聊天視窗左上角的閃電圖示觸發,AI 會自主端到端完成。" + }, + "loading": "載入中...", + "quickActions": { + "defaults": { + "commit": { + "label": "提交", + "prompt": "提交目前所有程式碼變更" + }, + "createPr": { + "label": "建立 PR", + "prompt": "為目前分支建立 Pull Request" + } + } + }, + "sections": { + "builtin": { + "title": "內建動作", + "description": "開箱即用的 Git 工作流程動作,可編輯指令、啟用或停用。" + }, + "custom": { + "title": "自訂動作", + "description": "新增你自己的快捷指令,點擊後直接傳給 AI 執行。", + "empty": "還沒有自訂動作,在下方新增第一個吧。" + } + }, + "edit": { + "button": "編輯" + }, + "delete": { + "button": "刪除" + }, + "add": { + "button": "新增動作" + }, + "modal": { + "addTitle": "新增快捷動作", + "editTitle": "編輯快捷動作", + "labelField": "動作名稱", + "labelPlaceholder": "例:產生單元測試", + "promptField": "觸發指令", + "promptPlaceholder": "例:請為目前程式碼變更產生單元測試", + "promptHint": "點擊選單項時,此指令會直接傳送給 AI,由 AI 自主完成。", + "cancel": "取消", + "saveEdit": "儲存", + "confirmAdd": "新增" + }, + "messages": { + "saved": "已儲存", + "saveFailed": "儲存失敗" + } +}
{header}
+ {typeof cell === 'string' ? {cell} - : } + : 'node' in cell + ? cell.node + : }