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:
Bohdan Triapitsyn
2026-08-09 19:30:25 +03:00
parent 493a618efc
commit f4743ea060
69 changed files with 5777 additions and 894 deletions
@@ -50,6 +50,8 @@ import { usePlanDetection } from '@/hooks/usePlanDetection';
import { useI18n } from '@/lib/i18n';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
import { isVSCodeRuntime } from '@/lib/desktop';
import { WorkStatusPanel } from './work-status/WorkStatusPanel';
import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility';
import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat';
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
@@ -694,6 +696,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
// composer enters the same fullscreen-input mode via its drag handle.
const isDesktopExpandedInput = isExpandedInput;
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
// Work-status panel: a borderless column to the right of the transcript.
// It yields to the context panel and to a narrow chat; `rowRef` goes on the
// row that holds both columns, so its width never depends on the panel's
// own visibility.
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
directory: effectiveSessionDirectory,
isMobile,
isVSCode,
});
// Session view only. The draft branch returns its own layout before this
// one, so the panel has no place there yet.
// Surfaces that never host the panel skip it entirely; the rest keep it
// mounted so its visibility can animate rather than snap.
const workStatusPanelMountable = !isMobile
&& !isVSCode
&& chatSurfaceMode !== 'mini-chat'
&& !isDesktopExpandedInput;
const showWorkStatusPanel = workStatusPanelMountable && workStatusVisible;
// Offered over the chat when there is no room beside it. The panel is still
// switched on; only the layout refuses it.
const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled);
const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen);
const setWorkStatusPanelFits = useUIStore((state) => state.setWorkStatusPanelFits);
// Mounted whenever it could be shown, not only while it is: an element
// that appears and disappears with the condition has nothing to animate.
const workStatusOverlayMountable = workStatusPanelMountable
&& workStatusPanelEnabled
&& !workStatusFits;
const showWorkStatusOverlay = workStatusOverlayMountable && workStatusOverlayOpen;
React.useEffect(() => {
setWorkStatusPanelFits(workStatusPanelMountable && workStatusFits);
return () => setWorkStatusPanelFits(false);
}, [setWorkStatusPanelFits, workStatusFits, workStatusPanelMountable]);
// Published so the header can drop the readouts the panel already carries.
// Cleared on unmount: a chat that goes away is not showing anything.
const setWorkStatusPanelVisible = useUIStore((state) => state.setWorkStatusPanelVisible);
React.useEffect(() => {
setWorkStatusPanelVisible(showWorkStatusPanel);
return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -1152,7 +1197,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
}
return (
<div data-composer-bound className="relative flex flex-col h-full bg-background">
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
@@ -1205,6 +1251,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
</div>
{/* Inside the chat column, not beside it: as a row sibling it took
part in the flex layout and pushed the transcript, which is the
one thing an overlay must not do. */}
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null}
/>
) : null}
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
@@ -1216,5 +1274,16 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
onLoadEarlier={handleLoadOlderClick}
/>
</div>
{/* Kept mounted while it could ever show, so it can animate its own
collapse; `visible` drives that. Unmounting on the spot is what made
the chat jump wide before easing narrow again. */}
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null}
/>
) : null}
</div>
);
};
+48 -2
View File
@@ -16,6 +16,7 @@ import {
} from '@/sync/attachment-files';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions';
import { buildLinkedIssue } from '@/lib/linkedIssues';
import { useUserMessageHistory } from "@/sync/sync-context";
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
@@ -1227,6 +1228,45 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
void sendPromise.then(() => {
// Record what this session was pointed at, so the work-status panel
// can show it as a context source long after the message scrolled
// away. A snapshot only — never re-fetched, never authoritative.
// Failures are swallowed: the message went out, and a missing
// bookkeeping entry must not surface as a send error.
const attachedThread = linkedIssue
? { attachment: linkedIssue, kind: 'issue' as const }
: linkedPr
? { attachment: linkedPr, kind: 'pull' as const }
: null;
// On a draft there is no session yet in this closure: the send path
// creates one and makes it current before resolving, so the id is
// read from the store. The fallback is used only when the closure
// had no session at all, so a mid-send session switch cannot
// redirect the write to an unrelated session.
const sessionState = useSessionUIStore.getState();
const linkTargetSessionId = currentSessionId ?? sessionState.currentSessionId;
const linkTargetDirectory = currentSessionId
? currentSessionDirectoryForSync ?? currentDirectory
: sessionState.currentSessionDirectory
?? (linkTargetSessionId ? sessionState.getDirectoryForSession(linkTargetSessionId) : null)
?? currentDirectory;
if (attachedThread && linkTargetSessionId) {
void sessionActions.setLinkedIssue(
linkTargetSessionId,
linkTargetDirectory,
buildLinkedIssue({
url: attachedThread.attachment.url,
number: attachedThread.attachment.number,
title: attachedThread.attachment.title,
kind: attachedThread.kind,
author: attachedThread.attachment.author,
linkedAt: Date.now(),
}),
true,
).catch(() => undefined);
}
// Clear linked issue after successful message send
if (linkedIssue) {
setLinkedIssue(null);
@@ -2221,6 +2261,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const footerGapClass = 'gap-x-1.5 gap-y-0';
const isVSCode = isVSCodeRuntime();
// The work-status panel carries the agent's todos and the changed-file
// count, but only on the desktop/web layout — VS Code and mobile have no
// panel, so these keep their place above the composer there.
const composerStatusExtrasEnabled = isVSCode || isMobile;
const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode;
// Which project and directory a new session will target.
@@ -2485,8 +2529,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<MemoStatusRow
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos
leftAccessory={newSessionDraftOpen || !hasPendingChanges ? null : <PendingChangesBar />}
showTodos={composerStatusExtrasEnabled}
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
? null
: <PendingChangesBar />}
/>
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<DraftTargetSelectors
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
@@ -50,12 +51,13 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
const isEngaged = armed || Boolean(liveGoal);
const colorClass = (() => {
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
return '';
})();
// One mapping for every goal surface. This button used to carry its own,
// which painted `paused` the same info colour as `active` — so a paused goal
// was indistinguishable from a running one — and `blocked` as an error rather
// than a warning. `armed` is not a goal status, so it keeps its own case.
const iconColor = goal
? sessionGoalStatusColor[goal.status]
: (armed ? 'var(--status-info)' : undefined);
const label = goal
? t('chat.goal.button.manageAria')
@@ -74,7 +76,8 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
const button = (
<button
type="button"
className={cn(footerIconButtonClass, colorClass)}
className={footerIconButtonClass}
style={iconColor ? { color: iconColor } : undefined}
onClick={handleClick}
// Same guard as PermissionAutoAcceptButton, but only for the ARM
// toggle: arming happens mid-typing (the next message IS the
@@ -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.
@@ -0,0 +1,130 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { Icon } from '@/components/icon/Icon';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useSession } from '@/sync/sync-context';
import { getLinkedIssues } from '@/lib/linkedIssues';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
type Props = {
sessionId: string | null;
directory: string | null;
};
/**
* What is loaded into the agent's context: the GitHub threads this session was
* pointed at, plus how much ambient material is available.
*
* Agents are deliberately absent — an agent is who does the work, not material
* the work is done with. Tools are absent for want of an honest source:
* `Agent.tools` is a per-agent override map, not a registry, so its size would
* report something other than "tools available".
*/
export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const skills = useSkillsStore((state) => state.skills);
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
);
// Skills were previously fetched only when the composer's slash autocomplete
// opened, so this row reported whatever count happened to be cached — often
// none — until the user typed "/". The panel states a count, so it is the
// panel's business to have one. Re-run per directory because skills are
// discovered relative to the active project. No background-network wrap
// here: `loadSkills` already gates its own fetch, and wrapping it again
// would hold a second slot idle for the length of the first.
const loadSkills = useSkillsStore((state) => state.loadSkills);
React.useEffect(() => {
void loadSkills();
}, [directory, loadSkills]);
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
// Connected servers only. A disabled server contributes nothing to the
// context, so counting it here contradicts the MCP section right above,
// which shows the same servers switched off.
const mcpCount = React.useMemo(
() => Object.values(mcpStatus ?? {}).filter((entry) => entry?.status === 'connected').length,
[mcpStatus],
);
useReportWorkStatusPresence('context-sources', linked.length > 0 || skills.length > 0 || mcpCount > 0);
if (linked.length === 0 && skills.length === 0 && mcpCount === 0) return null;
// The heading names what is distinctive about this session when there is
// something — an attached thread — and falls back to the ambient counts
// when there is not. `1 · 33 · 2` said nothing without opening the section.
const issueCount = linked.filter((entry) => entry.kind === 'issue').length;
const prCount = linked.length - issueCount;
const summaryParts: string[] = [];
if (issueCount > 0) {
summaryParts.push(issueCount === 1
? t('chat.workStatus.breakdown.issueCountSingle', { count: issueCount })
: t('chat.workStatus.breakdown.issueCountPlural', { count: issueCount }));
}
if (prCount > 0) {
summaryParts.push(prCount === 1
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
}
if (summaryParts.length === 0) {
if (skills.length > 0) {
summaryParts.push(skills.length === 1
? t('chat.workStatus.breakdown.skillCountSingle', { count: skills.length })
: t('chat.workStatus.breakdown.skillCountPlural', { count: skills.length }));
}
if (mcpCount > 0) {
summaryParts.push(mcpCount === 1
? t('chat.workStatus.breakdown.mcpCountSingle', { count: mcpCount })
: t('chat.workStatus.breakdown.mcpCountPlural', { count: mcpCount }));
}
}
return (
<WorkStatusCollapsibleSection
id="context-sources"
title={t('chat.workStatus.section.contextBreakdown')}
icon="stack"
summary={summaryParts.join(' · ')}
>
{/* Attached threads first: they are specific to this session, while the
counts below describe the workspace. */}
{linked.map((entry) => (
<WorkStatusRow
key={entry.id}
leading={entry.authorAvatarUrl ? (
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
) : (
<Icon
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
className="size-4 shrink-0 text-muted-foreground"
/>
)}
label={entry.title}
muted
// The stored snapshot is enough to render; the live thread only ever
// exists on github.com.
onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')}
ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
value={<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>}
/>
))}
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.skills')}
value={<WorkStatusValue>{skills.length}</WorkStatusValue>}
/>
<WorkStatusRow
muted
label={t('chat.workStatus.breakdown.mcp')}
value={<WorkStatusValue>{mcpCount}</WorkStatusValue>}
/>
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,76 @@
import React from 'react';
import { toast } from 'sonner';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useSessionGoal } from '@/hooks/useSessionGoal';
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
import { sessionGoalStatusColor } from '@/lib/sessionGoalPresentation';
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
import { WorkStatusRow, WorkStatusRowAction } from './WorkStatusPrimitives';
type Props = {
sessionId: string | null;
directory: string | null;
};
/** The session goal, on the mapping every other goal surface uses. */
export const WorkStatusGoalRow: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory ?? undefined);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [busy, setBusy] = React.useState(false);
const handleToggleStatus = React.useCallback(async (nextStatus: 'active' | 'paused') => {
if (!sessionId || busy) return;
setBusy(true);
try {
await setSessionGoalStatus(sessionId, directory ?? undefined, nextStatus);
} catch {
toast.error(t('chat.workStatus.goal.updateFailed'));
} finally {
setBusy(false);
}
}, [busy, directory, sessionId, t]);
const objective = enabled && goal ? goal.objective?.trim() || null : null;
if (!objective || !sessionId) return null;
// No control while complete: there is nothing left to pause or resume.
const canPause = goal?.status === 'active';
const canResume = goal?.status === 'paused'
|| goal?.status === 'blocked'
|| goal?.status === 'budgetLimited';
return (
<>
<WorkStatusRow
leading={(
<Icon
name={goal?.status ? 'target-fill' : 'target'}
className="size-4 shrink-0"
style={{ color: goal ? sessionGoalStatusColor[goal.status] : undefined }}
/>
)}
label={objective}
onClick={() => setDialogOpen(true)}
ariaLabel={t('chat.workStatus.goal.open')}
value={canPause || canResume ? (
<WorkStatusRowAction
tone={canPause ? 'info' : 'warning'}
disabled={busy}
ariaLabel={canPause ? t('chat.workStatus.goal.pause') : t('chat.workStatus.goal.resume')}
onClick={() => { void handleToggleStatus(canPause ? 'paused' : 'active'); }}
>
{canPause ? t('chat.workStatus.goal.pause') : t('chat.workStatus.goal.resume')}
</WorkStatusRowAction>
) : undefined}
/>
<SessionGoalDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
sessionId={sessionId}
directory={directory ?? undefined}
/>
</>
);
};
@@ -0,0 +1,142 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { Switch } from '@/components/ui/switch';
import { useMcpStore } from '@/stores/useMcpStore';
import { McpIcon } from '@/components/icons/McpIcon';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { toast } from 'sonner';
import { isVSCodeRuntime } from '@/lib/desktop';
import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusRowAction } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
type Props = {
directory: string | null;
};
/**
* MCP servers with their connection switches, reusing the dropdown's own
* connect/disconnect actions.
*/
export const WorkStatusMcpSection: React.FC<Props> = ({ directory }) => {
const { t } = useI18n();
const mcpStatus = useMcpStore(
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
);
const refreshMcp = useMcpStore((state) => state.refresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const [busyServer, setBusyServer] = React.useState<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or
// opened to know its MCP servers. Silent and background-gated, so it cannot
// compete with chat bootstrap traffic for sockets.
React.useEffect(() => {
void runBackgroundNetworkTask(() => refreshMcp({ directory, silent: true }));
}, [directory, refreshMcp]);
const mcpServers = React.useMemo(
() => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)),
[mcpStatus],
);
const mcpConnected = React.useMemo(
() => mcpServers.filter(([, entry]) => entry?.status === 'connected').length,
[mcpServers],
);
// A server waiting on authorization cannot be reconnected into working
// order: `connect` just repeats the attempt that produced `needs_auth`.
// Authorising sends the user to the provider instead.
const handleAuthorize = React.useCallback(async (name: string) => {
setBusyServer(name);
try {
const { opened } = await startMcpAuthorization({
name,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('chat.workStatus.mcp.authorizeOpenFailed'));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t('chat.workStatus.mcp.authorizeFailed'));
} finally {
setBusyServer((current) => (current === name ? null : current));
}
}, [directory, t]);
const handleToggle = React.useCallback(async (name: string, next: boolean) => {
// Switching on a server that is waiting for sign-in cannot connect: it only
// repeats the attempt that produced `needs_auth`. Authorization is the real
// action, and the dropdown already routes the same switch that way — the
// two surfaces must not disagree about what this control does.
const status = (mcpStatus ?? {})[name]?.status;
if (next && (status === 'needs_auth' || status === 'needs_client_registration')) {
await handleAuthorize(name);
return;
}
setBusyServer(name);
try {
if (next) await connect(name, directory);
else await disconnect(name, directory);
} finally {
setBusyServer((current) => (current === name ? null : current));
}
}, [connect, disconnect, directory, handleAuthorize, mcpStatus]);
useReportWorkStatusPresence('mcp', mcpServers.length > 0);
if (mcpServers.length === 0) return null;
return (
<WorkStatusCollapsibleSection
id="mcp"
title={t('chat.workStatus.section.mcp')}
iconNode={<McpIcon className="size-4 shrink-0 text-muted-foreground" />}
summary={`${mcpConnected}/${mcpServers.length}`}
>
{mcpServers.map(([name, entry]) => {
const connected = entry?.status === 'connected';
const needsAuth = entry?.status === 'needs_auth' || entry?.status === 'needs_client_registration';
const failed = entry?.status === 'failed';
return (
<WorkStatusRow
key={name}
leading={(
<Switch
checked={connected}
disabled={busyServer === name}
className="scale-75 data-[checked]:bg-status-info"
aria-label={t('chat.workStatus.mcp.toggle', { name })}
onCheckedChange={(checked) => { void handleToggle(name, checked); }}
/>
)}
label={name}
muted={!connected}
// A server asking for sign-in or reporting a failure is asking to be
// acted on; the state is the affordance, so it is the button.
value={needsAuth ? (
<WorkStatusRowAction
tone="warning"
disabled={busyServer === name}
onClick={() => { void handleAuthorize(name); }}
>
{t('chat.workStatus.mcp.needsAuth')}
</WorkStatusRowAction>
) : failed ? (
<WorkStatusRowAction
tone="error"
disabled={busyServer === name}
onClick={() => { void handleToggle(name, true); }}
>
{t('chat.workStatus.mcp.failed')}
</WorkStatusRowAction>
) : undefined}
/>
);
})}
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,251 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useUIStore } from '@/stores/useUIStore';
import { WORK_STATUS_PANEL_WIDTH } from './useWorkStatusVisibility';
import { WorkStatusGoalRow } from './WorkStatusGoalRow';
import { WorkStatusPrimaryGroup } from './WorkStatusPrimaryGroup';
import { WorkStatusUsageSection } from './WorkStatusUsageSection';
import { WorkStatusSubagentsSection } from './WorkStatusSubagentsSection';
import { WorkStatusTasksSection } from './WorkStatusTasksSection';
import { WorkStatusMcpSection } from './WorkStatusMcpSection';
import { WorkStatusPinnedSection } from './WorkStatusPinnedSection';
import { WorkStatusContextSection } from './WorkStatusContextSection';
import { WorkStatusSectionsDialog } from './WorkStatusSectionsDialog';
import { isWorkStatusSectionVisible } from './sections';
import { WorkStatusPresenceProvider } from './presence';
import { Icon } from '@/components/icon/Icon';
type Props = {
/** Null on a new-session draft: repository readouts still apply. */
sessionId: string | null;
directory: string | null;
/** Whether the panel should currently occupy space. */
visible: boolean;
/**
* Floats over the transcript instead of sitting beside it, for when the chat
* is too narrow to give it a column of its own.
*/
overlay?: boolean;
};
/**
* Matches the context panel's own width animation exactly.
*
* The two are siblings of the transcript, and opening the context panel hides
* this one. With an instant unmount the chat first jumped wider (this panel
* gone) and then eased narrower (the context panel expanding) — two opposite
* width changes in a row, which reads as a flutter. Collapsing on the same
* curve and duration makes the chat's width move once, in one direction.
*/
const PANEL_TRANSITION_MS = 200;
const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
/**
* Work-status panel: a card inside the chat column reporting the state of the
* session, its branch and its subagents.
*
* Ordering is by durability, not by category. The first sections hold readouts
* that stay true for the whole session, then the state of the work in flight,
* then episodic material an agent may never produce. Each section renders
* nothing when it has nothing, so the panel collapses toward the top instead of
* reserving empty space.
*
* The card clips; the scroller lives inside it, so the same top/bottom scroll
* shadows the transcript uses stay within the rounded border instead of
* bleeding past it. The scrollbar itself is hidden — at this width it would
* eat a visible slice of every row's trailing value, and the shadows already
* say there is more to see.
*/
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, overlay = false }) => {
const { t } = useI18n();
const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop);
const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen);
const hiddenSections = useUIStore((state) => state.workStatusHiddenSections);
const [sectionsDialogOpen, setSectionsDialogOpen] = React.useState(false);
// Starts optimistic: sections report after their first commit, and rendering
// nothing on the way in would make the card flash out and back on arrival.
const [renderedSections, setRenderedSections] = React.useState(1);
const sectionVisible = React.useCallback(
(sectionId: Parameters<typeof isWorkStatusSectionVisible>[1]) =>
isWorkStatusSectionVisible(hiddenSections, sectionId),
[hiddenSections],
);
const frameRef = React.useRef<number | null>(null);
// Restoring the offset has to happen the moment the scroller attaches, and
// the panel unmounts whenever the context panel opens. Reading the stored
// value through a ref keeps this a mount-time restore rather than a
// subscription that would fight the user mid-scroll.
// Content is dropped only after the collapse finishes, so the card animates
// out with something in it rather than emptying first, and its subscriptions
// stop once it is truly gone.
const [contentMounted, setContentMounted] = React.useState(visible);
// Hidden, mid-collapse, or reporting nothing: in each case the card is not
// something the user can act on, so it should not be reachable.
const interactive = visible && renderedSections > 0;
React.useEffect(() => {
if (visible) {
setContentMounted(true);
return undefined;
}
const timer = window.setTimeout(() => setContentMounted(false), PANEL_TRANSITION_MS);
return () => window.clearTimeout(timer);
}, [visible]);
const restore = React.useCallback((node: HTMLElement | null) => {
if (!node) return;
const stored = useUIStore.getState().workStatusScrollTop;
if (stored > 0) node.scrollTop = stored;
}, []);
// Coalesced to one write per frame: scroll fires far faster than the store
// needs to hear about it.
const handleScroll = React.useCallback((event: React.UIEvent<HTMLElement>) => {
const { scrollTop } = event.currentTarget;
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
setScrollTop(scrollTop);
});
}, [setScrollTop]);
React.useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
}, []);
// The offset belongs to the panel a session produced, not to the panel in
// general: restoring one session's scroll into another's shorter panel lands
// somewhere arbitrary.
React.useEffect(() => {
setScrollTop(0);
}, [sessionId, setScrollTop]);
// Dismissed like any transient surface: a click elsewhere or Escape. It
// covers the transcript, so leaving it up would block the thing it reports on.
const overlayRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
// Only while it is actually up: a hidden overlay listening for clicks would
// swallow the very press that opens it.
if (!overlay || !visible) return undefined;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
if (overlayRef.current?.contains(target)) return;
// The header toggle closes it on its own; letting this fire too would
// close and immediately reopen.
if (target?.closest('[data-work-status-toggle]')) return;
setOverlayOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOverlayOpen(false);
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
};
}, [overlay, setOverlayOpen, visible]);
return (
<aside
ref={overlayRef}
aria-label={t('chat.workStatus.ariaLabel')}
aria-hidden={!interactive}
// The card stays mounted while hidden so it can animate its own collapse,
// and the sections button sits outside the content gate. Without `inert`
// Tab could land on an invisible control — and `aria-hidden` around a
// focusable descendant is an accessibility fault in its own right.
inert={!interactive}
className={cn(
// `self-start` keeps the card at content height instead of stretching
// to the row; `max-h` then caps it so a long panel scrolls rather than
// overflowing the chat.
// A left margin as well as a right one: flush against the transcript
// the card's own shadow had no room and was clipped down that edge.
'relative my-4 flex shrink-0 flex-col self-start overflow-hidden',
'max-h-[calc(100%-2rem)]',
interactive ? 'ml-2 mr-4' : 'ml-0 mr-0',
// Out of the flow entirely, anchored to the chat column's top-right so
// it reads as a dropdown from the header button. As a flex child it
// took part in the layout and pushed the transcript, which is the one
// thing an overlay must not do. Stronger shadow: it sits on content now.
overlay && [
'absolute right-3 top-3 z-30 mx-0 my-0',
'max-h-[calc(100%-1.5rem)]',
'shadow-[0_8px_28px_-8px_rgb(0_0_0_/_0.28)]',
// Beside the transcript the translucent fill reads as depth; on top
// of it, message bubbles showed straight through the rows. Frosting
// separates the two without going fully opaque.
'bg-[var(--surface-muted)]/80 backdrop-blur-md',
],
// An empty card is a border around a settings icon, which reads as a
// fault rather than as "nothing to report".
renderedSections === 0 && 'border-transparent bg-transparent shadow-none',
'motion-reduce:transition-none',
'rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-muted)]/40',
// A lighter version of the composer's lift: the same shape, but this
// card is taller, so the composer's spread reads as heavy here.
'shadow-[0_2px_8px_-3px_rgb(0_0_0_/_0.08)]',
)}
style={{
// The overlay keeps its width: it takes no space from the chat, so
// collapsing it would animate a dimension nothing depends on. It fades
// and lifts instead, like the dropdown it reads as.
width: overlay || interactive ? WORK_STATUS_PANEL_WIDTH : 0,
opacity: interactive ? 1 : 0,
transform: visible
? 'translateY(0) scale(1)'
: overlay
? 'translateY(-6px) scale(0.98)'
// Inline: leaves to the right and arrives from it, so the card
// reads as sliding out past the window edge.
: `translateX(${WORK_STATUS_PANEL_WIDTH / 4}px)`,
transformOrigin: 'top right',
transitionProperty: 'width, opacity, transform, margin',
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
transitionTimingFunction: PANEL_TRANSITION_EASING,
pointerEvents: interactive ? undefined : 'none',
}}
>
{/* Overlaid rather than placed in flow: the panel has no header of its
own, and giving it one would cost a row of height on every session. */}
<button
type="button"
aria-label={t('chat.workStatus.sections.open')}
onClick={() => setSectionsDialogOpen(true)}
className="absolute right-2 top-1.5 z-10 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
>
<Icon name="equalizer-2" className="size-4" />
</button>
{contentMounted ? (
<WorkStatusPresenceProvider onChange={setRenderedSections}>
<ScrollShadow
ref={restore}
onScroll={handleScroll}
size={24}
className="oc-hide-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-2"
>
<WorkStatusPrimaryGroup
sessionId={sessionId}
directory={directory}
showSession={sectionVisible('session')}
showRepository={sectionVisible('repository')}
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
/>
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
{sectionVisible('subagents') ? <WorkStatusSubagentsSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('tasks') ? <WorkStatusTasksSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('mcp') ? <WorkStatusMcpSection directory={directory} /> : null}
{sectionVisible('pinned') ? <WorkStatusPinnedSection sessionId={sessionId} directory={directory} /> : null}
{sectionVisible('contextSources') ? <WorkStatusContextSection sessionId={sessionId} directory={directory} /> : null}
</ScrollShadow>
</WorkStatusPresenceProvider>
) : null}
<WorkStatusSectionsDialog open={sectionsDialogOpen} onOpenChange={setSectionsDialogOpen} />
</aside>
);
};
@@ -0,0 +1,111 @@
import React from 'react';
import { toast } from 'sonner';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { useDirectorySync, useEnsureSessionMessages, useSession } from '@/sync/sync-context';
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
import { setContextObligatoryMessage } from '@/sync/session-actions';
import { WorkStatusRow, WorkStatusSection } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import type { State } from '@/sync/types';
type Props = {
sessionId: string | null;
directory: string | null;
};
/**
* Messages pinned into the context.
*
* The row carries two destinations, so the pin is its own button: pressing the
* pin unpins, pressing the text takes you to the message.
*/
export const WorkStatusPinnedSection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const parts = useDirectorySync(React.useCallback((state: State) => state.part, []));
const [busyId, setBusyId] = React.useState<string | null>(null);
const pinned = React.useMemo(() => {
const entries = getContextObligatoryMessages(session);
if (entries.length === 0) return [];
return entries.map((entry) => {
const messageParts = parts[entry.id] ?? [];
const text = messageParts.find(
(part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text',
)?.text?.trim();
return { id: entry.id, text: text || null };
});
}, [session, parts]);
// Pinned messages 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 instead of its text. Materialise the session,
// but only when a pin actually resolves to nothing: having pins is not a
// reason to fetch, and neither is something being unloaded in general.
const hasUnresolvedPin = pinned.length > 0 && pinned.some((entry) => entry.text === null);
useEnsureSessionMessages(sessionId ?? '', directory ?? undefined, hasUnresolvedPin);
const handleUnpin = React.useCallback(async (messageId: string) => {
if (!sessionId || busyId) return;
setBusyId(messageId);
try {
// Only the id matters when unpinning — `withContextObligatoryMessage`
// filters by it and discards the rest of the payload.
await setContextObligatoryMessage(
sessionId,
directory,
{ id: messageId, createdAt: 0, role: 'user' },
false,
);
} catch {
toast.error(t('chat.workStatus.pinned.unpinFailed'));
} finally {
setBusyId((current) => (current === messageId ? null : current));
}
}, [busyId, directory, sessionId, t]);
// The transcript listens for `#message-<id>` and scrolls there; it is the
// only cross-component jump the chat exposes. An unchanged hash fires no
// event, so clear it first to make a repeat press work.
const handleReveal = React.useCallback((messageId: string) => {
if (typeof window === 'undefined') return;
const target = `#message-${messageId}`;
if (window.location.hash === target) {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
window.location.hash = target;
}, []);
useReportWorkStatusPresence('pinned', pinned.length > 0);
if (pinned.length === 0) return null;
return (
<WorkStatusSection title={t('chat.workStatus.section.pinned')}>
{pinned.map((entry) => (
<WorkStatusRow
key={entry.id}
leading={(
<button
type="button"
disabled={busyId === entry.id}
aria-label={t('chat.workStatus.pinned.unpin')}
onClick={(event) => {
event.stopPropagation();
void handleUnpin(entry.id);
}}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40"
>
<Icon name="pushpin-2-fill" className="size-3.5" style={{ color: 'var(--primary)' }} />
</button>
)}
muted
label={entry.text ?? t('chat.workStatus.pinned.unavailable')}
onClick={() => handleReveal(entry.id)}
ariaLabel={t('chat.workStatus.pinned.reveal')}
/>
))}
</WorkStatusSection>
);
};
@@ -0,0 +1,323 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { normalizeProjectPath } from '@/lib/projectResolution';
import { resolveUsageTone } from '@/lib/quota';
import { computeContextUsage } from './contextUsage';
import {
WorkStatusCallout,
WorkStatusMeter,
WorkStatusPill,
WorkStatusRow,
WorkStatusSection,
WorkStatusValue,
} from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
type Props = {
sessionId: string | null;
directory: string | null;
/** Rendered first inside the Session section; owns its own dialog. */
goalRow: React.ReactNode;
showSession: boolean;
showRepository: boolean;
};
// Spend is read against a budget, so it keeps its real precision instead of
// collapsing to two decimals. Trailing zeros are dropped so exact values stay
// short.
const trimZeros = (value: string): string => (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value);
const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`;
// Matches the header readout exactly: one decimal, capped the same way, so the
// two places that report context fill never disagree by a rounding step.
const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`;
/**
* The persistent readouts — how full the context is, what the working tree and
* the pull request look like. All of it stays true for as long as the session
* is open, so it sits above anything episodic.
*/
export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory, goalRow, showSession, showRepository }) => {
const { t } = useI18n();
const session = useSession(sessionId ?? '', directory ?? undefined);
const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus);
const gitStatus = useGitStore(
React.useCallback(
(state) => (directory ? state.directories.get(directory)?.status ?? null : null),
[directory],
),
);
// Warm the shared git cache through the background-network gate so the panel
// never competes with the chat's own bootstrap traffic for sockets.
React.useEffect(() => {
if (!directory || !git) return;
void runBackgroundNetworkTask(() => ensureStatus(directory, git));
}, [directory, git, ensureStatus]);
const branch = gitStatus?.current?.trim() || null;
// The panel's directory can be a worktree, so the project is the registered
// one whose path contains it — longest match wins, since projects can nest.
const projectLabel = useProjectsStore(
React.useCallback((state) => {
const normalizedDirectory = normalizeProjectPath(directory ?? null);
if (!normalizedDirectory) return null;
let best: { path: string; label: string } | null = null;
for (const project of state.projects) {
const projectPath = normalizeProjectPath(project.path);
if (!projectPath) continue;
const contains = normalizedDirectory === projectPath
|| normalizedDirectory.startsWith(`${projectPath}/`);
if (!contains) continue;
if (best && best.path.length >= projectPath.length) continue;
const label = project.label?.trim()
|| projectPath.split('/').filter(Boolean).pop()
|| projectPath;
best = { path: projectPath, label };
}
return best?.label ?? null;
}, [directory]),
);
// Read-only: PR watching is owned by the background tracker. Starting a watch
// here would multiply GitHub requests per open session, which is exactly the
// fan-out the PR-status concurrency gate exists to prevent.
const prKey = React.useMemo(
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
[directory, branch],
);
const prSummary = usePrVisualSummary(prKey);
// `getCurrentModel` is an imperative getter: its reference never changes, so
// calling it in render subscribes to nothing. Subscribe to the selected model
// ids and recompute the limits from those.
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const sessionMessages = useSessionMessages(sessionId ?? '', directory ?? undefined);
const contextLimit = React.useMemo(() => {
const currentModel = getCurrentModel();
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
? (currentModel.limit as Record<string, unknown>)
: null;
return limit && typeof limit.context === 'number' ? limit.context : 0;
// eslint-disable-next-line react-hooks/exhaustive-deps -- getter output tracks the selected model ids
}, [getCurrentModel, currentProviderId, currentModelId]);
// Computed from this session's own messages rather than through
// `useSessionUIStore.getContextUsage`, which reads the *current* directory's
// store and so loses the readout for any session held elsewhere. See
// `contextUsage.ts`.
const contextUsage = React.useMemo(
() => computeContextUsage(sessionMessages, contextLimit),
[sessionMessages, contextLimit],
);
const openContextSurface = useUIStore((state) => state.openContextSurface);
const openContextOverview = useUIStore((state) => state.openContextOverview);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const openSurface = React.useCallback(
(mode: 'git' | 'pr') => { if (directory) openContextSurface(directory, mode); },
[directory, openContextSurface],
);
// Working-tree diff without a target path: the panel opens on the whole
// change set rather than picking a file on the user's behalf.
// Same destination as the header's context readout.
const openContext = React.useCallback(() => {
if (directory) openContextOverview(directory);
}, [directory, openContextOverview]);
const openChanges = React.useCallback(() => {
if (directory) openContextPanelTab(directory, { mode: 'diff', diffScope: 'working' });
}, [directory, openContextPanelTab]);
// Working-tree changes, from the same git status the Git panel reads.
//
// `Session.summary` looks like the natural source and is not: OpenCode resets
// it to zeros at the start of every turn and only ever fills per-message
// `summary.diffs`, so session-level totals are always 0/0/0. The `session.diff`
// event is reset to an empty array too, and carries real content only on
// revert. Git status is the one authoritative, already-cached answer.
const changed = React.useMemo(() => {
const files = gitStatus?.files ?? [];
if (files.length === 0) return null;
const stats = gitStatus?.diffStats;
let additions = 0;
let deletions = 0;
if (stats) {
for (const entry of Object.values(stats)) {
additions += entry?.insertions ?? 0;
deletions += entry?.deletions ?? 0;
}
}
return { files: files.length, additions, deletions, hasStats: Boolean(stats) };
}, [gitStatus?.files, gitStatus?.diffStats]);
const attentionReason = gitStatus?.attentionReason
?? (gitStatus?.rebaseInProgress ? 'rebase' : null)
?? (gitStatus?.mergeInProgress ? 'merge' : null);
const attentionLabel = attentionReason === 'merge' ? t('chat.workStatus.attention.merge')
: attentionReason === 'rebase' ? t('chat.workStatus.attention.rebase')
: attentionReason === 'cherry-pick' ? t('chat.workStatus.attention.cherryPick')
: attentionReason === 'revert' ? t('chat.workStatus.attention.revert')
: attentionReason === 'bisect' ? t('chat.workStatus.attention.bisect')
: null;
const usagePercent = contextUsage?.percent ?? null;
// Colour threshold uses the rounded percentage, matching what the header
// feeds `resolveUsageTone`; the displayed number stays unrounded.
const usageTone = usagePercent === null ? null : resolveUsageTone(Math.round(usagePercent));
// Same tone ramp as the header's context icon — healthy is success, not
// primary, so a full bar reads as a warning rather than as brand colour.
const meterColor = usageTone === 'critical' ? 'var(--status-error)'
: usageTone === 'warn' ? 'var(--status-warning)'
: 'var(--status-success)';
const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel);
useReportWorkStatusPresence('session-repository', hasSession || hasRepository);
if (!hasSession && !hasRepository) return null;
return (
<>
{hasSession ? (
<WorkStatusSection title={t('chat.workStatus.section.session')}>
{usagePercent !== null ? (
<>
<WorkStatusRow
icon="donut-chart"
onClick={directory ? openContext : undefined}
ariaLabel={t('chat.workStatus.action.openContext')}
label={t('chat.workStatus.context.label')}
value={(
<>
<WorkStatusValue>{formatPercent(usagePercent)}</WorkStatusValue>
{/* No icon of its own: the sprite has no currency glyph, and
spend belongs with consumption anyway. The `$` labels it. */}
{cost !== null ? <WorkStatusValue tone="muted">{formatCost(cost)}</WorkStatusValue> : null}
</>
)}
/>
<WorkStatusMeter percent={usagePercent} color={meterColor} />
</>
) : null}
{/* Below the context readout: the goal is a standing instruction,
while context is the live number the reader came for. */}
{goalRow}
</WorkStatusSection>
) : null}
{hasRepository ? (
<WorkStatusSection
title={t('chat.workStatus.section.repository')}
summary={projectLabel}
>
{attentionLabel ? <WorkStatusCallout>{attentionLabel}</WorkStatusCallout> : null}
{/* Branch first: the changes below are the changes *on it*, and the
row reads as a caption to the branch rather than a loose number. */}
{branch ? (
<WorkStatusRow
icon="git-branch"
onClick={directory ? () => openSurface('git') : undefined}
ariaLabel={t('chat.workStatus.action.openGit')}
label={branch}
value={(gitStatus?.ahead ?? 0) > 0 || (gitStatus?.behind ?? 0) > 0 ? (
<>
{(gitStatus?.ahead ?? 0) > 0
? <WorkStatusValue tone="muted">{`${gitStatus?.ahead}`}</WorkStatusValue> : null}
{(gitStatus?.behind ?? 0) > 0
? <WorkStatusValue tone="muted">{`${gitStatus?.behind}`}</WorkStatusValue> : null}
</>
) : undefined}
/>
) : null}
{changed ? (
<WorkStatusRow
icon="file-edit"
onClick={directory ? openChanges : undefined}
ariaLabel={t('chat.workStatus.action.openChanges')}
// The count names the row, matching the composer's changed-files
// bar; the diffstat stays the trailing value.
label={changed.files === 1
? t('chat.workStatus.git.changedFileSingle', { count: changed.files })
: t('chat.workStatus.git.changedFilePlural', { count: changed.files })}
value={changed.hasStats && (changed.additions > 0 || changed.deletions > 0) ? (
<>
<WorkStatusValue tone="success">{`+${changed.additions}`}</WorkStatusValue>
{/* Neutral separator: colouring it would imply it carries a
status of its own. */}
<WorkStatusValue tone="muted">/</WorkStatusValue>
<WorkStatusValue tone="error">{`${changed.deletions}`}</WorkStatusValue>
</>
) : undefined}
/>
) : null}
{prSummary ? (
<>
<WorkStatusRow
icon="git-pull-request"
onClick={directory ? () => openSurface('pr') : undefined}
ariaLabel={t('chat.workStatus.action.openPr')}
iconColor={`var(--pr-${prSummary.visualState})`}
label={prSummary.title ?? t('chat.workStatus.pr.untitled')}
value={(
<WorkStatusPill
color={`var(--pr-${prSummary.visualState})`}
background={`color-mix(in srgb, var(--pr-${prSummary.visualState}) 18%, transparent)`}
>
{prSummary.draft ? t('chat.workStatus.pr.draft') : `#${prSummary.number}`}
</WorkStatusPill>
)}
/>
{prSummary.checks && prSummary.checks.total > 0 ? (
<WorkStatusRow
icon="checkbox-circle"
onClick={directory ? () => openSurface('pr') : undefined}
ariaLabel={t('chat.workStatus.action.openPr')}
label={t('chat.workStatus.pr.checks')}
muted
value={(
<>
{prSummary.checks.failure > 0 ? (
<WorkStatusValue tone="error">
{t('chat.workStatus.pr.checksFailed', { count: prSummary.checks.failure })}
</WorkStatusValue>
) : null}
{prSummary.checks.pending > 0 ? (
<WorkStatusValue tone="warning">
{t('chat.workStatus.pr.checksPending', { count: prSummary.checks.pending })}
</WorkStatusValue>
) : null}
{prSummary.checks.failure === 0 && prSummary.checks.pending === 0 ? (
<WorkStatusValue tone="success">
{t('chat.workStatus.pr.checksPassed', { count: prSummary.checks.success })}
</WorkStatusValue>
) : null}
</>
)}
/>
) : null}
</>
) : null}
</WorkStatusSection>
) : null}
</>
);
};
@@ -0,0 +1,266 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { Icon } from '@/components/icon/Icon';
import { useUIStore } from '@/stores/useUIStore';
import type { IconName } from '@/components/icon/icons';
/**
* Row/section vocabulary for the work-status panel.
*
* Every readout is a labelled row — icon, name, trailing value — so a glance
* answers "what is this number" without hovering. Sections carry a heading and
* are separated by a hairline; the panel itself stays chrome-less, since it is
* an object inside the chat rather than a docked pane.
*/
/**
* Sections are direct siblings inside the panel (fragments add no DOM nodes),
* so the separator is a first-child CSS rule. Passing "am I first?" down as a
* prop would mean every group tracking what the groups above it decided to
* render.
*/
const SECTION_CLASS = cn(
'flex flex-col',
'[&:not(:first-child)]:mt-3 [&:not(:first-child)]:border-t',
'[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3',
);
const HEADING_CLASS = 'text-xs font-normal text-muted-foreground';
export const WorkStatusSection: React.FC<{
title: string;
/** Aggregate for the whole section; belongs on the heading, not on a row. */
summary?: React.ReactNode;
children: React.ReactNode;
}> = ({ title, summary, children }) => (
<section className={SECTION_CLASS}>
<div className="mb-0.5 flex items-center gap-2 px-1">
<h3 className={cn(HEADING_CLASS, 'min-w-0 flex-1 truncate')}>{title}</h3>
{summary !== undefined && summary !== null ? (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
) : null}
</div>
{children}
</section>
);
/**
* Section whose body folds away. The chevron swaps on expand exactly as the
* transcript's tool blocks do, so the two collapsibles read as the same
* control rather than two conventions in one window.
*
* Expanded state lives in the persisted UI store, not in component state: the
* panel unmounts whenever the context panel opens, and local state would
* silently discard the user's arrangement every time.
*/
export const WorkStatusCollapsibleSection: React.FC<{
/** Stable key for persisting expanded state. */
id: string;
title: string;
icon?: IconName;
/** For glyphs that live outside the sprite, such as the MCP mark. */
iconNode?: React.ReactNode;
iconColor?: string;
/** Shown on the header while collapsed and expanded alike. */
summary?: React.ReactNode;
defaultExpanded?: boolean;
children: React.ReactNode;
}> = ({ id, title, icon, iconNode, iconColor, summary, defaultExpanded = false, children }) => {
const stored = useUIStore(
React.useCallback((state) => state.workStatusExpandedSections[id], [id]),
);
const setExpandedInStore = useUIStore((state) => state.setWorkStatusSectionExpanded);
const expanded = stored ?? defaultExpanded;
return (
<section className={SECTION_CLASS}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedInStore(id, !expanded)}
className={cn(
'group/section mb-0.5 flex h-6 items-center gap-1.5 rounded-md px-1 text-left',
// No hover fill anywhere in the panel: at this row density the blocks
// of colour read as selection, not as affordance. Interactivity shows
// through the text instead.
'transition-colors hover:text-foreground',
)}
>
{iconNode ?? (icon ? (
<Icon
name={icon}
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
style={iconColor ? { color: iconColor } : undefined}
/>
) : null)}
<span className={cn(HEADING_CLASS, 'min-w-0 truncate')}>{title}</span>
<Icon
name={expanded ? 'arrow-down-s' : 'arrow-right-s'}
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="flex-1" />
{summary !== undefined && summary !== null ? (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{summary}</span>
) : null}
</button>
{expanded ? children : null}
</section>
);
};
type RowProps = {
icon?: IconName;
iconColor?: string;
leading?: React.ReactNode;
label: React.ReactNode;
value?: React.ReactNode;
muted?: boolean;
/** Turns the row into a button; the caller decides what it opens. */
onClick?: () => void;
ariaLabel?: string;
className?: string;
};
/**
* A single readout. `value` sits hard right; `label` truncates before it, so a
* long branch name never pushes its own ahead/behind counts out of view.
*/
export const WorkStatusRow: React.FC<RowProps> = ({
icon,
iconColor,
leading,
label,
value,
muted,
onClick,
ariaLabel,
className,
}) => {
const body = (
<>
{leading ?? (icon ? (
<Icon
name={icon}
className={cn('size-4 shrink-0', !iconColor && 'text-muted-foreground')}
style={iconColor ? { color: iconColor } : undefined}
/>
) : null)}
<span className={cn('min-w-0 flex-1 truncate text-[13px]', muted && 'text-muted-foreground')}>
{label}
</span>
{value !== undefined && value !== null ? (
<span className="flex shrink-0 items-center gap-1.5 text-[13px] tabular-nums">{value}</span>
) : null}
</>
);
const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className);
if (!onClick) return <div className={shared}>{body}</div>;
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={cn(shared, 'transition-colors hover:text-foreground')}
>
{body}
</button>
);
};
type WorkStatusTone = 'default' | 'muted' | 'success' | 'error' | 'warning' | 'info';
const TONE_COLOR: Record<Exclude<WorkStatusTone, 'default' | 'muted'>, string> = {
success: 'var(--status-success)',
error: 'var(--status-error)',
warning: 'var(--status-warning)',
info: 'var(--status-info)',
};
export const WorkStatusValue: React.FC<{
children: React.ReactNode;
tone?: WorkStatusTone;
}> = ({ children, tone = 'default' }) => (
<span
className={tone === 'muted' ? 'text-muted-foreground' : undefined}
style={tone === 'default' || tone === 'muted' ? undefined : { color: TONE_COLOR[tone] }}
>
{children}
</span>
);
/**
* Trailing control shaped like the PR badge: a status that is also the thing
* you press. Used where the state itself is the affordance — an MCP server
* asking for sign-in, a goal waiting to be resumed.
*/
export const WorkStatusRowAction: React.FC<{
children: React.ReactNode;
onClick: () => void;
tone?: 'default' | 'warning' | 'error' | 'info';
disabled?: boolean;
ariaLabel?: string;
}> = ({ children, onClick, tone = 'default', disabled, ariaLabel }) => {
const color = tone === 'default' ? undefined : TONE_COLOR[tone];
return (
<button
type="button"
aria-label={ariaLabel}
disabled={disabled}
onClick={(event) => {
// The row underneath is often a button of its own with a different
// destination.
event.stopPropagation();
onClick();
}}
className={cn(
'shrink-0 rounded-full px-1.5 py-px text-[11px] font-medium leading-4 transition-opacity',
'hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
!color && 'bg-[var(--interactive-hover)] text-muted-foreground',
)}
style={color
? { color, backgroundColor: `color-mix(in srgb, ${color} 18%, transparent)` }
: undefined}
>
{children}
</button>
);
};
export const WorkStatusPill: React.FC<{
children: React.ReactNode;
color?: string;
background?: string;
}> = ({ children, color, background }) => (
<span
className={cn(
'rounded-full px-1.5 py-px text-[11px] font-medium leading-4',
!color && 'bg-[var(--interactive-hover)] text-muted-foreground',
)}
style={color ? { color, backgroundColor: background } : undefined}
>
{children}
</span>
);
/** Full-width callout for states that block the branch (merge, rebase, …). */
export const WorkStatusCallout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
className="mx-1 mb-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] font-medium"
style={{ backgroundColor: 'var(--status-warning-background)', color: 'var(--status-warning)' }}
>
<Icon name="alert" className="size-4 shrink-0" />
<span className="min-w-0 truncate">{children}</span>
</div>
);
/** Context-window fill, drawn under its row rather than inside it. */
export const WorkStatusMeter: React.FC<{ percent: number; color: string }> = ({ percent, color }) => (
<div className="mx-1 mb-1 h-1 overflow-hidden rounded-full bg-[var(--chat-divider)]">
<div
className="h-full rounded-full"
style={{ width: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: color }}
/>
</div>
);
@@ -0,0 +1,56 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
WORK_STATUS_SECTION_IDS,
WORK_STATUS_SECTION_LABEL_KEYS,
isWorkStatusSectionVisible,
} from './sections';
/**
* Which sections the work-status panel may show.
*
* Everything is on by default and the choice is stored as the *hidden* set, so
* a section added in a later release appears for everyone rather than staying
* invisible to whoever had saved settings before it existed.
*/
export const WorkStatusSectionsDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ open, onOpenChange }) => {
const { t } = useI18n();
const hidden = useUIStore((state) => state.workStatusHiddenSections);
const setSectionVisible = useUIStore((state) => state.setWorkStatusSectionVisible);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('chat.workStatus.sections.dialogTitle')}</DialogTitle>
<DialogDescription>{t('chat.workStatus.sections.dialogDescription')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col">
{WORK_STATUS_SECTION_IDS.map((sectionId) => (
<SettingsCheckboxRow
key={sectionId}
settingsItem={`chat.work-status.section.${sectionId}`}
checked={isWorkStatusSectionVisible(hidden, sectionId)}
onChange={(checked) => setSectionVisible(sectionId, checked)}
label={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])}
ariaLabel={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])}
/>
))}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,110 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { useAllLiveSessions, useAllSessionStatuses, useDirectorySync } from '@/sync/sync-context';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import type { State } from '@/sync/types';
type Props = {
sessionId: string | null;
directory: string | null;
};
const SECTION_ID = 'subagents';
/**
* Running subagents and, more importantly, their blockers: a permission request
* raised by a child session has no representation in the transcript, so this
* panel is the only place it becomes visible.
*/
export const WorkStatusSubagentsSection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const isMobile = useUIStore((state) => state.isMobile);
const liveSessions = useAllLiveSessions();
const statuses = useAllSessionStatuses();
const children = React.useMemo(
() => (sessionId ? liveSessions.filter((candidate) => candidate.parentID === sessionId) : []),
[liveSessions, sessionId],
);
// One subscription covers every child: per-session hooks would multiply
// store subscriptions by the number of subagents.
const permissions = useDirectorySync(React.useCallback((state: State) => state.permission, []));
const questions = useDirectorySync(React.useCallback((state: State) => state.question, []));
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setSectionExpanded = useUIStore((state) => state.setWorkStatusSectionExpanded);
// Subagents appearing where there were none is the one moment this section
// has something urgent to say, so it opens itself. Only on the empty→present
// edge: re-expanding on every count change would fight a user who just
// collapsed it.
const hadChildren = React.useRef(children.length > 0);
React.useEffect(() => {
const present = children.length > 0;
if (present && !hadChildren.current) setSectionExpanded(SECTION_ID, true);
hadChildren.current = present;
}, [children.length, setSectionExpanded]);
// Same branch the transcript's Task tool takes: surfaces that cannot host an
// embedded panel navigate to the child session instead of opening a tab.
const openChildSession = React.useCallback((childId: string, label: string) => {
if (!directory) return;
if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) {
setCurrentSession(childId, directory);
return;
}
openContextPanelTab(directory, {
mode: 'chat',
dedupeKey: `session:${childId}`,
label,
readOnly: true,
});
}, [directory, isMobile, openContextPanelTab, setCurrentSession]);
useReportWorkStatusPresence('subagents', children.length > 0);
if (children.length === 0) return null;
const busyChildren = children.filter((child) => statuses[child.id]?.type === 'busy').length;
return (
<WorkStatusCollapsibleSection
id={SECTION_ID}
title={t('chat.workStatus.section.subagents')}
icon="ai-agent"
defaultExpanded
summary={busyChildren > 0 ? `${busyChildren}/${children.length}` : children.length}
>
{children.map((child) => {
const blocked = (permissions[child.id]?.length ?? 0) > 0;
const asked = (questions[child.id]?.length ?? 0) > 0;
const busy = statuses[child.id]?.type === 'busy';
const label = child.title?.trim() || t('chat.workStatus.subagent.untitled');
return (
<WorkStatusRow
key={child.id}
onClick={directory ? () => openChildSession(child.id, label) : undefined}
ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })}
label={label}
value={blocked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.needsPermission')}</WorkStatusValue>
) : asked ? (
<WorkStatusValue tone="warning">{t('chat.workStatus.subagent.askedQuestion')}</WorkStatusValue>
) : busy ? (
<WorkStatusValue tone="info">{t('chat.workStatus.subagent.working')}</WorkStatusValue>
) : (
<WorkStatusValue tone="muted">{t('chat.workStatus.subagent.done')}</WorkStatusValue>
)}
/>
);
})}
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,112 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDirectorySync } from '@/sync/sync-context';
import { useTodosPersistStore } from '@/stores/useTodosPersistStore';
import { WorkStatusRow, WorkStatusSection } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import type { State } from '@/sync/types';
import type { Todo } from '@opencode-ai/sdk/v2';
type Props = {
sessionId: string | null;
directory: string | null;
};
const EMPTY_TODOS: Todo[] = [];
/**
* Work first, then what is waiting, then what is done — the panel is read
* top-down for "what is happening", and a finished item never answers that.
* Unlike the composer's dropdown, completed items stay: this is a record of the
* session, not a queue to work through.
*/
const STATUS_RANK: Record<string, number> = {
in_progress: 0,
pending: 1,
completed: 2,
};
/** Same icons the composer's todo dropdown uses, so one list does not read as two. */
const statusIcon = (status: string): { name: 'record-circle' | 'checkbox-circle' | 'time'; color?: string } => {
if (status === 'in_progress') return { name: 'record-circle', color: 'var(--status-info)' };
if (status === 'completed') return { name: 'checkbox-circle', color: 'var(--status-success)' };
return { name: 'time' };
};
export const WorkStatusTasksSection: React.FC<Props> = ({ sessionId, directory }) => {
const { t } = useI18n();
const liveTodos = useDirectorySync(
React.useCallback(
(state: State) => (sessionId ? state.todo[sessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
[sessionId],
),
);
const persistedTodos = useTodosPersistStore(
React.useCallback(
(state) => (sessionId && directory ? state.getSessionTodos(directory, sessionId) : undefined),
[directory, sessionId],
),
);
// Live channel wins; persistence only restores context for a session whose
// todo events predate this client's connection.
const todos = liveTodos.length > 0 ? liveTodos : persistedTodos ?? EMPTY_TODOS;
const visibleTodos = React.useMemo(() => {
const kept = todos
.map((todo, index) => ({ todo, index }))
.filter(({ todo }) => todo.status !== 'cancelled');
// Stable within a rank: the agent's own ordering carries meaning, so only
// the status grouping is imposed on top of it.
return kept
.sort((left, right) => {
const rank = (STATUS_RANK[left.todo.status] ?? 1) - (STATUS_RANK[right.todo.status] ?? 1);
return rank !== 0 ? rank : left.index - right.index;
})
.map(({ todo }) => todo);
}, [todos]);
useReportWorkStatusPresence('tasks', visibleTodos.length > 0);
if (visibleTodos.length === 0) return null;
const doneCount = visibleTodos.filter((todo) => todo.status === 'completed').length;
return (
<WorkStatusSection
title={t('chat.workStatus.section.tasks')}
summary={`${doneCount}/${visibleTodos.length}`}
>
{visibleTodos.map((todo, index) => {
const done = todo.status === 'completed';
const icon = statusIcon(todo.status);
return (
<Tooltip key={`${todo.status}-${index}-${todo.content}`} delayDuration={600}>
<TooltipTrigger asChild>
<div>
<WorkStatusRow
leading={(
<Icon
name={icon.name}
className="size-3.5 shrink-0"
style={icon.color ? { color: icon.color } : undefined}
/>
)}
muted={done}
label={<span className={done ? 'line-through' : undefined}>{todo.content}</span>}
/>
</div>
</TooltipTrigger>
{/* Rows truncate at this width; the tooltip is the only way to read
a long task in full. */}
<TooltipContent side="left" className="max-w-[320px]">
{todo.content}
</TooltipContent>
</Tooltip>
);
})}
</WorkStatusSection>
);
};
@@ -0,0 +1,152 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
import { formatQuotaResetLabel, formatQuotaValueLabel } from '@/lib/quota';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useUIStore } from '@/stores/useUIStore';
import { useUsageProviderGroups } from '@/components/usage/usageGroups';
import { useConfigStore } from '@/stores/useConfigStore';
import { pickUsageHeadline } from './usageHeadline';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { WorkStatusRow, WorkStatusCollapsibleSection, WorkStatusValue } from './WorkStatusPrimitives';
import { useReportWorkStatusPresence } from './presenceContext';
import type { UsageWindow } from '@/types';
/**
* Provider rate limits.
*
* The mobile popover renders these as filled cards; that language does not
* survive here — the fills and their padding fight the panel's flat rows and
* cost roughly twice the height. Only the data is shared
* (`useUsageProviderGroups`); the presentation is the panel's own row
* vocabulary, with each provider as a quiet sub-heading.
*
* Sits above Subagents and MCP: a spent quota stops the work outright, so it
* belongs with the readouts that hold for the whole session rather than with
* whatever happens to be running.
*/
const windowTone = (window: UsageWindow): 'default' | 'warning' | 'error' => {
const used = window.usedPercent;
if (typeof used !== 'number' || !Number.isFinite(used)) return 'default';
if (used >= 80) return 'error';
if (used >= 50) return 'warning';
return 'default';
};
export const WorkStatusUsageSection: React.FC = () => {
const { t } = useI18n();
const groups = useUsageProviderGroups();
const displayMode = useQuotaStore((state) => state.displayMode);
const isLoading = useQuotaStore((state) => state.isLoading);
const quotaResults = useQuotaStore((state) => state.results);
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
// Keeps the periodic refresh running while the panel is mounted.
useQuotaAutoRefresh();
// `useQuotaAutoRefresh` only schedules an interval — it never performs the
// first fetch. That was owned by the header dropdown's open handler, so the
// panel stayed empty until the user opened it. Kick off the initial load for
// any enabled provider that has not reported yet, background-gated so it
// cannot compete with chat bootstrap traffic.
React.useEffect(() => {
if (isLoading || dropdownProviderIds.length === 0) return;
const missingProvider = dropdownProviderIds.some(
(providerId) => !quotaResults.some((result) => result.providerId === providerId),
);
if (!missingProvider) return;
void runBackgroundNetworkTask(() => fetchAllQuotas());
}, [dropdownProviderIds, fetchAllQuotas, isLoading, quotaResults]);
React.useEffect(() => {
if (groups.length === 0) return;
preloadProviderLogos(groups.map((group) => group.providerId));
}, [groups]);
useReportWorkStatusPresence('usage', groups.length > 0);
if (groups.length === 0) return null;
const modeLabel = displayMode === 'remaining'
? t('header.services.remaining')
: t('header.services.used');
// Collapsed, the section shows the tightest quota of the provider the
// composer is pointed at — the number that decides whether the next turn
// lands. With no match it falls back to the display-mode label rather than
// showing some other provider's quota as if it were the active one.
const headline = pickUsageHeadline(groups, currentProviderId);
const headlineMetric = headline
? formatQuotaValueLabel(
headline.row.window.valueLabel,
displayMode === 'remaining' ? headline.row.window.remainingPercent : headline.row.window.usedPercent,
)
: null;
return (
<WorkStatusCollapsibleSection
id="usage"
title={t('chat.workStatus.section.usage')}
icon="timer"
summary={(
<span className="inline-flex items-center gap-1.5">
{isLoading ? <Icon name="refresh" className="size-3 animate-spin" /> : null}
{headline && headlineMetric && headlineMetric !== '-' ? (
<>
<span className="truncate">{headline.row.label}</span>
<WorkStatusValue tone={windowTone(headline.row.window)}>{headlineMetric}</WorkStatusValue>
</>
) : modeLabel}
</span>
)}
>
{groups.map((group) => (
<React.Fragment key={group.providerId}>
<WorkStatusRow
leading={<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />}
label={group.providerName}
muted
value={group.status && group.rows.length === 0 ? (
<WorkStatusValue tone="muted">{group.status}</WorkStatusValue>
) : undefined}
/>
{group.rows.map((row) => {
const displayPercent = displayMode === 'remaining'
? row.window.remainingPercent
: row.window.usedPercent;
const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent);
const resetLabel = formatQuotaResetLabel(
row.window.resetAt,
row.window.resetAfterFormatted ?? row.window.resetAtFormatted,
timeFormatPreference,
);
return (
<WorkStatusRow
key={`${group.providerId}-${row.key}`}
label={(
<span className="inline-flex min-w-0 items-baseline gap-1.5">
<span className="truncate">
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
</span>
{resetLabel ? (
<span className="shrink-0 text-[11px] text-muted-foreground">{resetLabel}</span>
) : null}
</span>
)}
value={metricLabel === '-' ? undefined : (
<WorkStatusValue tone={windowTone(row.window)}>{metricLabel}</WorkStatusValue>
)}
/>
);
})}
</React.Fragment>
))}
</WorkStatusCollapsibleSection>
);
};
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { computeContextUsage, DEFAULT_CONTEXT_LIMIT } from './contextUsage';
const assistant = (tokens: Record<string, unknown>, id = 'msg') => ({ id, role: 'assistant', tokens });
describe('computeContextUsage', () => {
test('sums every token bucket of the newest reporting assistant message', () => {
const usage = computeContextUsage(
[assistant({ input: 100, output: 20, reasoning: 5, cache: { read: 800, write: 75 } })],
2000,
);
expect(usage?.totalTokens).toBe(1000);
expect(usage?.percent).toBe(50);
});
test('reports the latest turn rather than a sum across turns', () => {
// Each assistant turn reports the whole window it saw, so adding them up
// would report several times the real fill.
const usage = computeContextUsage(
[
assistant({ input: 400, output: 0, reasoning: 0 }, 'old'),
assistant({ input: 900, output: 0, reasoning: 0 }, 'new'),
],
1000,
);
expect(usage?.totalTokens).toBe(900);
});
test('skips user messages and assistant turns that reported nothing', () => {
const usage = computeContextUsage(
[
assistant({ input: 300, output: 0, reasoning: 0 }, 'real'),
assistant({ input: 0, output: 0, reasoning: 0 }, 'zeroed'),
{ id: 'user', role: 'user' },
],
1000,
);
expect(usage?.totalTokens).toBe(300);
});
test('leaves the percentage unrounded', () => {
// Rounding here is what made the panel print "34.0%" against the header's
// "33.6%".
const usage = computeContextUsage([assistant({ input: 336, output: 0, reasoning: 0 })], 1000);
expect(usage?.percent.toFixed(1)).toBe('33.6');
});
test('falls back to the default limit when the model exposes none', () => {
const usage = computeContextUsage([assistant({ input: 20_000, output: 0, reasoning: 0 })], 0);
expect(usage?.limit).toBe(DEFAULT_CONTEXT_LIMIT);
expect(usage?.percent).toBe(10);
});
test('returns null when no message carries usable tokens', () => {
expect(computeContextUsage([], 1000)).toBeNull();
expect(computeContextUsage([{ id: 'u', role: 'user' }], 1000)).toBeNull();
expect(computeContextUsage([assistant({ input: 0, output: 0, reasoning: 0 })], 1000)).toBeNull();
});
test('tolerates partial token payloads', () => {
const usage = computeContextUsage([assistant({ input: 10 })], 100);
expect(usage?.totalTokens).toBe(10);
});
});
@@ -0,0 +1,71 @@
/**
* Context-window usage for a specific session.
*
* `useSessionUIStore.getContextUsage` cannot serve this panel. It reads
* `getSyncMessages(sessionId)` with **no directory**, which resolves to the
* *current* directory's child store, and it keys off the store's own
* `currentSessionId`. A session held by another directory — a worktree, or any
* moment right after a directory switch — therefore reads as "no messages" and
* the readout silently disappears while the header still shows a value.
*
* This computes the same quantity from messages the caller has already
* subscribed to for a known session and directory, so there is no hidden
* global read to race with.
*/
type MessageTokens = {
input?: number;
output?: number;
reasoning?: number;
cache?: { read?: number; write?: number };
};
type MessageLike = {
id?: string;
role?: string;
tokens?: MessageTokens;
};
type WorkStatusContextUsage = {
totalTokens: number;
/** Context limit actually used for the ratio, after the default fallback. */
limit: number;
/** Unrounded, so the panel and the header cannot disagree by a rounding step. */
percent: number;
};
/** The store's own fallback when a model exposes no context limit. */
export const DEFAULT_CONTEXT_LIMIT = 200_000;
const sumTokens = (tokens: MessageTokens): number => (
(tokens.input ?? 0)
+ (tokens.output ?? 0)
+ (tokens.reasoning ?? 0)
+ (tokens.cache?.read ?? 0)
+ (tokens.cache?.write ?? 0)
);
/**
* Usage from the newest assistant message that reported a non-zero token count.
* Each assistant turn reports the whole window it saw, so the latest one is the
* current fill — not a sum across turns.
*/
export const computeContextUsage = (
messages: readonly MessageLike[],
contextLimit: number,
): WorkStatusContextUsage | null => {
if (messages.length === 0) return null;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== 'assistant' || !message.tokens) continue;
const totalTokens = sumTokens(message.tokens);
if (totalTokens <= 0) continue;
const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT;
return { totalTokens, limit, percent: (totalTokens / limit) * 100 };
}
return null;
};
@@ -0,0 +1,25 @@
import React from 'react';
import { PresenceContext } from './presenceContext';
/**
* Collects which sections rendered, so the panel can hide its card entirely
* when none did. See `presenceContext.ts` for why sections report rather than
* the panel deriving it.
*/
export const WorkStatusPresenceProvider: React.FC<{
onChange: (count: number) => void;
children: React.ReactNode;
}> = ({ onChange, children }) => {
const presentRef = React.useRef(new Set<string>());
const report = React.useCallback((id: string, present: boolean) => {
const set = presentRef.current;
const had = set.has(id);
if (present === had) return;
if (present) set.add(id);
else set.delete(id);
onChange(set.size);
}, [onChange]);
return <PresenceContext.Provider value={report}>{children}</PresenceContext.Provider>;
};
@@ -0,0 +1,23 @@
import React from 'react';
/**
* Whether any section actually rendered.
*
* Every section decides for itself that it has nothing to say and returns
* null, so the panel cannot know in advance whether it is empty — and an empty
* panel is a bordered card holding nothing but its settings icon, which reads
* as a fault. Re-deriving each section's emptiness at the panel level would
* mean duplicating every data source it reads, so sections report instead.
*/
export const PresenceContext = React.createContext<((id: string, present: boolean) => void) | null>(null);
/** Call from a section with whether it rendered anything this pass. */
export const useReportWorkStatusPresence = (id: string, present: boolean): void => {
const report = React.useContext(PresenceContext);
React.useEffect(() => {
report?.(id, present);
// Leaving the set on unmount, so a section that stops rendering entirely
// does not keep the panel alive.
return () => report?.(id, false);
}, [id, present, report]);
};
@@ -0,0 +1,48 @@
import { describe, expect, test } from 'bun:test';
import {
WORK_STATUS_SECTION_IDS,
WORK_STATUS_SECTION_LABEL_KEYS,
isWorkStatusSectionVisible,
sanitizeWorkStatusHiddenSections,
} from './sections';
describe('section registry', () => {
test('every section has a label, and every label a section', () => {
// One list drives the panel and the dialog; a mismatch means a section the
// user cannot switch, or a switch for nothing.
expect(Object.keys(WORK_STATUS_SECTION_LABEL_KEYS).sort())
.toEqual([...WORK_STATUS_SECTION_IDS].sort());
});
});
describe('isWorkStatusSectionVisible', () => {
test('everything is visible by default', () => {
// Storing the hidden set means a section added later is on for everyone,
// rather than invisible to whoever had settings saved before it existed.
expect(isWorkStatusSectionVisible([], 'usage')).toBe(true);
expect(isWorkStatusSectionVisible(undefined, 'usage')).toBe(true);
expect(isWorkStatusSectionVisible(null, 'usage')).toBe(true);
});
test('hides exactly the listed section', () => {
expect(isWorkStatusSectionVisible(['usage'], 'usage')).toBe(false);
expect(isWorkStatusSectionVisible(['usage'], 'tasks')).toBe(true);
});
});
describe('sanitizeWorkStatusHiddenSections', () => {
test('keeps known ids and drops everything else', () => {
expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks']))
.toEqual(['usage', 'tasks']);
});
test('deduplicates', () => {
expect(sanitizeWorkStatusHiddenSections(['usage', 'usage'])).toEqual(['usage']);
});
test('treats a non-array payload as no preference', () => {
expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]);
expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]);
expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]);
});
});
@@ -0,0 +1,59 @@
import type { I18nKey } from '@/lib/i18n/messages/en';
/**
* Every section the work-status panel can render, in display order.
*
* One list drives both the panel and its settings dialog, so a section cannot
* exist in the panel without being switchable, or appear in the dialog without
* existing.
*
* The ids are persisted in user settings — renaming one silently resets that
* user's choice for it.
*/
export const WORK_STATUS_SECTION_IDS = [
'session',
'repository',
'usage',
'subagents',
'tasks',
'mcp',
'pinned',
'contextSources',
] as const;
type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number];
export const WORK_STATUS_SECTION_LABEL_KEYS: Record<WorkStatusSectionId, I18nKey> = {
session: 'chat.workStatus.section.session',
repository: 'chat.workStatus.section.repository',
usage: 'chat.workStatus.section.usage',
subagents: 'chat.workStatus.section.subagents',
tasks: 'chat.workStatus.section.tasks',
mcp: 'chat.workStatus.section.mcp',
pinned: 'chat.workStatus.section.pinned',
contextSources: 'chat.workStatus.section.contextBreakdown',
};
const KNOWN_IDS = new Set<string>(WORK_STATUS_SECTION_IDS);
const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId =>
typeof value === 'string' && KNOWN_IDS.has(value);
/**
* Hidden sections are stored, not visible ones: everything is on by default, so
* an empty list means "the user has changed nothing" and a section added later
* appears without touching anyone's saved settings.
*/
export const isWorkStatusSectionVisible = (
hidden: readonly string[] | null | undefined,
id: WorkStatusSectionId,
): boolean => !hidden?.includes(id);
export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<WorkStatusSectionId>();
for (const entry of value) {
if (isWorkStatusSectionId(entry)) seen.add(entry);
}
return [...seen];
};
@@ -0,0 +1,95 @@
import { describe, expect, test } from 'bun:test';
import { pickUsageHeadline, resolveQuotaProviderId } from './usageHeadline';
import type { UsageProviderGroup } from '@/components/usage/usageGroups';
const HOUR = 3600;
const window = (windowSeconds: number | null) => ({
usedPercent: 10,
remainingPercent: 90,
windowSeconds,
resetAfterSeconds: null,
resetAt: null,
resetAtFormatted: null,
resetAfterFormatted: null,
});
const group = (providerId: string, rows: Array<{ key: string; label: string; subtitle?: string; seconds: number | null }>): UsageProviderGroup => ({
providerId: providerId as UsageProviderGroup['providerId'],
providerName: providerId,
status: null,
rows: rows.map((row) => ({
key: row.key,
label: row.label,
subtitle: row.subtitle,
window: window(row.seconds),
})),
});
describe('resolveQuotaProviderId', () => {
test('passes through ids that already match a quota provider', () => {
expect(resolveQuotaProviderId('opencode-go')).toBe('opencode-go');
});
test('maps the known divergences', () => {
expect(resolveQuotaProviderId('openai')).toBe('codex');
expect(resolveQuotaProviderId('anthropic')).toBe('claude');
});
test('is case and whitespace tolerant, and rejects empties', () => {
expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex');
expect(resolveQuotaProviderId('')).toBeNull();
expect(resolveQuotaProviderId(null)).toBeNull();
});
});
describe('pickUsageHeadline', () => {
const groups = [
group('codex', [{ key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }]),
group('opencode-go', [
{ key: 'm', label: 'Monthly Limit', seconds: 30 * 24 * HOUR },
{ key: 'h', label: '5-Hour', seconds: 5 * HOUR },
{ key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR },
]),
];
test('picks the shortest window of the matching provider', () => {
// The tightest bucket is the one that decides whether the next turn lands.
expect(pickUsageHeadline(groups, 'opencode-go')?.row.label).toBe('5-Hour');
});
test('resolves the provider through the alias table', () => {
expect(pickUsageHeadline(groups, 'openai')?.group.providerId).toBe('codex');
});
test('returns null when no group matches the composer provider', () => {
// Showing another provider's quota would read as the active one.
expect(pickUsageHeadline(groups, 'mistral')).toBeNull();
expect(pickUsageHeadline(groups, null)).toBeNull();
});
test('ignores model-scoped rows while any provider-level row exists', () => {
const scoped = [group('zai-coding-plan', [
{ key: 'model', label: '5-Hour', subtitle: 'GLM-5', seconds: 5 * HOUR },
{ key: 'provider', label: 'Weekly Limit', seconds: 7 * 24 * HOUR },
])];
expect(pickUsageHeadline(scoped, 'zai-coding-plan')?.row.label).toBe('Weekly Limit');
});
test('falls back to a durationless row when nothing reports a window', () => {
const balances = [group('codex', [{ key: 'credits', label: 'Credits Balance', seconds: null }])];
expect(pickUsageHeadline(balances, 'codex')?.row.label).toBe('Credits Balance');
});
test('prefers any real window over a durationless row', () => {
const mixed = [group('codex', [
{ key: 'credits', label: 'Credits Balance', seconds: null },
{ key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR },
])];
expect(pickUsageHeadline(mixed, 'codex')?.row.label).toBe('Weekly Limit');
});
test('returns null for a matched provider that reported no rows', () => {
expect(pickUsageHeadline([group('codex', [])], 'codex')).toBeNull();
});
});
@@ -0,0 +1,66 @@
import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usageGroups';
/**
* Picking the one quota worth showing while the Usage section is collapsed.
*
* The interesting limit is the one that runs out first, which is the shortest
* window a provider reports — a 5-hour bucket says more about whether the next
* turn will land than a monthly one. Rows without a window duration (credit
* balances, tool counters) are kept only as a last resort, since they never
* answer "can I keep working right now".
*/
/**
* Quota provider ids mostly match OpenCode provider ids; these are the ones
* that do not. Unmatched providers simply produce no headline.
*/
const QUOTA_PROVIDER_ALIASES = new Map<string, string>([
['openai', 'codex'],
['chatgpt', 'codex'],
['anthropic', 'claude'],
['gemini', 'google'],
]);
const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase();
export const resolveQuotaProviderId = (modelProviderId: string | null | undefined): string | null => {
const normalized = normalize(modelProviderId);
if (!normalized) return null;
return QUOTA_PROVIDER_ALIASES.get(normalized) ?? normalized;
};
/**
* Shortest reported window for the provider the composer is pointed at.
*
* Returns null when nothing matches — the section then falls back to its
* display-mode label rather than showing a quota belonging to some other
* provider, which would read as the active one.
*/
export const pickUsageHeadline = (
groups: readonly UsageProviderGroup[],
modelProviderId: string | null | undefined,
): { group: UsageProviderGroup; row: UsageLimitRow } | null => {
const quotaProviderId = resolveQuotaProviderId(modelProviderId);
if (!quotaProviderId) return null;
const group = groups.find((candidate) => normalize(candidate.providerId) === quotaProviderId);
if (!group || group.rows.length === 0) return null;
// Provider-level rows only: a model-scoped row describes one model, not the
// provider the composer is pointed at.
const providerRows = group.rows.filter((row) => !row.subtitle);
const rows = providerRows.length > 0 ? providerRows : group.rows;
let best: UsageLimitRow | null = null;
let bestSeconds = Number.POSITIVE_INFINITY;
for (const row of rows) {
const seconds = row.window.windowSeconds;
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) continue;
if (seconds < bestSeconds) {
best = row;
bestSeconds = seconds;
}
}
return { group, row: best ?? rows[0] };
};
@@ -0,0 +1,329 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
type PanelState = {
isOpen: boolean;
tabs: { id: string; mode: string }[];
activeTabId: string | null;
};
let panelByDirectory: Record<string, PanelState> = {};
let panelEnabled = true;
mock.module('@/stores/useUIStore', () => ({
useUIStore: (selector: (state: unknown) => unknown) =>
selector({ contextPanelByDirectory: panelByDirectory, workStatusPanelEnabled: panelEnabled }),
}));
mock.module('@/lib/pathNormalization', () => ({
normalizePath: (value?: string | null) => value ?? null,
}));
const { useWorkStatusVisibility, WORK_STATUS_REQUIRED_ROW_WIDTH: REQUIRED } = await import(
'./useWorkStatusVisibility'
);
/** Elements the stubbed ResizeObserver was asked to observe, in order. */
let observed: unknown[] = [];
let notify: ((entries: { contentRect: { width: number } }[]) => void) | null = null;
class StubResizeObserver {
constructor(callback: (entries: { contentRect: { width: number } }[]) => void) {
notify = callback;
}
observe(element: unknown) {
observed.push(element);
}
disconnect() {
notify = null;
}
}
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
const documentStub: Record<string, unknown> = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('ResizeObserver', StubResizeObserver);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
type Args = { directory: string | null; isMobile: boolean; isVSCode: boolean };
/**
* Renders the hook with a stand-in row node, attached through the returned
* callback ref exactly as the real tree does.
*/
const renderVisibility = (args: Args, rowWidth: number) => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
// `closest` returns null here, so the hook falls back to the row itself —
// the fallback path is what these cases exercise.
const rowNode = {
getBoundingClientRect: () => ({ width: rowWidth }),
closest: () => null,
} as unknown as HTMLDivElement;
const result = { visible: false, fits: false };
const Probe: React.FC = () => {
const { rowRef, visible, fits } = useWorkStatusVisibility(args);
result.visible = visible;
result.fits = fits;
React.useLayoutEffect(() => {
rowRef(rowNode);
return () => rowRef(null);
}, [rowRef]);
return null;
};
act(() => { root.render(React.createElement(Probe)); });
return {
result,
rowNode,
teardown: () => {
act(() => { root.unmount(); });
dom.restore();
},
};
};
beforeEach(() => {
panelByDirectory = {};
panelEnabled = true;
observed = [];
notify = null;
});
afterEach(() => {
observed = [];
notify = null;
});
describe('useWorkStatusVisibility', () => {
test('shows the panel when the row can afford both columns', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
teardown();
});
test('hides the panel when the row cannot afford both columns', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED - 1,
);
expect(result.visible).toBe(false);
teardown();
});
test('prefers the marked chat area over the row it was handed', () => {
// The row is what the context panel squeezes, over an animation. Measuring
// it made the panel reappear only once that number caught up, so the chat
// widened first and narrowed again afterwards.
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const chatArea = { getBoundingClientRect: () => ({ width: REQUIRED }) };
const rowNode = {
getBoundingClientRect: () => ({ width: 0 }),
closest: () => chatArea,
} as unknown as HTMLDivElement;
const result = { visible: false };
const Probe: React.FC = () => {
const { rowRef, visible } = useWorkStatusVisibility({
directory: '/repo',
isMobile: false,
isVSCode: false,
});
result.visible = visible;
React.useLayoutEffect(() => {
rowRef(rowNode);
return () => rowRef(null);
}, [rowRef]);
return null;
};
act(() => { root.render(React.createElement(Probe)); });
expect(observed).toEqual([chatArea]);
expect(result.visible).toBe(true);
act(() => { root.unmount(); });
dom.restore();
});
test('measures a container the panel cannot resize, never the chat column', () => {
// The measured element must not depend on whether the panel is showing:
// otherwise hiding the panel widens it and re-shows the panel, forever.
// In the app this is the chat area (chat + context panel); here `closest`
// finds nothing, so the hook falls back to the row it was given.
const { rowNode, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED,
);
expect(observed).toHaveLength(1);
expect(observed[0]).toBe(rowNode);
teardown();
});
test('reacts to a live resize across the threshold', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
act(() => { notify?.([{ contentRect: { width: REQUIRED - 40 } }]); });
expect(result.visible).toBe(false);
act(() => { notify?.([{ contentRect: { width: REQUIRED + 200 } }]); });
expect(result.visible).toBe(true);
teardown();
});
test('yields to an open context panel while still measuring the row', () => {
// Measurement continues so the panel can come back in the same commit that
// reveals it. Stopping cost a frame: closing the context panel widened the
// chat, and only then did the panel reappear and narrow it again.
panelByDirectory = {
'/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' },
};
const { result, rowNode, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(false);
expect(observed).toEqual([rowNode]);
teardown();
});
test('ignores an open context panel that has no resolvable tab', () => {
// ContextPanel renders nothing in that state, so it displaces nothing.
panelByDirectory = { '/repo': { isOpen: true, tabs: [], activeTabId: null } };
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
teardown();
});
test('measures a row that attaches after the first render', () => {
// Regression: with an object ref the measuring effect read `.current`
// once, found nothing when the row mounted late, and only recovered when
// some unrelated dependency changed — in practice, opening and closing the
// context panel. The panel must appear as soon as the row exists.
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const rowNode = {
getBoundingClientRect: () => ({ width: REQUIRED }),
closest: () => null,
} as unknown as HTMLDivElement;
const result = { visible: false };
let attach: (value: boolean) => void = () => undefined;
const Probe: React.FC = () => {
const [attached, setAttached] = React.useState(false);
const { rowRef, visible } = useWorkStatusVisibility({
directory: '/repo',
isMobile: false,
isVSCode: false,
});
result.visible = visible;
attach = setAttached;
React.useLayoutEffect(() => {
if (attached) rowRef(rowNode);
}, [attached, rowRef]);
return null;
};
act(() => { root.render(React.createElement(Probe)); });
expect(result.visible).toBe(false);
act(() => { attach(true); });
expect(result.visible).toBe(true);
act(() => { root.unmount(); });
dom.restore();
});
test('stays hidden when the user switched the panel off, but still reports the fit', () => {
// The header offers the panel as an overlay when layout refuses it, so it
// needs the two answers apart: whether the user wants it, and whether
// there is room for it.
panelEnabled = false;
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED * 2,
);
expect(result.visible).toBe(false);
expect(result.fits).toBe(true);
teardown();
});
test('reports no fit when the row is too narrow, whatever the switch says', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
REQUIRED - 1,
);
expect(result.fits).toBe(false);
expect(result.visible).toBe(false);
teardown();
});
test('stays hidden on mobile and in VS Code regardless of width', () => {
const mobile = renderVisibility(
{ directory: '/repo', isMobile: true, isVSCode: false },
REQUIRED * 2,
);
expect(mobile.result.visible).toBe(false);
mobile.teardown();
observed = [];
const vscode = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: true },
REQUIRED * 2,
);
expect(vscode.result.visible).toBe(false);
vscode.teardown();
});
});
@@ -0,0 +1,116 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { normalizePath } from '@/lib/pathNormalization';
/**
* Fixed panel width. The panel is not user-resizable: it is an object inside
* the chat rather than a docked pane, so it has no resizer and no persisted
* width.
*/
export const WORK_STATUS_PANEL_WIDTH = 300;
/**
* Minimum width the message column must keep for itself. Below this the panel
* yields — a squeezed transcript costs more than the status it displaces.
*/
const WORK_STATUS_MIN_CHAT_WIDTH = 560;
/** The card's own horizontal margins (`ml-2` + `mr-4`). */
const WORK_STATUS_PANEL_GUTTER = 8 + 16;
/** Row width below which the panel gives its space back to the transcript. */
export const WORK_STATUS_REQUIRED_ROW_WIDTH =
WORK_STATUS_PANEL_WIDTH + WORK_STATUS_PANEL_GUTTER + WORK_STATUS_MIN_CHAT_WIDTH;
type Options = {
directory: string | null | undefined;
isMobile: boolean;
isVSCode: boolean;
};
type Result = {
/** Layout can host the panel inline, regardless of the user's switch. */
fits: boolean;
/**
* Attach to the flex row that contains the chat column and the panel.
*
* A callback ref, not an object ref: an object ref gives no signal when the
* node attaches, so a measuring effect that reads `.current` would silently
* observe nothing whenever the row mounts after the effect first ran, and
* would only recover on the next unrelated dependency change.
*/
rowRef: (node: HTMLDivElement | null) => void;
visible: boolean;
};
/**
* Decides whether the work-status panel may occupy space inside the chat.
*
* The width test measures the ROW (chat column + panel), never the chat column
* alone. The chat column's width is an output of this decision: hiding the
* panel widens it, which would re-satisfy a chat-width test and re-show the
* panel, oscillating forever. The row width is independent of the panel, so it
* is the only stable input.
*/
export const useWorkStatusVisibility = ({ directory, isMobile, isVSCode }: Options): Result => {
const [rowNode, setRowNode] = React.useState<HTMLDivElement | null>(null);
const [rowWidth, setRowWidth] = React.useState<number | null>(null);
const rowRef = React.useCallback((node: HTMLDivElement | null) => { setRowNode(node); }, []);
const directoryKey = React.useMemo(() => normalizePath(directory ?? null), [directory]);
// Mirrors ContextPanel's own derivation: a panel with `isOpen` but no
// resolvable active tab renders nothing, and must not displace this panel.
const contextPanelOpen = useUIStore(
React.useCallback(
(state) => {
const panel = directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined;
if (!panel?.isOpen) return false;
const activeTab = panel.tabs.find((tab) => tab.id === panel.activeTabId)
?? panel.tabs[panel.tabs.length - 1]
?? null;
return Boolean(activeTab);
},
[directoryKey],
),
);
// The user's own switch, persisted to server settings, gates everything
// before layout is even measured.
const panelEnabled = useUIStore((state) => state.workStatusPanelEnabled);
// Split from the switch: a narrow chat is a layout fact, and the header needs
// it to offer the panel as an overlay instead of pretending it is off.
const layoutAllows = !isMobile && !isVSCode && !contextPanelOpen;
// Measures the chat AREA — the container holding the chat and the context
// panel together — not the chat row inside it.
//
// The row is what the context panel squeezes, and it squeezes it over a
// 200ms animation. Measuring the row therefore reported a width that was
// still catching up while the context panel collapsed, so this panel only
// reappeared once that number crossed the threshold: the chat widened first
// and narrowed again afterwards. The chat area's width does not move when
// the context panel opens, so the reading is correct the instant it closes.
//
// It is also the stable input the oscillation argument needs: this panel's
// own visibility cannot change the width being measured.
React.useEffect(() => {
if (!rowNode || typeof ResizeObserver === 'undefined') return undefined;
const measured = rowNode.closest<HTMLElement>('[data-chat-area]') ?? rowNode;
setRowWidth(measured.getBoundingClientRect().width);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
setRowWidth(entry.contentRect.width);
});
observer.observe(measured);
return () => observer.disconnect();
}, [rowNode]);
const fits = layoutAllows && rowWidth !== null && rowWidth >= WORK_STATUS_REQUIRED_ROW_WIDTH;
const visible = panelEnabled && fits;
return { rowRef, visible, fits };
};