# Web App: subagent panel with a live call graph, streaming that survives refresh, live header stats, one-line mobile rows, Trace import/export, and in-app version/update

Six chat-and-observability changes land together: subagent conversations move out of the message flow into a dockable panel with a call graph, an in-progress reply now survives a page refresh, the header statistics tick live, running-state rows stop growing on phones, Trace files can be moved across deployments, and the app can tell you what version it runs and update itself.

## Subagent conversations move to a side panel with a call graph

A `run_subagent` call used to inline the child session's entire conversation into the parent's message flow, nested cards within cards. The child conversation now lives in a dedicated **agents panel** that docks on the right exactly like the Workspace files panel (toolbar toggle, drag-to-resize with width memory, bottom sheet on narrow viewports, and the two docks are mutually exclusive so they never crush the chat column). In the message flow each child leaves only a full-width bar row styled like the other collapsed stream rows — agent avatar, resolved agent name (from the child's own `session_meta`, falling back to the spawn arguments, the session list, then a short id), a spinner while it runs, and an amber dot whenever an approval is pending anywhere in its subtree; the dot is part of the accessible name, and the toolbar button carries the same badge, so a nested approval stays discoverable with the panel closed. The panel's top shows a **call graph**: one node per participating agent (avatar + name + run-state dot + elapsed time — ticking wall clock while it runs, frozen from message timestamps when done, so a reloaded page shows the same durations as a live one; the main session is the root), edges for who spawned whom, repolled children deduplicated. Clicking a node switches the conversation shown below — rendered by the same machinery as the main stream, so nested tool cards stream live and approval buttons work from inside the panel. The graph shows the latest Task by default and resets to it on every Session switch; clicking a row from an older turn pins that turn's Task and shows its historical spawn tree instead. Opening either docked panel (any path, toolbar included) closes the other, so the two can never crowd the chat column together. The panel's visibility is **task-scoped**: every new Task starts with it closed (an open panel from an unrelated previous task never lingers), it auto-opens once per task when that task first spawns a subagent (docked layouts only), and a manual open or close always wins for the rest of the task; entering a session starts closed too — this per-task rule is the one place the panel deliberately diverges from the files panel's persistence, and a goal-mode iteration counts as a task boundary. The child conversation also finally shows its own user side live: the subagent runner forwards the spawn prompt (and follow-up inputs) through the origin-tagged stream, so the panel renders the child's user messages identically live and after a reload.

## An in-progress reply survives refresh and reconnect

Refreshing mid-Task used to lose whatever was streaming — a half-written reply, thinking, or a long tool call's partial output — until the message completed, because history is read from the Trace (complete messages only) and a fresh SSE connection replays nothing. The server now keeps a **live tail** per running session: an aggregate of every open fragment, keyed by origin chain and fragment identity, updated in the same tick a message is published and capped so a runaway output keeps only its tail. The messages endpoint returns it as `live: { cursor, fragments }`, captured atomically before the trace read begins. The client seeds the fragments as synthetic `partial_* start` events at the cursor position, drops its buffered partials at or before the cursor (their content is already inside the fragments), and lets complete messages keep flowing through the existing overlap dedup — deliberately never cursor-dropping them, because a publish can precede its trace append landing on disk. The effect: after a refresh, close-and-reopen, or a resync, the already-streamed prefix is back immediately and keeps growing, for the main session and for subagents alike. The end-to-end test reloads mid-command and mid-text and asserts the final transcript is byte-identical to a never-reloaded run.

## Header statistics update live

The three chips in the chat header (session Tokens including subagents, cost, elapsed) effectively refreshed only when a Task finished. Elapsed now ticks once per second while a Task runs (settled cross-Task total plus the running Task's wall clock, folded together by the same pure helper the tests pin down, so the running addition can never double-count across the Task boundary). Cost shows the last server-recorded value plus a live estimate converted from the running Task's usage buckets at the current Model's pricing — subagents on other models make the estimate slightly approximate mid-Task, and the idle refetch reconciles to the recorded value; without pricing the live addition is skipped and the `*` uncosted marker keeps its meaning. Tokens already advanced per completed request and are untouched. On a brand-new session the cost chip stays hidden until the first usage lands rather than flashing a formatted zero the instant a Task starts.

## Running-state rows stay one line on mobile

Below 640px, running-state UI no longer wraps or grows: the reply's stats footer — whose fixed height made wrapped chips paint over the content below, the one genuine bug — stays one line, and both message footers (reply and user bubble) are now **always visible on phones**: their hover-reveal never fired under touch, a gap that predated this PR. To fit 390px without scrolling, the TPS chip hides below `sm` and the numeric chips use compact variants there (money at two significant digits so a nonzero cost never reads `$0.00`; whole seconds from 10s up); everything still sits in a hidden-scrollbar scroll row as a fallback, with the copy button pinned at the end. The header shows a pulsing dot instead of the "Running" text and an icon-only Workspace button; the work group hides the step count and collapses the awaiting-approval pill to a labeled amber dot. On tool cards the textual decision indicator is **gone at every breakpoint** — the status icon alone carries "Approved · auto/manual" or "Denied · …" as its accessible name (exactly one source of truth, and a denied card no longer also says "aborted"). Action buttons follow the opposite rule: Allow/Deny stay **textual everywhere** — indicators may be iconic, buttons must be words — and while a call awaits approval on a phone, its full command wraps into view (the one sanctioned exception to the one-line rule: you read what you approve), collapsing back after the decision. Desktop rendering is pixel-unchanged.

## Trace files can be exported and imported

The Traces page can now move a trajectory across deployments: any member downloads a Trace file verbatim (`application/x-ndjson` attachment), and a Project owner imports one under any agent via an upload button on the agent's row (14MB cap, mirroring the Agent snapshot import). An imported file must be valid Trace JSONL whose first record is a `session_meta` with a filename-safe `session_id` — re-validated in the service right where the path is built. A session id that already exists under the target agent is rejected outright (409): index _N_ of a session means "context segment _N_", so absorbing an unrelated file there would splice it into the existing transcript, silently become the resume point, and race a live session's writer. An accepted import is therefore always file 001 of a new session, written with `wx` so even concurrent imports cannot clobber each other, filed under its first record's local date (matching the Trace writer's convention), and oversized picks are refused client-side before any upload. The tree refreshes and selects the imported file immediately; imported sessions are browsable like any other directory-discovered ("unmanaged") session.

## The app knows its version and can update itself

The sidebar user menu gains a **Check for updates** row directly below Change password, with the running version inline on its right (`v0.1.3`, no product-name prefix) and a superscript "New version available" badge when the check finds one; the row's tooltip carries a human-readable `Last updated Jul 26` (`最近更新日期 7 月 26 日`) taken **offline-only** from the release-stamped `BUILD_DATE` — every future release stores its date at publish time in both the tarball and npm jobs, and no network lookup is ever made for it. Clicking the row forces a fresh check (`?force=1` presses through the server's TTL cache — user-initiated and low-volume — and the outcome is re-cached), with "you're on the latest" and failures surfacing through the shared toast. The new-chat draft page carries the same quiet line under its subtitle, with the badge as the release link. Opening the menu also triggers an update check, the server's first and only outbound internet call: the latest GitHub Release is fetched with the same request shape as `penguin update`, cached server-side (an hour on success, ten minutes on failure), strictly fail-soft — a failed lookup reports "no update known", never an error — and `PENGUIN_UPDATE_CHECK=off` disables it entirely. When a newer release exists, the avatar gains a dot and the menu a release-notes link; admins additionally get **Update now**, which re-runs the CLI's own updater (`penguin server|web` exports its entry as `PENGUIN_CLI_ENTRY`; the endpoint spawns `node <entry> update --yes` with a fixed argument list — nothing from the request reaches the command). The CLI's install-kind detection and refusals pass through verbatim, and a successful run says the one thing that still needs a human: restart the service to load the new code.
