Commit graph

2 commits

Author SHA1 Message Date
Shaojin Wen
170ce7917d
feat(web-shell): show Settings and Daemon Status as an in-place panel (#6341)
* feat(web-shell): show Settings and Daemon Status as an in-place panel

The Settings and Daemon Status buttons opened centered modal overlays that
dimmed the whole app. Render them as a full-height panel that replaces the
chat surface instead — a Back button or Escape returns to the chat — with the
content left-aligned and filling the chat pane width rather than centered in a
narrow column. On the Daemon Status page, give the time-series charts a taller
plot and the overview cards a wider track so a wide window is actually used;
the tab grouping (overview / metrics / diagnostics) is kept.

* fix(web-shell): preserve composer draft and refine panel focus/escape

Address review feedback on the in-place Settings / Daemon Status panel:

- Keep the chat view (message list + composer) mounted and just hidden while a
  panel is shown, so typing a prompt, opening Settings/Status, then going Back
  no longer discards the unsent draft and attachments (the composer subtree was
  being unmounted and remounted empty).
- Focus the Back button when a panel opens and restore focus to the composer
  when it closes, replacing the focus management DialogShell used to provide.
- Reload workspace settings after the fast-model command resolves so the still-
  mounted Settings panel doesn't keep showing the previous value.
- Don't close the panel when Escape is handled inside the sidebar (its search
  input clears on Escape without stopping the event).
- Guard the overview card grid with min(100%, 340px) so a 340px track can't
  overflow a narrow panel.

* fix(web-shell): reset panel scroll when switching Settings/Status

Add key={activePanel} on the panel body so switching directly between Settings
and Daemon Status (activePanel goes 'settings' -> 'status' without passing
through null) remounts the scroll container and opens at the top instead of
inheriting the previous panel's scrollTop.

* fix(web-shell): restore new-chat vertical centering

The chatViewWrap added to keep the composer mounted while a panel is shown was
filling the pane, which cancelled the empty (new-chat) state's vertical
centering (welcome header + composer stuck to the top instead of centered).
Shrink the wrapper to its content in the empty state so
`.appChatEmpty .chatPane { justify-content: center }` centers it again, matching
how `.content` behaves there.

* fix(web-shell): restore session-org test destructuring dropped in merge

My earlier merge of origin/main 3-way-combined WebShellSidebar.test.tsx
incorrectly, dropping the `{ }` destructuring from the 7 session-organization
tests. renderSidebar returns `{ container, rerender }` (not the element), so
`const container = renderSidebar(...)` made `container.querySelector` throw.
Restore the file to origin/main so the tests pass again.

* fix(web-shell): surface pending approvals over Settings/Status panel

The in-place Settings/Daemon Status panel hides the chat footer with
display:none, and the ToolApproval / AskUserQuestion overlays live in
that footer. A gated tool call that arrived while a panel was open
rendered the approval into a hidden container, so the turn hung with no
visible prompt (reported [Critical]).

Close the panel when an actionable approval is pending so it surfaces.
Only actionable approvals count (pendingToolApproval / pendingAskUserApproval
already gate on canActOnPendingApproval), so a non-owner in a shared
session isn't yanked out of Settings by someone else's prompt. Leave
focus for the overlay rather than the composer on this path: ToolApproval
uses a window-level key handler that ignores editable targets, so
focusing the composer would swallow its shortcuts.

Also refocus the Back button on panel->panel switches (not just on open),
so focus no longer relies on the Back button being the same DOM node
across the keyed panel body, and expose SvgLineChart's plot height as
--chart-height (fallback 140px) so a constrained caller can shrink it.

Adds App-level tests: the panel auto-closes on a pending tool approval,
and stays open when the only block is a resolved (non-actionable) one.

* test(web-shell): cover AskUserQuestion auto-close; robust panel selector

Follow-up to the approval auto-close fix, addressing review suggestions:

- Add data-testid="inline-panel" to the panel <section> and query by it in
  App.test instead of querySelector('section'), which would false-positive if
  any future component renders a <section>.
- Add a companion test for the pendingAskUserApproval branch of the auto-close
  effect. The ask-user block carries toolCall.input.questions so
  isAskUserPermission() classifies it as an AskUserQuestion; mutation-checked
  (restricting the effect to pendingToolApproval fails only this test).
- Document why handleFastModelSelect's post-sendPrompt reload is not racy:
  sendPrompt awaits waitForAcceptedPromptCompletion, so the /model --fast turn
  has already applied when reloadWorkspaceSettings() runs. Also note why the
  command path needs the explicit reload that the setWorkspaceSetting pickers
  (vision/voice) get for free via the settingsVersion signal.

* fix(web-shell): close inline panel when resuming a session

The /resume <id> command and the ResumeDialog onSelect both call
loadSession() without closePanel(), unlike createNewSession and
loadSidebarSession. Loading a session means the user wants that chat, so
leaving a Settings/Daemon Status panel open would hide it. Add closePanel()
to both paths for consistency (a no-op when no panel is open).

Currently reachable only in theory — the composer that submits /resume is
display:none while a panel is shown — but the guard keeps the invariant if
a non-composer entry point (sidebar/shortcut) is ever added.

Adds a mutation-checked test (removing closePanel from the /resume handler
fails it) and gives the ChatEditor test mock a focus() method, since the
panel-close focus effect now runs editorRef.focus() on this no-approval path.

* fix(web-shell): keep composer dormant while an approval overlay is up

Follow-up to the approval auto-close fix. When an approval arrives while a
Settings/Status panel is open, the auto-close clears activePanel, so
interactionBlocked flips false and useComposerCore's dialogOpen effect
refocuses the still-mounted composer. ToolApproval ignores approval
shortcuts from editable targets, so the now-visible approval stops
responding to Enter/Escape/number keys until focus moves away.

Key the ChatEditor dialogOpen prop off the pending approval too, so the
composer stays blurred while an approval owns the keyboard. Consolidate the
"an approval overlay is active" condition — previously duplicated in the
auto-close effect and the panel focus guard — into a single
approvalOverlayActive value so the three consumers can't drift.

Uses the actionable-approval condition (pendingToolApproval /
pendingAskUserApproval) rather than raw pendingApproval, matching the
auto-close gating and avoiding a blurred composer when a non-actionable
approval renders no overlay.

Adds a mutation-checked test asserting dialogOpen is true while an approval
is pending with no panel open.

* test(web-shell): cover the Daemon Status panel branch

A review note observed that all five panel tests open via /settings, so the
activePanel === 'status' branch (DaemonStatusDialog) had no coverage — a
regression in the 'status' literal would go undetected. Add a test that
opens Daemon Status through the sidebar and asserts the panel opens and
auto-closes on a pending approval, exercising that branch and confirming the
auto-close is panel-type-agnostic.

Gives the WebShellSidebar test mock an onOpenDaemonStatus button, since
there is no slash command to open the status panel. Mutation-checked:
neutering setActivePanel('status') fails only this test.

* fix(web-shell): dismiss stacked dialogs on approval, focus overlay, a11y

Addresses a review round on the inline Settings/Status panel.

[Critical] When an approval arrives while a DialogShell sub-dialog (model
picker / approval-mode picker) is open over the panel, the auto-close
removed the panel but left the sub-dialog backdrop covering the footer
approval overlay, so the turn hung. Worse, the approval-mode picker stayed
usable — selecting "yolo" auto-approved (handleSetMode) a tool call the
user never saw. Dismiss the panel AND both sub-dialogs when an actionable
approval is pending.

[Critical] After the panel closes for an approval, focus fell to <body>
(the Back button unmounted; ToolApproval has no autofocus). Move focus onto
the ToolApproval overlay wrapper (tabindex=-1, so its window-listener
shortcuts keep working and Enter doesn't confirm early) once it is visible.
AskUserQuestion keeps managing its own focus.

Suggestions:
- Guard reloadWorkspaceSettings() rejection (was unhandled).
- aria-hidden the display:none chat view so AT can't wander into it.
- Close the panel before /model --fast so its response shows in the chat
  in context instead of piling up behind the hidden panel.
- Remove the dead .panelBodyInner wrapper (redundant width:100%).
- Rename data-mobile-drawer -> data-sidebar-shell (it wraps the desktop
  sidebar for all viewports) across App.tsx + standalone.css.

Tests (+5, mutation-checked): sub-dialog dismissal on approval, overlay
focus, Escape closes panel / sidebar-Escape does not, dialogOpen while a
panel replaces the chat. Adds an observable DialogShell test mock.

* fix(web-shell): restore composer focus after approval resolves; cleanups

[Critical] The panel focus effect consumed prevActivePanelRef to null on the
approval auto-close (correctly skipping editor focus then). When the approval
later resolved with no panel to return to, neither branch fired and focus was
left on <body> — the visible composer took no keyboard input. Track the
approvalOverlayActive transition too and restore composer focus on
approval-resolve-after-panel-close. (useComposerCore's dialogOpen effect also
covers this; the extra branch makes the panel effect self-contained.)

Suggestions:
- Log the reloadWorkspaceSettings() rejection instead of swallowing it.
- Remove the dead :global([data-dialog-fullscreen]) .grid rule (the
  allowFullscreen source was removed when Daemon Status became a panel).

Tests (+4, focus-restore mutation-checked): focus restored after an approval
resolves post-auto-close; Back-button closes the panel and restores focus;
fast-model pick closes the panel, sends /model --fast, and reloads settings;
chat view is aria-hidden while a panel is shown. Adds interactive
SettingsMessage / ModelDialog mocks and stable editor-focus / settings-reload
spies.

* fix(web-shell): make Settings/Status panel and Scheduled Tasks mutually exclusive

Opening Scheduled Tasks (mainView — a position:absolute z-index overlay) and
then Daemon Status (activePanel) rendered the Daemon Status panel *behind* the
Scheduled Tasks overlay, so the button looked like it did nothing. These are
mutually-exclusive full-pane views, so opening one must close the other.

Centralize into openPanel() / openScheduledTasks() helpers (last-opened wins)
and route all six open sites (the /settings and /schedule commands, the three
sidebar handlers, and the StatusBar) through them.

Mutation-checked tests: opening Daemon Status closes the Scheduled Tasks page
(the reported repro), and opening Scheduled Tasks closes an open panel.
2026-07-06 05:06:46 +00:00
Shaojin Wen
52a190b5c6
feat(web-shell): time-series metrics charts on Daemon Status (#6307)
* feat(web-shell): time-series metrics charts on Daemon Status

Add seven bottleneck-analysis line charts (concurrency, requests, API
latency, prompt latency, event-loop lag, memory, token burn) to the
Daemon Status dashboard, backed by a new server-side metrics ring.

The status endpoint is a point-in-time snapshot, so line charts need a
time series. A bounded ring buffer in the daemon (daemon-metrics-ring.ts)
seals one bucket every 5s (~15min retained) from three seams:
- HTTP request rate/latency via the telemetry middleware
- prompt queue-wait/duration via the bridge telemetry hooks
- per-round token usage sniffed at the bridge session/update fan-in
  (new DaemonBridgeTelemetryMetrics.tokenUsage hook)
plus memory / active sessions+prompts / a window-scoped event-loop lag
p99 read as gauges at seal time.

The series rides the existing GET /daemon/status contract
(runtime.metrics.series), threaded through the SDK types (JSON passthrough)
to a dependency-free inline-SVG chart component in web-shell -- no charting
library added to the CSP-strict serve --web bundle.

Tests: metrics-ring math, token-usage sniffing on the real sessionUpdate
path, and SVG chart rendering. Verified end-to-end against a live daemon
(GLM-5.2): requests/latency/memory/event-loop, real token burn and prompt
duration, with the concurrency gauge tracking active prompts.

* feat(web-shell): tabs, chart tooltips, and fullscreen for Daemon Status

Split the now chart-heavy Daemon Status dashboard into Overview / Metrics /
Diagnostics tabs (status badge, refresh, and issues stay global) so
monitoring, configuration, and troubleshooting each get their own space
instead of one long 70vh scroll.

Add an interactive hover cursor to the charts: a vertical time line, a dot on
each series, and a tooltip reading the bucket time plus every series' value at
that point -- previously only the latest value and peak were legible, from the
legend.

Add an opt-in fullscreen toggle to DialogShell (via allowFullscreen, wired for
Daemon Status) that expands the panel to near the full viewport; scrolling is
consolidated into the shell body so the content actually grows with it.

Tests: tab switching + diagnostics-behind-tab, SVG tooltip rendering, and the
DialogShell fullscreen toggle. Verified end-to-end against a live daemon
(GLM-5.2) with real request / token / prompt data.

* feat(web-shell): add CPU, LLM-latency, queue-depth, IPC & connection metrics

Extend the Daemon Status metrics ring with more bottleneck-analysis
dimensions, filling the two biggest gaps — resource cost had only memory
(no CPU), and latency had only client->daemon HTTP (not daemon->model):

- CPU %: process.cpuUsage() delta, core-normalized (memoryPressureMonitor
  formula, clamped 0-100), sampled alongside memory.
- LLM API latency p50/p95: the token frame's _meta.durationMs (the
  daemon->model round-trip), separating 'model is slow' from 'we are slow'.
- Prompt queue depth: a new bridge.pendingPromptTotal aggregate, folded into
  the concurrency chart beside active tasks.
- IPC pipe throughput: daemon<->ACP-child stdio bytes (already measured; now
  windowed via metricsRing.recordPipe).
- Connection counts (SSE/WS/ACP) and rate-limit rejections, read lazily in the
  sampler from the ACP handle registry and the rate limiter.

The tokenUsage telemetry hook is widened to carry durationMs. Verified
end-to-end against a live daemon (GLM-5.2): LLM p95 28.6s vs HTTP p95 324ms,
queue depth 1, IPC peak 0.3MB, SSE gauge 1 on a live stream.

* feat(web-shell): add ACP child process CPU/memory (self-reported over ACP)

The daemon's own CPU/memory only tell half the story — the real LLM/tool work
runs in the spawned 'qwen --acp' child, which is where the resource cost lives.
Surface it: the child self-reports its rss + cpuPercent to the daemon over a new
read-only ACP extMethod (qwen/status/workspace/resource); the bridge caches the
latest sample on the live channel, and the metrics sampler reads it
synchronously each tick (firing an async refresh for the next, off the hot path).

The child computes cpuPercent as a process.cpuUsage() delta between polls (no
dependency on MemoryPressureMonitor's tool-gated sampling), core-normalized and
clamped. Rendered as a second line on the CPU and Memory charts (daemon vs
child, side by side).

Verified end-to-end (GLM-5.2): child RSS ~300MB vs daemon RSS ~225MB, child CPU
tracking above the daemon's -- the child is the resource hog, now visible.

* test(web-shell): cover Metrics tab, chart rendering, and the recordRequest seam

Address review — the metrics dashboard's rendering and its HTTP data seam had
no tests:
- DaemonStatusDialog: switching to the Metrics tab renders the charts from the
  series (one SvgLineChart per card) and hides the Overview panel; an empty
  series shows the collecting-metrics placeholder.
- daemonTelemetryMiddleware: recordRequest fires once with (durationMs,
  statusCode) on a matched route (real status code; once across finish/close),
  is not called for unmatched routes, and is a silent no-op when omitted.

* fix(web-shell): enlarge Daemon Status charts in fullscreen

Fullscreen widened the panel but the charts stayed small — the grid just packed
in more 280px cards at a fixed 52px SVG height, so the extra viewport bought
more small charts, not bigger ones. Now the DialogShell body carries a
`data-dialog-fullscreen` marker; the chart grid switches to wider cards (min
480px → fewer columns) and the SVG grows to 120px, so fullscreen actually
enlarges the plots. Verified: 2 wide columns at 120px vs 3-4 columns at 52px.

* fix(web-shell): resolve chart colors in portal, guard child-resource polling

Address review (real-user + ci-bot):
- [Critical] Chart colors (--primary, --agent-blue-400) resolved to nothing in
  the DialogShell portal (createPortal to document.body escapes the app root that
  defines them), so ~half the chart lines rendered stroke:none. Add both vars to
  DialogShell's own theme scope. Verified: 25/25 path strokes colored (was 5 none).
- [Critical] refreshChildResource had no in-flight guard; requestWorkspaceStatus
  waits up to 10s (> the 5s cadence), so a degraded child accumulated concurrent
  polls. Add a single-flight guard.
- [Critical] getChildResourceSnapshot returned last-good rss/cpu forever; add a
  30s staleness window so a stuck child reads 0 instead of looking healthy.
- Exclude GET /daemon/status (the dashboard's own poll) from the metrics-ring
  request rate, so the Requests chart doesn't count itself.
- Fix cpuPercent JSDoc (percent of total capacity across cores, clamped [0,100])
  in the ring + SDK mirror; add a keep-in-sync cross-reference on the mirror.

Tests: recordRequest excludes /daemon/status; buildDaemonStatusResponse embeds
runtime.metrics.series when provided and omits it otherwise.

* fix(web-shell): address Daemon Status charts review feedback

Correctness fixes surfaced in review:

- bridgeClient: guard token accounting on a live `entry`. On the
  `session/load` path HistoryReplayer re-emits saved usage as live
  session/update frames before the session entry is registered, which
  otherwise dumped a session's historical token total into the current
  metrics window as a phantom burn spike with no model call.
- run-qwen-serve metrics sampler: wrap each tick in try/catch/finally so a
  throwing getter can't crash the daemon; reset the event-loop-lag histogram
  in finally so a thrown tick can't permanently discard it; skip the CPU
  delta (and leave the baseline untouched) when process.cpuUsage() throws;
  seed the rate-reject baseline on the first tick instead of reporting the
  whole since-start backlog as one spike.
- acpAgent workspaceResource: advance the child-CPU baseline only on a
  successful read, avoiding a ~2x phantom spike on the poll after a failure.
- bridge.pendingPromptTotal: count only queued prompts (state === 'queued'),
  not the running one, so the "Queued" chart reflects real backpressure and
  no longer shadows the "Active tasks" line.
- Make the new Daemon Status bridge hooks optional in AcpSessionBridge and
  optional-chain them in the sampler, so a bridge injected via
  RunQwenServeDeps.bridge that predates them degrades gracefully.

Robustness / UX:

- daemon-metrics-ring sanitizes non-finite gauges to 0 so a bad reading
  never serializes as JSON null and gaps the chart.
- child-resource refresh logs failures at debug for observability.
- formatBytes drops to KB/B for sub-MB pipe traffic (was "0.0 MB").
- SvgLineChart peak label is now i18n'd (daemon.charts.peak).
- Daemon Status tabs get the full WAI-ARIA tabs pattern: aria-controls,
  role=tabpanel, and Arrow/Home/End keyboard navigation with roving tabindex.

Tests: replay token guard (no live entry), pipe/gauge/sample-cap defenses,
large-value legend formatting, and tab keyboard navigation.

* fix(web-shell): keep Daemon Status fullscreen + tooltip correct in dialog portal

Two DialogShell-portal theme-scope issues surfaced by a follow-up review:

- Fullscreen was clamped back to 80vh on narrow screens: the
  `@media (max-width: 560px)` `.panel` rule has equal specificity and later
  source order than the base `.panelFullscreen`, so it won. Add a media-scoped
  `.panelFullscreen` override so fullscreen actually expands on mobile.
- SvgLineChart tooltip background used `var(--popover, var(--card))`, neither of
  which the portal theme scope defines, so the declaration dropped and the
  tooltip rendered transparent over the chart. Fall back to `--background`
  (which the dialog scope does define).

* fix(web-shell): flip chart tooltip below cursor near scroll-container top

The Daemon Status charts live inside DialogShell's overflow-y:auto body, so the
topmost chart's upward tooltip (bottom: calc(100% + 4px)) clipped against the
scroll container's top edge, truncating the time header / first series row on
hover. SvgLineChart now resolves its nearest scroll parent and flips the tooltip
below the cursor when the plot sits within ~one tooltip-height of that clip
boundary.

* fix(daemon-status): harden child-resource CPU/memory + sampler lag on failure

Follow-up review fixes:
- acpAgent: prevChildCpu inits to null (not {0,0}) and the workspaceResource
  handler gates the delta on a live prevCpu baseline, so an init-time
  cpuUsage() failure no longer manufactures a phantom spike on the first poll
  — mirrors the daemon sampler's safeCpuUsage null-on-failure contract.
- acpAgent: guard process.memoryUsage() too, reporting 0 rss on failure while
  keeping the already-computed cpuPercent instead of throwing the handler.
- bridge: require Number.isFinite() (typeof NaN === 'number' is true) and
  clamp cpuPercent to [0,100] when caching the child's self-report.
- run-qwen-serve sampler: gate the 5s child-resource refresh on an active
  SSE/WS client (idle staleness already reads 0), and hoist the event-loop
  lag read before the try so a thrown tick charts the real accumulated lag
  instead of a misleading 0.

* fix(daemon-status): protect artifact path from metrics callback + share CPU delta

Follow-up review fixes:
- bridgeClient: wrap recordLiveTokenUsage in try/catch so a throwing injected
  onTokenUsage callback can't skip the critical artifact processing after it —
  metrics are optional, artifacts are not.
- Extract computeCpuPercent() into daemon-metrics-ring and share it between the
  daemon self-sampler and the ACP child's workspaceResource handler, removing
  the duplicated delta/normalize/clamp math and giving it direct unit coverage
  (null sample, non-positive window, normalization, phantom-spike + negative
  clamps).
- Add a single-flight test for bridge.refreshChildResource (two rapid calls
  collapse to one in-flight RPC).
2026-07-05 03:06:09 +00:00