Skip to content
This repository was archived by the owner on Sep 4, 2026. It is now read-only.

feat(chat): let the working row say what is running and for how long - #392

Merged
ElbertePlinio merged 1 commit into
mainfrom
feat-365-turn-activity
Jul 26, 2026
Merged

feat(chat): let the working row say what is running and for how long#392
ElbertePlinio merged 1 commit into
mainfrom
feat-365-turn-activity

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

The turn-level half of #365 — its headline complaint, which #387 did not address.

Mid-turn the chat showed a bare WORKING dot and nothing else. During a four-minute lanes_wait there was no way to tell thinking from blocked on a tool from dead.

Two message kinds were arriving and being dropped

The bridge is a pure pass-through — scripts/claude-bridge.ts forwards every SDKMessage verbatim and claude_bridge.rs re-serialises it untouched. Nothing was lost before the parser. The dispatch in claude_stream.rs simply handled only system / stream_event / assistant / user / result:

  • tool_progress{tool_use_id, elapsed_time_seconds}, emitted as a heartbeat while a tool executes. This is an authoritative clock from the source. There was no timing field anywhere in the pipeline, so the only alternative was a client-side timer, which drifts and lies across a slow frame or a reconnect.
  • system / subtype: "status" — the SDK's own compacting / requesting.

The status branch is checked BEFORE handle_system's init guard, which returns early once the session has started. Put it after and every status following init is silently dropped — that has its own test, because it is exactly the bug this change could have shipped.

Live-only, deliberately

run_agent_event records both alongside ProviderPayload as never persisted. They describe a turn while it runs; replayed from history they are meaningless — the tool has finished, and its real duration is whatever the completion recorded. The exhaustive match in manager.rs forced that decision rather than letting it default.

Naming the row

The store matches the heartbeat's itemId against the timeline row that started it, so the row names itself:

row shown as
toolUse the tool name
mcpToolCall server/tool
command the command line

An unmatched id is ignored, not shown as a nameless clock — other providers never emit this at all. Activity clears on every turn boundary including turnFailed; a test pins that, and it caught a real gap where my first pass missed the inline turn-close paths.

WorkingRow renders name and elapsed on the row that already exists rather than adding a badge (the standing readouts-need-an-edge rule), and the accessible name carries both. Elapsed formats as 12s / 2m 05s — it only formats, it never counts.

Behind the default-off turnActivity flag; flags.test.ts enumerates the registry so it is asserted, not silently added.

Tests

Rust (5) and store (6), all verified load-bearing:

  • tool_progress → elapsed; missing id or clock ignored
  • system/status → activity; a status frame after init still surfaces; empty status ignored
  • running tool named for all three row kinds; unmatched id ignored; activity cleared on turn close

Removing the reducer case fails 3 store tests; the Rust suite is 32 passing in claude_stream.

Validation

  • cargo test -p pickforge-core claude_stream — 32 passed
  • cargo clippy -p pickforge-core --all-targets -- -D warnings — clean
  • bun run test:unit1616 passed
  • bunx tsc --noEmit clean; bun run lint clean

What still remains on #365

  • No VRT fixture drives a live tool_progress, so the working row has unit coverage but no snapshot. The mock emits a completed history, not a mid-flight turn — adding that is its own fixture work.
  • Streaming tool arguments (input_json_delta) and subagent attribution (parent_tool_use_id) remain deferred, as the issue says.

Refs #365

The turn-level half of #365. Mid-turn the chat showed a bare `WORKING` dot and
nothing else — during a four-minute `lanes_wait` there was no way to tell
thinking from blocked on a tool from dead.

**Two message kinds were arriving and being dropped.** The bridge forwards every
`SDKMessage` verbatim and `claude_bridge.rs` re-serialises it untouched, so
nothing was lost before the parser; the dispatch in `claude_stream.rs` simply
handled only `system`/`stream_event`/`assistant`/`user`/`result`:

- `tool_progress` — `{tool_use_id, elapsed_time_seconds}`, emitted as a
  heartbeat *while a tool executes*. This is an authoritative clock from the
  source. There was no timing field anywhere in the pipeline, so the
  alternative was a client-side timer that drifts and lies across a slow frame.
- `system` / `subtype: "status"` — the SDK's own `compacting` / `requesting`.

The status branch is checked BEFORE `handle_system`'s init guard, which returns
early once the session has started — otherwise every status after init is
silently dropped. That has its own test.

Both are **live-only**: `run_agent_event` records them alongside
`ProviderPayload` as never-persisted. They describe a turn while it runs and are
meaningless replayed from history, where the tool has finished and its real
duration is whatever the completion recorded.

The store tracks the running tool by matching the heartbeat's `itemId` against
the timeline row that started it, so the row names itself — a tool by name, an
MCP call as `server/tool`, a command by its command line. An unmatched id is
ignored rather than shown as a nameless clock, since other providers never emit
this at all. Activity clears on every turn boundary, including `turnFailed`.

`WorkingRow` renders the name and elapsed time on the row that already exists
rather than adding a badge, and the accessible name carries both. Behind the
default-off `turnActivity` flag.

Refs #365
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant