Skip to content

feat(web-shell): show the trajectory over real time, idle included - #12607

Merged
qqqys merged 4 commits into
QwenLM:mainfrom
qqqys:feat/web-shell-trajectory-clock-mode
Sep 24, 2026
Merged

qqqys merged 4 commits into
QwenLM:mainfrom
qqqys:feat/web-shell-trajectory-clock-mode

Conversation

@qqqys

@qqqys qqqys commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

The trajectory overview strip gets a second way of laying out time. By default it still cuts out the idle time between turns, so the strip answers where the running time went. A new clock button on the axis, before the zoom buttons, switches it to real time: idle stretches stay on the axis, the two ends of the axis read as local clock times, and the right-hand label says how long the window ran and how much of that was activity, for example "1m elapsed, 1.8s active". The button is a pressed-state toggle. Zoomed windows are labelled with clock times at the precision the window needs, down to milliseconds. The switch is announced through the strip's existing status line.

On a real-time axis a dragged selection can fall entirely inside idle time. The table then says "No request or tool ran in the selected time." with a clear button beside it, and the header reads "Showing 0 of N rows". This is the state the first selection PR left out, because on the compressed axis every stretch touches some record.

Switching mode drops the current selection and zoom. Both are held in the coordinates of the axis they were made on, and the other mode lays the axis out differently, so the same numbers would point at a different stretch of the run. The selection is now tied to the timeline it was drawn on rather than to the loaded window. It lapses in the same render the axis changes, whether that comes from a refresh, another session or a mode switch.

Why it's needed

Part of #12293. Active time answers "was it the model or the tools", but not "how long did this run actually take, and where did it sit waiting". Real time answers that: long pauses before a retry, or between a tool result and the next request, become visible as gaps. This follows the actualTime switch in deepseek-harness's trajectory overview.

Reviewer Test Plan

How to verify

  1. Open the Trajectory tab on a session with a few turns. Press the clock button on the overview's axis. The spans spread out with gaps between turns, the axis ends read as clock times, and the right-hand label reads "… elapsed, … active".
  2. Zoom into a gap with the wheel and drag inside it. The table is replaced by the "No request or tool ran" message; its clear button restores every row.
  3. Make a selection, then switch mode. The selection and zoom are gone and the table is whole.
  4. Press the clock button again. The axis goes back to active time and starts at 0.
  5. The browser specs under "real time" in web-shell.trajectory.spec.ts drive all of this with real mouse input.

Evidence (Before & After)

Captured by CI's Capture web-shell visuals job on head efc4903: the same session as the existing trajectory captures, switched to real time. Turn 1, with its failed request, retry, read and delegation, sits at the left. Turn 2 sits at the far right after the idle gap. The axis starts at a clock time, the right-hand label reads "1m 18s elapsed, 18.8s active", and the clock button is lit.

Dark Light
clock dark clock light

web-shell E2E Smoke on the same head: 192 passed, and the log shows all four "real time" cases below executing. The export renderer bundle measured 1,944,135 bytes in that run, under the 1,970,000 warning line.

Browser specs added, all @smoke:

  • spreads the run over real time when switched: first span at the track's left end, last at its right, one minute between turns equal to that share of the track within 2 px, axis start reads as a clock time, table and grid position unchanged
  • a selection in idle time empties the table and can be cleared: zooms in on a gap, drags inside it, checks the message and "Showing 0 of 160 rows", then clears it
  • keeps the turns a real-time selection covers: a drag from the gap before turn 12 to the gap after turn 15 leaves exactly those four turns
  • switching back cuts idle out again and drops the selection

Unit tests: 204 pass across the trajectory hook, loader, projection, range helper, panel and overview. Fifteen mutations were each killed by at least one test. They cover:

  • the projection: real time still compressing idle, active time reported as the whole span, the axis origin taken from the first listed row instead of the earliest start, the default mode flipped
  • the selection: kept across a mode switch, and the empty-selection message removed
  • the toggle and axis: toggle without a pressed state, toggle always asking for the same mode, axis start still printed as 0, clock times ignoring the origin, fractional digits dropped, a 24-hour cycle reading midnight as 24
  • the zoom and wording: zoom kept across a switch, switch not announced, selected range named in durations on a real-time axis

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux ✅

