Skip to content

feat(ai): surface live AI generation status with smooth timing, per-tool duration, and prompt terminal handling - #6938

Open
Abeautifulsnow wants to merge 10 commits into
t8y2:mainfrom
Abeautifulsnow:08-21-feat-ai-generation-status-display-6743
Open

feat(ai): surface live AI generation status with smooth timing, per-tool duration, and prompt terminal handling#6938
Abeautifulsnow wants to merge 10 commits into
t8y2:mainfrom
Abeautifulsnow:08-21-feat-ai-generation-status-display-6743

Conversation

@Abeautifulsnow

Copy link
Copy Markdown
Contributor

变更说明

Summary

The AI assistant panel previously showed only a static "Thinking…" placeholder while the model was working, making it impossible to tell a slow request from a hung one. This PR adds a live generation-status line (Issue #6743, feature 1) driven entirely by the existing ai-agent-event stream, plus a series of fixes that make it behave correctly under real-world streaming load.

It replaces the placeholder with a full status display — phase, turn, active tool, overall elapsed time, and a gentle >60s hint — while fixing three defects found once the feature shipped:

  • the elapsed timer jumped and stuttered under streaming main-thread contention,
  • the status line lingered as "正在生成回复 · 已运行 Xs" after the reply completed,
  • and a stale "thinking… 0s" residue appeared between the terminal event and the promise resolving.

It also aligns the display with the high-fidelity mockup and adds per-tool step durations.


Highlights

Status state machine (apps/desktop/src/lib/ai/aiGenerationStatus.ts)

  • Pure, event-driven reducer: phase is derived solely from ai-agent-event (turn_startrunning_toolgenerating, never mode-based), so Ask mode's read-only tool calls are surfaced correctly.
  • Copy priority: cancelling → idle (>20s) → running tool → generating → no-events ("等待模型响应"). Never asserts "stuck/hung" — the timer is elapsed-time proof, not a liveness proof.
  • Terminal finished phase: agent_end/error preserve startedAt/turn, clear the active tool, and let the component hide the line instantly. finished is terminal (later events can't resurrect it), markCancelling is a no-op on it, and statusText/liveAnnouncementText return "" so no wrong copy or new i18n key is needed.
  • Parallel tool tracking: agent_loop emits every ToolCallStart before running read-only tools in parallel; the reducer now tracks all outstanding calls by tool_call_id so an earlier completion cannot clear the "正在执行" phase while a later tool is still running.

Status line (apps/desktop/src/components/editor/AiAssistant.vue)

  • rAF ticker: replaces the 1s setInterval with a self-rescheduling requestAnimationFrame loop that writes statusNow only when the displayed whole second changes — the counter rolls +1s within a frame of each real boundary instead of skipping values (jump) or freezing between delayed ticks (stutter). Elapsed/idle clamp to >= 0 so a stale ticker can't render "-1s".
  • Hides the instant finished arrives (v-if="isGenerating && generationStatus.phase !== 'finished'"), independent of isGenerating clearing later.
  • Ends the reply-card "思考过程" spinner at agent_end rather than waiting for finally.
  • Matches the mockup: idle state uses a non-spinning Hourglass (the >60s hint keeps Clock), container gap 7px, 12px status icons, turn-badge padding, tool-chip tint, and >60s hint padding.

Per-tool step duration (apps/desktop/src/lib/ai/aiAgentStepPresentation.ts)

  • AiAgentStepItem gains startedAtMs/endedAtMs/durationMs; upsertAgentStep computes the duration across the start/end merge.
  • Completed tool steps render a right-aligned tabular-nums duration (0.8s / 1.2s / 12s via formatToolDurationMs); running tool steps swap the Play icon for a spinner and show "执行中…" (ai.agentSteps.executing, all 8 locales).

Backend terminal-event timing (crates/dbx-core/src/ai_cli_agent.rs)

  • The synthesized AgentEnd is now emitted as soon as CLI stdout is fully consumed, before child.wait() / the stderr drain — a CLI that finished its work but was slow to exit no longer keeps the status line visible for the whole process reap. Cancellation/read-error paths skip it so the cancelling phase persists. Applies to codex, claude-code, cursor, grok, codebuddy, qoder, and opencode (Pi already emits at terminal-event parse time).

Accessibility

  • role="status" / aria-live="polite" / aria-atomic="true" sr-only live region fed by liveAnnouncementText, which announces discrete state changes (phase / tool / turn / idle crossing) but deliberately excludes the per-second numerals so screen readers aren't spammed by the running timer.

Tests

  • apps/desktop/src/lib/ai/__tests__/aiGenerationStatus.spec.ts — phase transitions, idle/60s thresholds, cancelling-first priority, terminal finished (startedAt preserved, idle copy never leaks), parallel-tool completion, elapsed clamping.
  • apps/desktop/src/lib/ai/__tests__/aiAgentStepPresentation.spec.ts (new) — duration merge across start/end, end-without-start, negative clamp, formatToolDurationMs boundaries.
  • apps/desktop/src/components/editor/__tests__/AiAssistant.clearAndCancel.spec.ts — dual-path status resets, live-region markup, finished v-if guard, >60s hint gating, step-tail branches.
  • crates/dbx-core/src/ai_cli_agent.rs — regression: a CLI that closes stdout then lingers 2s must still get its AgentEnd within 1s (before the reap) with correct aggregated usage.
  • vue-tsc type-check and oxlint clean on the changed frontend files; the desktop frontend suite runs green (the single failing spec, windowsInstallerTemplate, is a pre-existing Windows-only env failure, reproducible on the base commit).

Commits

Hash Type Description
f55e78e06 feat Surface AI generation status for slow-vs-hung distinction
d032cdfd7 fix Expose generation status to screen readers via live region
1d693e81e fix Smooth generation-status timer and hide the line once the reply completes
64fb00e3d feat Align generation-status display with the mockup and show per-tool step duration
b1bf626f5 fix Emit the synthesized CLI agent_end as soon as stdout is consumed
ac3ba616e fix Keep the status on the newest tool when a parallel tool completes first

Files changed

15 files changed, +1283 / −23

  • apps/desktop/src/lib/ai/aiGenerationStatus.ts — state machine, finished phase, parallel tool tracking
  • apps/desktop/src/components/editor/AiAssistant.vue — status line, rAF ticker, step tail, mockup polish
  • apps/desktop/src/lib/ai/aiAgentStepPresentation.ts — step duration model + formatter
  • crates/dbx-core/src/ai_cli_agent.rs — early synthesized AgentEnd
  • apps/desktop/src/i18n/locales/{en,es,it,ja,ko,pt-BR,zh-CN,zh-TW}.tsai.status.*, ai.agentSteps.executing
  • Tests: aiGenerationStatus.spec.ts, aiAgentStepPresentation.spec.ts (new), AiAssistant.clearAndCancel.spec.ts

变更类型

  • 新功能
  • Bug 修复
  • 性能优化
  • 代码重构
  • 文档更新
  • CI / 构建

涉及前端

  • 本 PR 涉及前端改动,已附截图/录屏(见下方)

验证

  • make check 通过
  • make cargo-check-fast 通过
  • 相关测试通过

关联 Issue

Add a pure state machine (lib/ai/aiGenerationStatus.ts) driven by existing
ai-agent-event events, plus a status line in the AI assistant panel that
covers the whole generation period (Issue t8y2#6743 feature 1):
- elapsed timer (tabular-nums), 1s recompute while generating
- phase: preparing / waiting_model / generating / running_tool / cancelling
- running tool: "第 N 轮 · 正在执行 {tool} · 已运行 {s}s"
- no events yet: "等待模型响应 · 已运行 {s}s" (CLI slow first token)
- idle >20s: "等待此步骤完成 · 最后活动 {n}s 前 [ · 正在执行 {tool}]"
- >60s: gentle hint near Stop "响应时间较长,可继续等待或停止"
- never uses "卡死"/stuck wording - timer is elapsed proof, not liveness proof

Phase is event-driven, not mode-based (Ask mode emits turn_start and can call
read-only tools); tool_call_end clears activeTool; dual-path reset (finally +
abandon/onUnmounted/ensureLoaded) prevents cross-conversation leakage. Adds 24
unit tests + component reset assertions; i18n keys for all 8 locales.
The status line updated every second and on stream events but had no
role="status" / aria-live semantics, so screen readers never received the
newly added execution state. Add an sr-only live region (role="status",
aria-live="polite", aria-atomic="true") fed by a new pure helper
`liveAnnouncementText` that announces discrete state changes (phase / tool /
turn / idle crossing) but deliberately EXCLUDES the per-second elapsed/idle
numerals - a live region re-announced once per second would spam screen-reader
users with the running timer. Mark decorative spinner/clock aria-hidden.

Adds 6 unit tests (announced string never embeds a ticking \d+s countdown) +
a rendering assertion pinning the live-region markup in AiAssistant.vue.
i18n: waitingModelLive/generatingLive/idleLive keys across all 8 locales.
…ply completes

Fix two defects in the Issue t8y2#6743 generation-status line (data-ai-generation-status):

1. Timer jumps/stutters under streaming load. The 1s setInterval recomputed
   the elapsed from wall clock at irregular fire times, so a delayed tick
   landed past a second boundary and Math.ceil skipped intermediate values
   (jump) while the display froze between ticks. Replace it with a
   self-rescheduling requestAnimationFrame ticker that writes statusNow only
   when the displayed whole second changes (mirroring the existing
   runAssistantDeltaFrame pattern), so the counter rolls +1s within ~a frame
   of each real boundary. Clamp elapsed/idle to >=0 so a stale ticker can
   never render "-1s".

2. Stale "thinking…0s" residue after the reply completes. agent_end/error
   reset the status via createGenerationStatus(now) (startedAt=now,
   phase=preparing), and the line was gated only on isGenerating — which is
   cleared in send()'s finally after runAgentStream()'s promise resolves,
   while the terminal event arrives earlier through the event callback (CLI
   child.wait/stderr drain and SSE stream close can take seconds). Agent_end/
   error now enter a terminal "finished" phase that preserves startedAt/turn
   and clears activeTool; the line hides the instant it arrives
   (v-if excludes finished), the ticker stops, markCancelling is a no-op on
   finished, and statusText/liveAnnouncementText return "" for finished so no
   new i18n key is needed. Also clear msg.isThinking at agent_end so the reply
   card's spinner stops with the terminal event instead of waiting for the
   finally.

Adds finished-phase state-machine tests (termination, idle-copy non-leak,
cancelling no-op, long-running-hint suppression) and component source-text
assertions pinning the v-if finished guard and the reset paths.
…r-tool step duration

Mirror the Issue t8y2#6743 high-fidelity mockup (tmp/ai-generation-status-mockup.html):

- Per-tool step duration: AiAgentStepItem gains startedAtMs/endedAtMs/durationMs.
  agentEventToStep stamps tool_call_start/tool_call_end with Date.now(); upsertAgentStep
  computes durationMs when both stamps exist (preserving startedAtMs across the merge,
  clamping negatives to 0). Completed tool steps render a right-aligned tabular-nums
  duration (0.8s/1.2s/12s via the new pure formatToolDurationMs); running tool steps
  swap the Play icon for a spinning Loader2 and render a spinner + "执行中…" tail. New
  i18n key ai.agentSteps.executing across all 8 locales.

- Status-line polish to match the mockup: idle state uses a non-spinning Hourglass
  (the >60s hint keeps Clock); container gap 6px -> 7px; status icons 14px -> 12px;
  turn badge padding px-1 -> px-[5px]; tool chip bg-chart-2/10 -> /12; >60s hint
  px-2 py-1 -> px-[9px] py-[5px]. Tool names keep the spec-sanctioned i18n toolLabel
  map and the elapsed numerals keep tabular-nums (no mono added).

Adds unit tests for the duration merge/formatter boundaries and source-text
assertions for the step tail branches and the Hourglass idle branch.
…umed

run_cli_jsonl_agent emitted the fallback AgentEnd only after child.wait() +
the stderr drain, so a CLI that finished its work but was slow to exit
(lingering shutdown, held stderr, session teardown) kept the desktop
generation-status line ("正在生成回复 · 已运行 Xs") visible for the whole
reap duration even though the reply was already complete.

Move the synthesized AgentEnd dispatch to right after the stdout read loop
breaks, gated on no terminal error and no CLI-supplied AgentEnd. The process
is still reaped and a non-zero exit still returns an error; only the
terminal-event ordering changes so the frontend hides the status line the
instant the stream ends. Cancellation/read-error paths skip it so the
cancelling phase persists until the frontend finally. Covers all providers
that share the runner (codex, claude-code, cursor, grok, codebuddy, qoder,
opencode); Pi already emits AgentEnd at terminal-event parse time.

Adds a regression test where the fake CLI closes stdout then lingers 2s: the
AgentEnd must arrive within 1s (before the process reap) and be the terminal
event with correct aggregated usage.
…letes first

agent_loop emits every ToolCallStart before running read-only tools in
parallel, and their results can arrive in any order. The reducer previously
cleared activeTool on the FIRST tool_call_end, so the status line flipped to
"正在生成回复" while a later parallel tool was still executing.

Track all outstanding tools per turn (activeTools by tool_call_id) and
remove only the matching call on tool_call_end, keeping the newest remaining
tool as the displayed activeTool and the phase running_tool until the last
one completes. An end without a seen start keeps the old safe fallback, and
the terminal agent_end/error clears activeTools.

Adds a unit test: c1 completes before c2 -> phase stays running_tool with c2
active, then c2 completes -> generating.
@github-actions github-actions Bot added area/core Shared DBX core runtime area/desktop Desktop application or Tauri shell enhancement New feature or request ui-change Changes user-visible interface, text, or visual assets labels Aug 21, 2026

@t8y2 t8y2 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request changes: AgentEnd 目前在 CLI 进程退出结果确定前就发送了。

crates/dbx-core/src/ai_cli_agent.rs:1101 在 stdout EOF 后立即发送 AgentEnd,但直到后面的 child.wait() 和退出状态检查才知道进程是否真正成功。CLI 可能先关闭 stdout,再卡住或以非零状态退出;与此同时,Tauri 前端收到 agent_end 后会取消事件监听并隐藏生成状态,因此失败流程会短暂或永久地被展示成已经完成。

请保持 AgentEnd 只表示已确认成功的终态。若需要在 stdout 完成后提前结束回复动画,建议新增独立的非终态事件(例如 response_complete / finalizing),不要提前复用 AgentEnd

请补充两个时序测试:

  1. stdout 关闭后进程以状态 1 退出,失败确定前不得发送 AgentEnd
  2. stdout 关闭后进程延迟成功退出,验证新增事件和最终 AgentEnd 的顺序。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Shared DBX core runtime area/desktop Desktop application or Tauri shell enhancement New feature or request ui-change Changes user-visible interface, text, or visual assets

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants