feat(chat): work-status panel, and MCP auth and settings fixes (#2776)
Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration.
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
# Work-status panel
|
||||
|
||||
A card rendered to the right of the transcript inside `ChatContainer`. It
|
||||
reports the state of the current session, its branch, its quotas and its
|
||||
subagents.
|
||||
|
||||
## Structure
|
||||
|
||||
Every readout is a **labelled row**: icon, name, trailing value. A number
|
||||
without a name is unreadable at a glance, which is what an unlabelled stream of
|
||||
values degenerates into.
|
||||
|
||||
Rows are grouped into **named sections**, one component each, composed in
|
||||
order by `WorkStatusPanel`. The separator between them is a
|
||||
`:not(:first-child)` CSS rule rather than a prop, because every section renders
|
||||
conditionally; passing "am I first?" down would mean each one tracking what the
|
||||
sections above it decided to render.
|
||||
|
||||
Sections render nothing when they have no rows, so the panel collapses upward
|
||||
instead of reserving empty space.
|
||||
|
||||
## What it is not
|
||||
|
||||
It is **not** a context-panel surface. It is not registered in
|
||||
`lib/surfaces/registry.ts`, has no rail icon, no tab, no persisted width and no
|
||||
resizer. It is a card floating inside the chat column — rounded border, faint
|
||||
fill, its own margin — rather than a docked pane flush against the window edge.
|
||||
|
||||
## Placement
|
||||
|
||||
`ChatContainer`'s top-level return is a flex row:
|
||||
|
||||
- the existing chat column (`data-composer-bound`, `flex-1 min-w-0`), holding
|
||||
the viewport, the composer and the timeline dialog;
|
||||
- `WorkStatusPanel`, a fixed-width `shrink-0` sibling.
|
||||
|
||||
Nothing inside `ChatViewport` changed. The virtualizer sees the column shrink
|
||||
exactly as it already does when the context panel opens.
|
||||
|
||||
## Visibility
|
||||
|
||||
`useWorkStatusVisibility` hides the panel when any of these hold:
|
||||
|
||||
- the user switched it off;
|
||||
- the runtime is mobile or VS Code;
|
||||
- the context panel is open for the active directory;
|
||||
- the row cannot fit `WORK_STATUS_MIN_CHAT_WIDTH` of transcript alongside
|
||||
`WORK_STATUS_PANEL_WIDTH` of panel.
|
||||
|
||||
`ChatContainer` additionally suppresses it in mini-chat and in expanded-input
|
||||
mode, and the panel does not appear on a new-session draft: that branch returns
|
||||
its own layout before the one that hosts the panel. The repository readouts
|
||||
would apply there — branch and working-tree state inform what to ask for — so
|
||||
this is a gap worth closing rather than a decision.
|
||||
|
||||
`rowRef` is a **callback ref, not an object ref**. An object ref gives no signal
|
||||
when the node attaches, so the measuring effect read `.current`, found nothing
|
||||
whenever the row mounted after the effect first ran, and only recovered on the
|
||||
next unrelated dependency change — in practice, opening and closing the context
|
||||
panel. `useWorkStatusVisibility.test.ts` covers a row that attaches late.
|
||||
|
||||
### Why the chat area is measured, not the chat column
|
||||
|
||||
**The width test must observe something the panel cannot resize.** The chat
|
||||
column's width is an *output* of the visibility decision: hiding the panel
|
||||
widens the chat, which would re-satisfy a chat-width test and re-show the
|
||||
panel, which narrows the chat again — an infinite oscillation.
|
||||
|
||||
It measures the **chat area** — the container holding the chat and the context
|
||||
panel together, marked `data-chat-area` in `MainLayout`. Measuring the chat row
|
||||
instead reported a width still catching up while the context panel animated
|
||||
closed, so the panel reappeared only once that number crossed the threshold:
|
||||
the chat widened and then narrowed again. The chat area does not move when the
|
||||
context panel opens. `useWorkStatusVisibility.test.ts` pins both properties.
|
||||
|
||||
The context-panel check mirrors `ContextPanel`'s own derivation: `isOpen` alone
|
||||
is not enough, because a panel with no resolvable active tab renders nothing
|
||||
and therefore displaces nothing.
|
||||
|
||||
## Data sources
|
||||
|
||||
Everything is read from already-warm caches. The panel adds no aggregated
|
||||
endpoint and no polling of its own.
|
||||
|
||||
| Block | Source | Notes |
|
||||
|---|---|---|
|
||||
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
|
||||
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` |
|
||||
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
|
||||
| PR + checks | `usePrVisualSummary` | **read-only** |
|
||||
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
|
||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
||||
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
|
||||
| Linked threads | `lib/linkedIssues.ts` over session metadata | written by the flows that attach an issue or PR |
|
||||
| Goal | `useSessionGoal` | respects the Settings toggle |
|
||||
| MCP | `useMcpStore` | connect/disconnect reuses the dropdown's actions |
|
||||
| Pinned messages | `getContextObligatoryMessages` + `state.part` | see below |
|
||||
| Todos | live `state.todo[sessionId]`, persisted fallback | live channel wins |
|
||||
|
||||
### Context usage has its own computation, on purpose
|
||||
|
||||
`useSessionUIStore.getContextUsage` cannot serve this panel for two reasons:
|
||||
|
||||
1. It reads `getSyncMessages(sessionId)` with **no directory**, resolving to the
|
||||
*current* directory's child store, and keys off the store's own
|
||||
`currentSessionId`. A session held by another directory — a worktree, or the
|
||||
moment after a directory switch — reads as "no messages", and the readout
|
||||
vanished while the header still showed a value.
|
||||
2. It is an **imperative getter**, as is `useConfigStore.getCurrentModel`.
|
||||
Selecting one yields a reference that never changes, so calling it during
|
||||
render subscribes to nothing; the readout went stale across session switches.
|
||||
|
||||
`contextUsage.ts` therefore computes the same quantity from messages the panel
|
||||
has already subscribed to for a known session and directory, and the panel
|
||||
subscribes to `currentProviderId` / `currentModelId` for the limits.
|
||||
`contextUsage.test.ts` pins the arithmetic — notably that the *latest*
|
||||
reporting assistant turn is the answer, not a sum across turns.
|
||||
|
||||
Two further rules on this readout:
|
||||
|
||||
- The displayed percentage is computed **unrounded**. `clampPercent` applies
|
||||
`Math.round`, so routing the display value through it turned 33.6% into
|
||||
"34.0%" and made the panel disagree with the header. Rounding is still right
|
||||
for the colour threshold, which is what the header feeds it.
|
||||
- When the model exposes no context limit, the percentage falls back to the
|
||||
store's own default limit instead of disappearing.
|
||||
|
||||
There is no cost-only fallback row. A row labelled "Context" showing nothing but
|
||||
a price is not a context reading; cost rides along with the percentage or waits
|
||||
for it.
|
||||
|
||||
### Pinned messages load only what they need
|
||||
|
||||
Pins are most useful on a long session — which is exactly when the pinned
|
||||
message has scrolled far enough back not to be loaded, leaving the row with a
|
||||
placeholder. The section materialises the session, but only when a pin actually
|
||||
resolves to nothing: having pins is not a reason to fetch a session, and
|
||||
neither is something being unloaded in general.
|
||||
|
||||
### PR status is deliberately read-only
|
||||
|
||||
The panel never calls `startWatching`. PR watching is owned by the background
|
||||
tracker, and its concurrency gate exists because per-consumer PR fetches once
|
||||
saturated the browser's connection pool and stalled startup for ~20s. A panel
|
||||
that started a watch per open session would reintroduce exactly that fan-out.
|
||||
|
||||
### Changed files come from git status, not the session
|
||||
|
||||
`Session.summary` looks like the obvious source and does not work. OpenCode's
|
||||
`SessionSummary.summarize` writes `{additions: 0, deletions: 0, files: 0}` at
|
||||
the start of every turn and then fills only the **message**-level
|
||||
`summary.diffs`; session-level totals stay zero forever. The `session.diff`
|
||||
event is reset to `[]` in the same place and carries real content only on
|
||||
revert, so `state.session_diff` is not an aggregate either.
|
||||
|
||||
That leaves two honest options: aggregate per-message `summary.diffs` across
|
||||
every turn, or read git status. The panel reads git status — it is
|
||||
authoritative, already cached per directory, costs nothing extra, and sits
|
||||
directly under the branch row where working-tree state is what a reader
|
||||
expects.
|
||||
|
||||
The consequence is a real semantic difference: this counts the working tree,
|
||||
including edits the user made by hand and excluding session edits that are
|
||||
already committed. If a session-authored count is ever needed, it has to come
|
||||
from aggregating message summaries, not from `Session.summary`.
|
||||
|
||||
## Section order
|
||||
|
||||
Ordering is by durability, not category:
|
||||
|
||||
1. **Session** (goal, context, cost), **Repository** (attention, branch,
|
||||
changes, PR, checks) and **Usage** — true for as long as the session is
|
||||
open. Usage sits here rather than lower down because a spent quota stops the
|
||||
work outright;
|
||||
2. **Subagents**, **Tasks** — what is happening right now;
|
||||
3. **MCP**, **Pinned messages**, **Context sources** — supporting material.
|
||||
|
||||
## Switching it off
|
||||
|
||||
A persisted preference (`workStatusPanelEnabled`) drives a header toggle, and a
|
||||
dialog behind the equalizer icon switches individual sections off. Hidden
|
||||
sections are stored rather than visible ones, so a section added later appears
|
||||
for everyone instead of staying invisible to whoever had saved settings before
|
||||
it existed. Both travel the full settings pipeline, including the server
|
||||
whitelist without which the keys never reach `settings.json`.
|
||||
|
||||
`workStatusPanelVisible` is separate and transient: the switch can be on while
|
||||
layout still refuses the panel. The header and the git rail read it to drop the
|
||||
readouts the panel already carries, and it is deliberately not persisted — it
|
||||
describes the current frame, not a preference.
|
||||
|
||||
## Appearing and disappearing
|
||||
|
||||
The panel collapses on the context panel's own curve and duration rather than
|
||||
unmounting, and slides out to the right with a fade when switched off. It stays
|
||||
mounted wherever it could ever show, so the collapse has something to animate;
|
||||
its content is dropped once the collapse finishes.
|
||||
|
||||
An empty card is a border around a settings icon, which reads as a fault. Each
|
||||
section decides for itself that it has nothing to say, so they report through
|
||||
`presenceContext.ts` and the panel collapses when none rendered. Deriving that
|
||||
at the panel level would mean duplicating every data source the sections read.
|
||||
|
||||
The scroll offset resets on session change: restoring one session's offset into
|
||||
another's shorter panel lands somewhere arbitrary.
|
||||
|
||||
The Subagents section opens itself when subagents appear where there were none,
|
||||
on that edge only: re-expanding on every count change would fight a user who
|
||||
just collapsed it.
|
||||
|
||||
## Tasks
|
||||
|
||||
Icons and strike-through match the composer's todo dropdown, so one list does
|
||||
not read as two. Two deliberate differences:
|
||||
|
||||
- **Completed items stay.** The dropdown is a queue to work through; this is a
|
||||
record of the session.
|
||||
- **Sorted by status** — in progress, then pending, then completed — and stable
|
||||
within each rank, since the agent's own ordering carries meaning.
|
||||
|
||||
Rows truncate at this width, so each carries a delayed tooltip with the full
|
||||
task text.
|
||||
|
||||
## Collapsed Usage headline
|
||||
|
||||
Collapsed, the Usage section shows one quota rather than a mode word: the
|
||||
**shortest window reported by the provider the composer is pointed at**. A
|
||||
5-hour bucket answers "will the next turn land"; a monthly one does not.
|
||||
|
||||
Selection rules live in `usageHeadline.ts` and are pinned by
|
||||
`usageHeadline.test.ts`:
|
||||
|
||||
- provider ids are matched directly, with a small alias table for the ones that
|
||||
diverge from OpenCode's (`openai`/`chatgpt` → `codex`, `anthropic` → `claude`,
|
||||
`gemini` → `google`);
|
||||
- model-scoped rows are skipped while any provider-level row exists — a
|
||||
per-model quota is not the provider's;
|
||||
- rows without a window duration (credit balances, tool counters) are a last
|
||||
resort, never preferred over a real window;
|
||||
- **no match means no headline.** The section falls back to the display-mode
|
||||
label, because showing an unmatched provider's quota would read as the active
|
||||
one.
|
||||
|
||||
## Actions
|
||||
|
||||
Rows that name something the app can already show are buttons:
|
||||
|
||||
| Row | Opens |
|
||||
|---|---|
|
||||
| Context | the context overview (`openContextOverview`), same destination as the header readout |
|
||||
| Changes | working-tree diff (`openContextPanelTab`, `diffScope: 'working'`, no target path) |
|
||||
| Branch | git surface (`openContextSurface(dir, 'git')`) |
|
||||
| Pull request, Checks | PR surface (`openContextSurface(dir, 'pr')`) |
|
||||
| Subagent | that child session's chat tab, read-only |
|
||||
| Goal (row) | the composer's own `SessionGoalDialog` |
|
||||
| Goal (pause/resume) | `setSessionGoalStatus(sessionId, directory, status)` |
|
||||
| MCP switch | connects/disconnects the server |
|
||||
| MCP status | the state doubles as the button that reconnects |
|
||||
| Pinned (pin icon) | unpins the message |
|
||||
| Pinned (text) | jumps the transcript to that message |
|
||||
|
||||
The goal icon reproduces the **composer target button's** colour mapping, not
|
||||
the goal strip's. The two disagree today — the strip paints `paused` muted and
|
||||
`blocked` warning, the button paints them info and error — and the button is
|
||||
where this panel's reader last saw the goal. Unifying them is a separate change.
|
||||
|
||||
Jumping to a message goes through the `#message-<id>` URL hash, which
|
||||
`useChatTurnNavigation` listens for inside `ChatContainer`. It is the only
|
||||
cross-component jump the chat exposes; there is no store action or ref
|
||||
registry. An unchanged hash fires no event, so the panel clears it first to make
|
||||
a repeat press work.
|
||||
|
||||
Opening a subagent takes the same branch as the transcript's Task tool: an
|
||||
embedded panel, mobile, or VS Code navigates to the session instead of nesting
|
||||
a tab.
|
||||
|
||||
## Context sources
|
||||
|
||||
Linked GitHub threads first, then skills and MCP counts.
|
||||
|
||||
Agents are deliberately absent: an agent is who does the work, not material
|
||||
loaded into the context. Tools are absent too — `Agent.tools` is a per-agent
|
||||
override map rather than a registry, so its size would be a number that means
|
||||
something other than "tools available".
|
||||
|
||||
### Linked issues and pull requests
|
||||
|
||||
Written by the flows that already attach a thread — the composer's issue/PR
|
||||
pickers, and session creation from an issue or PR in `NewWorktreeDialog` and
|
||||
`GitHubIssuePickerDialog`. There is no manual "link this" control: attaching a
|
||||
thread to the work *is* the act of linking it.
|
||||
|
||||
Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace
|
||||
`openchamber.linked_issues`), riding the same `patchSessionMetadata` channel as
|
||||
pinned messages. Number, title, url, author and avatar only — the body,
|
||||
comments and state belong to GitHub, and mirroring them would mean owning their
|
||||
staleness. The stored title can drift; that is the price of a store that never
|
||||
needs refreshing. The row opens the real thread, which is where current state
|
||||
lives.
|
||||
|
||||
Writes happen **after** the send promise resolves and are deliberately
|
||||
swallowed on failure: the message went out, and a missing bookkeeping entry
|
||||
must not surface as a send error.
|
||||
|
||||
The entry id comes from the thread URL rather than a separate owner/repo pair,
|
||||
because every attach flow has the URL and only some carry the repo separately.
|
||||
Issues and pull requests share one id shape, since they share a numbering space
|
||||
per repository.
|
||||
|
||||
## Loading data the header used to own
|
||||
|
||||
Two readouts had no loader of their own and appeared only after the user opened
|
||||
the matching header dropdown:
|
||||
|
||||
- **MCP** — `McpDropdown` was the only mount-time caller of `refresh()`.
|
||||
- **Usage** — `useQuotaAutoRefresh` merely schedules an interval; the *first*
|
||||
fetch was performed by the dropdown's open handler.
|
||||
- **Skills** — `loadSkills()` ran only when the composer's slash autocomplete
|
||||
opened, so the context-sources count was whatever happened to be cached. The
|
||||
section loads them itself, keyed on the directory, since skills are
|
||||
discovered relative to the active project. It does not wrap the call in
|
||||
`runBackgroundNetworkTask`: the store already gates its own fetch.
|
||||
|
||||
The panel now performs these itself, silently and through the
|
||||
background-network gate, so it cannot compete with chat bootstrap traffic for
|
||||
sockets. A panel that reports a subsystem's state cannot depend on an unrelated
|
||||
component having been mounted or opened.
|
||||
|
||||
## Persisted panel state
|
||||
|
||||
Expanded sections (`workStatusExpandedSections`, keyed by a stable section id)
|
||||
and the scroll offset (`workStatusScrollTop`) live in the persisted
|
||||
`useUIStore`. Component state would not do: the panel unmounts every time the
|
||||
context panel opens, which would silently discard the user's arrangement.
|
||||
|
||||
The scroll offset is restored in the scroller's callback ref, at the moment it
|
||||
attaches, and read through `useUIStore.getState()` rather than a subscription —
|
||||
subscribing would fight the user mid-scroll. Writes are coalesced to one per
|
||||
animation frame.
|
||||
|
||||
## Not implemented yet
|
||||
|
||||
- Test/build/dev-server status and LSP diagnostics — a separate track. Note
|
||||
that `state.lsp` already exists in the sync state.
|
||||
Reference in New Issue
Block a user