Environment (optional)

Unit tests locally on Linux. The browser specs run in CI's web-shell E2E Smoke; Chromium cannot run on the machine these changes were written on.

Risk & Scope

  • Main risk or tradeoff: "elapsed" is measured from the first drawn record to the last in the loaded window, not from session start. The label says elapsed rather than session length for that reason. On a long real-time axis a one-second request is drawn at the 3 px minimum; zoom shows it properly.
  • Not validated / out of scope: the mode is not remembered across sessions or reloads. Selections are not converted between modes; they are dropped. Keyboard control of the strip is the next step, T3c in the ladder.
  • Breaking changes / migration notes: none. The default is unchanged. Only the Trajectory tab changes, and hosts opt into it. No SDK, daemon or protocol change.

Linked Issues

Part of #12293

中文说明

轨迹概览条新增第二种时间布局。默认仍然压缩掉轮次之间的空闲,回答「运行时间花在哪」。轴上缩放按钮之前新增一个时钟按钮,切换到真实时间:空闲留在轴上,轴两端显示本地时刻,右端显示窗口历时与其中的活跃时长,例如「历时 1m,活跃 1.8s」。按钮是带按下状态的开关;放大后的窗口按所需精度显示时刻,最细到毫秒;切换通过概览已有的状态行播报。

真实时间下拖选可能完全落在空闲里。此时表格显示「所选区间内没有请求或工具运行。」和清除按钮,表头为「区间内 0 / N 行」。这正是第一个选区 PR 留下的状态:压缩轴上任何区间都会碰到记录。

切换模式会清除当前选区与缩放。两者都用所在轴的坐标保存,另一种模式的轴布局不同,同样的数字会指向运行里的另一段。选区现在绑定到绘制它的时间轴,而不是已加载的窗口;刷新、换会话、切换模式都会让它在同一次渲染里失效。

「历时」从窗口里第一条画出的记录算到最后一条,不是会话时长。模式不持久化;选区不在模式间换算;键盘操作概览条是下一步(阶梯中的 T3c)。

@qqqys

qqqys commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. CI landed green on efc49031fd before this reply: web-shell E2E Smoke passed 192, with all four "real time" cases executing, and the visuals job captured both themes. The Evidence section now has those captures. On the three notes:

  1. Selection keyed on a memoized value: changed in 971ef82789. The selection is now keyed on the axis's inputs, the loaded window and the mode, instead of on the memoized timeline's identity. A mutation that drops the mode from the key is killed by the existing "drops a selection made on the other axis" test. The overview's zoom stays keyed on the model it receives as a prop. There, a new model object is the signal that the data changed, which is the same contract feat(web-shell): zoom and pan the trajectory overview #12557 shipped.
  2. Midnight on the axis: left as is, deliberately. The label beside the axis states the elapsed time, which removes the ambiguity. Adding a date to every axis end would cost width on a 64 px strip for a case most runs never hit. If it turns out to matter, the right fix is a date shown only when the window crosses a day, not a date everywhere.
  3. The same sentence said twice: fixed in 971ef82789. The empty-selection message is no longer a live region. The header's row count, which changes in the same render, is the one announcement. A test now pins that the message sits outside any status region, and a mutation that restores the role is killed.

The new head needs CI again, so the deferred approval will re-evaluate on 971ef82789.

@qqqys

qqqys commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qqqys
qqqys enabled auto-merge September 24, 2026 06:40

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM at 971ef82. CI green and settled on this head (11 lanes, 0 pending, 0 failed), zero open threads. Requested by maintainer.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE at 971ef82.

  • Head re-verified immediately before submitting: 971ef82, OPEN and mergeable. Every check context carries a success, with review-pr excluded as reviewer-bot infrastructure per my standing disclosure. No prior review and no inline thread.
  • No new Critical. This adds a second time axis to a strip whose first axis is already shipped, so I read it for the two ways adding a mode usually goes wrong: the new mode disturbing the old one, and a selection or a label surviving a switch that invalidates it.
  • The existing axis is untouched by construction rather than by care. The mode is an option defaulting to active, the shift accumulator advances only in active mode while clock mode merely sums the gaps, and a test asserts the active axis stays as it was, so the default cannot drift because someone tuned the new mode.
  • The label and the axis cannot disagree, which is the invariant worth having here. The strip reads the mode off the model instead of off a second prop, and the docblock on the mode-change callback states the reason. A component that accepted the mode twice could announce real time while drawing compressed time.
  • Selection invalidation was extended instead of left behind. The stored selection carries the mode alongside the window it was drawn on and is honoured only while both still match, so switching axes lapses a selection in the same render rather than filtering the new axis by numbers taken from the old one. The comment keys that on the inputs to the axis rather than on the memoised axis itself, because React keeps a memo identity as an optimisation and not as a promise, which is the correct reason and an easy one to get wrong.
  • An empty selection is an answered question rather than a blank table. A stretch dragged across idle time in real-time mode finds no rows, the panel says so in words with the way back beside it, and the comment records why it is deliberately not a live region: the header row count changes in the same render and two announcements would say the same thing twice.
  • The numbers the new mode introduces are computed and pinned rather than assumed. Active time is the domain minus the idle total clamped at zero, and a test asserts that parallel and overlapping calls count once towards it, which is exactly where a naive gap sum double-counts. The origin is the earliest drawn start after sorting, pinned by a test that feeds rows out of order, and rows with no recorded start are still never drawn in either mode.
  • The clock formatter carries the two tests I would have asked for, as many fractional digits as the window needs and the first hour after midnight reading as 00 rather than 24, and coverage reaches the precision risk a wall-clock axis introduces: a short call placed exactly inside a long stretch of real time, the round trip back to compressed time, and the zoom dropped on a mode switch. The new copy is present in both languages and reuses the existing clear-selection key rather than adding a near-duplicate.

@qqqys
qqqys added this pull request to the merge queue Sep 24, 2026
Merged via the queue into QwenLM:main with commit 759df71 Sep 24, 2026
52 checks passed
@chiga0

chiga0 commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Post-merge review (head 971ef827, merged) — No blocking findings.

Checked:

  • buildTimeline.ts — activeMs = total - idleMs is correct. In clock mode total is the full wall-clock span; idleMs sums every gap between coveredUntil and the next span start. The test fixture confirms: total 60 500 ms − idle 58 750 ms = activeMs 1 750 ms ✓
  • originMs = raw[0].start — valid because raw is sorted by start before the walk (raw is assigned from spans sorted ascending). The "whatever order the rows are in" test pins the subagent-listed-first case.
  • formatClockTime — fractional-digit thresholds (≥10s→0, ≥1s→1, ≥100ms→2, else 3) match the test fixture. hourCycle: h23 gives 00:05:06 for midnight, not 24:05:06.
  • Commit 3 (remove mode prop from TrajectoryOverview): correct — clock = model?.mode === 'clock' already derives the mode from the model. Passing it separately was a redundant and potentially inconsistent channel.
  • Commit 4 (range key on {trajectory, mode} not timeline): correct — keying on the memoized result is unsound because React preserves memo identity as an optimisation, not a contract. Keying on the two inputs that fully determine the axis is the right fix; the "drops a selection made on the other axis" test would catch a regression.
  • role="status" removed from the empty-range div: correct — the trajectory-range-status element (an existing live region) already announces the row-count change in the same render; two live regions would say it twice. Test asserts role is null and no ancestor role=status.

Not run: local vitest or E2E suite (no working tree). Author reports E2E passed 192 on efc49031fd with all four real-time cases.

Reviewed with AI assistance.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants