Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a26eceb
feat(artifacts): add a status bar to the Agent interface status view
ibetitsmike Oct 27, 2026
42f0f47
fix(artifacts): satisfy lint in the status bar and its tests
ibetitsmike Oct 27, 2026
41c98c8
refactor(artifacts): split the status bar into smaller parts
ibetitsmike Oct 27, 2026
85a5c3f
test(artifacts): cover the status bar's receipt, empty state and prev…
ibetitsmike Oct 27, 2026
0cd7f73
test(artifacts): close status bar mutation gaps
ibetitsmike Oct 27, 2026
122168c
fix(artifacts): keep ?? for the startup breadcrumb, as the lint rule …
ibetitsmike Oct 27, 2026
098f477
chore(artifacts): drop a status bar comment its docstring already covers
ibetitsmike Oct 27, 2026
5195e3c
test(artifacts): cover compacting and question phases over a running …
ibetitsmike Oct 27, 2026
b96a268
fix(artifacts): show the receipt's clock time and drop a dangling ari…
ibetitsmike Oct 27, 2026
5171805
fix(artifacts): address Codex review of the status bar
ibetitsmike Oct 27, 2026
09c88c4
refactor(artifacts): gate Start Here on one blocked flag
ibetitsmike Oct 27, 2026
5838e3b
test(artifacts): cover the Start Here race, the peek chord and held i…
ibetitsmike Oct 27, 2026
7699c78
test(artifacts): cover the peek chord under a dialog and the newest p…
ibetitsmike Oct 27, 2026
13e2e95
fix(artifacts): forward the peek chord from the status page and add S…
ibetitsmike Oct 27, 2026
704f64e
test(artifacts): check interrupting in the turn-active table
ibetitsmike Oct 27, 2026
bfcbc5b
test(artifacts): check that a held or disabled Start here chord is st…
ibetitsmike Oct 27, 2026
31248d9
test(stories): make the status bar mid-turn snapshot deterministic
ibetitsmike Oct 27, 2026
2b9b606
fix(artifacts): keep Start here on the reply it opened for, and off d…
ibetitsmike Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/workspaces/artifacts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ When the workspace has an active goal, Xum keeps `goal.status.html` up to date i
- When the workspace has no status page, or the page is empty or cannot be shown (for example a binary file), the main pane shows the chat transcript.
- On the status page, every `window.xum.send` asks first, also from a click: Xum shows the text above the page with **Send** and **Dismiss**. The page shows without you opening it, so its script could otherwise take a click elsewhere in Xum for a click in the page.

A bar above the status page, drawn by Xum and not by the page, shows what the agent is doing now (for example the tool it runs, or **Idle**) and where your last message is: **Queued**, **Held, not sent**, **Sending…**, or **In transcript**. **Show transcript**, or `Ctrl+Alt+Shift+P` (`Cmd+Option+Shift+P` on macOS), opens the newest transcript rows below the bar, and Xum remembers whether it is open. **Start here**, or `Ctrl+Alt+Shift+H` (`Cmd+Option+Shift+H` on macOS), starts a new context from the agent's last reply and keeps earlier history, like **Start Here** on a reply in the chat. It is off while a turn runs. Its shortcut does nothing while focus is in the page, so the page's script cannot open the confirmation while you type in the page.

### Status page agent

**Status page agent** is a sub-experiment of Artifacts that needs Agent interface view. Turn both on under the Artifacts row in **Settings > Experiments**: the **Status page agent** row shows there only while Agent interface view is on. When it is on, a separate status page agent keeps `status.html` current, so the main agent spends no time or tokens on the page.
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,10 +1,40 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { CircleAlert, CircleDot, Hourglass, ListStart, Loader2 } from "lucide-react";
import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip";
import { useAPI } from "@/browser/contexts/API";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { useStartHere } from "@/browser/hooks/useStartHere";
import {
useWorkspaceAggregator,
useWorkspaceStoreRaw,
type WorkspaceState,
} from "@/browser/stores/WorkspaceStore";
import { formatTimestamp } from "@/browser/utils/ui/dateTime";
import {
formatKeybind,
isAltGraphPressed,
KEYBINDS,
matchesKeybind,
type Keybind,
} from "@/browser/utils/ui/keybinds";
import { AGENT_INTERFACE_TRANSCRIPT_PEEK_KEY } from "@/common/constants/storage";
import { cn } from "@/common/lib/utils";
import { AGENT_INTERFACE_STATUS_ARTIFACT } from "@/common/constants/artifacts";
import type { ArtifactReadResult } from "@/common/orpc/schemas/artifacts";
import type { ChatInputAPI } from "@/browser/features/ChatInput/types";
import { useUserPreferences } from "@/browser/stores/AppConfigStore";
import {
deriveLastMessageReceipt,
deriveStatusActivity,
findStartHereSource,
isTurnActive,
recentTranscriptRows,
type LastMessageReceipt,
type LastMessageReceiptState,
type StatusActivity,
} from "./agentInterfaceStatusBar";
import {
isShortcutOwnedElsewhere,
readStatusPage,
recordStatusPageRead,
subscribeStatusListings,
Expand Down Expand Up @@ -140,6 +170,278 @@
return { ...handlers, requestSend: (text, data) => handlers.requestSend(text, data, "ask") };
}

/** How many transcript rows the peek lists. */
const TRANSCRIPT_PEEK_ROWS = 8;

const RECEIPT_CHIPS: Record<LastMessageReceiptState, { label: string; className: string }> = {
queued: { label: "Queued", className: "text-pending" },
held: { label: "Held, not sent", className: "text-error" },
sending: { label: "Sending…", className: "text-muted" },
"in-transcript": { label: "In transcript", className: "text-success" },
};

/**
* The workspace's state, or null while the store has not registered it (tests, teardown):
* the bar then shows nothing instead of throwing.
*/
function useRegisteredWorkspaceState(workspaceId: string): WorkspaceState | null {
const store = useWorkspaceStoreRaw();
return useSyncExternalStore(
(listener) => store.subscribeKey(workspaceId, listener),
() => (store.hasRegisteredWorkspace(workspaceId) ? store.getWorkspaceState(workspaceId) : null)
);
}

function ActivityIcon(props: { activity: StatusActivity }) {
const className = "h-3.5 w-3.5 shrink-0";
switch (props.activity.kind) {
case "idle":
return <CircleDot aria-hidden="true" className={cn(className, "text-muted")} />;
case "error":
return <CircleAlert aria-hidden="true" className={cn(className, "text-error")} />;
case "monitor":
return <Hourglass aria-hidden="true" className={cn(className, "text-muted")} />;
default:
return <Loader2 aria-hidden="true" className={cn(className, "text-muted animate-spin")} />;
}
}

/**
* The host's bar above the Agent interface status page: what the agent does right now, where the
* user's last message is, a peek at the transcript the page covers, and Start Here from the
* agent's last reply. A leaf that subscribes to the workspace itself, so stream deltas re-render
* only the bar, not the page's frame.
*/
function AgentInterfaceStatusBar(props: { workspaceId: string }) {
const state = useRegisteredWorkspaceState(props.workspaceId);
if (state == null) return null;
return <StatusBar workspaceId={props.workspaceId} state={state} />;
}

/**
* A chord of the bar, on the app's window. The bar is mounted only while the status page shows,
* so its chords do nothing in the chat view. `run` is null while the action is off: the chord is
* still claimed. Keys typed in the page's frame reach this listener only when its bridge
* forwards them (ARTIFACT_FRAME_CHORDS).
*/
function useBarShortcut(keybind: Keybind, run: (() => void) | null) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// AltGr reads as Ctrl+Alt and types text, which stays the focused field's.
if (!matchesKeybind(e, keybind) || isAltGraphPressed(e) || isShortcutOwnedElsewhere(e)) {
return;
}
e.preventDefault();
// A held chord would act on every key repeat.
if (e.repeat || run == null) return;
run();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [keybind, run]);
}

function StatusBar(props: { workspaceId: string; state: WorkspaceState }) {
const { state } = props;
const panelId = useId();
const [peekOpen, setPeekOpen] = usePersistedState(AGENT_INTERFACE_TRANSCRIPT_PEEK_KEY, false);
const togglePeek = () => setPeekOpen((open) => !open);
useBarShortcut(KEYBINDS.TOGGLE_STATUS_TRANSCRIPT_PEEK, togglePeek);
// Not in WorkspaceState, as for StreamingBarrier: setInterrupting bumps the state, and that
// re-renders the bar.
const aggregator = useWorkspaceAggregator(props.workspaceId);
const activity = deriveStatusActivity(state.messages, {
canInterrupt: state.canInterrupt,
isInterrupting: aggregator?.hasInterruptingStream() ?? false,

Check warning on line 255 in src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: ?? -> ||

No unit test failed when StatusBar had ?? -> ||. Add a test that tells them apart, or say why they are equivalent.
isStreamStarting: state.isStreamStarting,
isCompacting: state.isCompacting,
awaitingUserQuestion: state.awaitingUserQuestion,
Comment thread
ibetitsmike marked this conversation as resolved.
activeBashMonitorCount: state.activeBashMonitorCount,
runtimeStatus: state.runtimeStatus,
autoRetryStatus: state.autoRetryStatus,
});
Comment thread
ibetitsmike marked this conversation as resolved.
const receipt = deriveLastMessageReceipt(state.messages, state.queuedMessage, state.heldInputs);

return (
<>
<div
data-testid="agent-interface-status-bar"
className="border-border-light bg-surface-secondary shrink-0 border-b text-[11px]"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1">
<ActivityLine activity={activity} />
<div className="flex shrink-0 items-center gap-1">
<TooltipIfPresent
tooltip={`${peekOpen ? "Hide" : "Show"} the newest transcript rows (${formatKeybind(KEYBINDS.TOGGLE_STATUS_TRANSCRIPT_PEEK)})`}
side="bottom"
>
<button
type="button"
aria-expanded={peekOpen}
// The peek is unmounted while closed, and aria-controls must name an element that exists.
aria-controls={peekOpen ? panelId : undefined}
onClick={togglePeek}
className="text-muted hover:bg-hover hover:text-foreground focus-visible:ring-accent h-6 rounded px-1.5 focus-visible:ring-1"
>
{peekOpen ? "Hide transcript" : "Show transcript"}
</button>
</TooltipIfPresent>
<StartHereButton
workspaceId={props.workspaceId}
messages={state.messages}
turnActive={isTurnActive(activity)}
Comment thread
ibetitsmike marked this conversation as resolved.
/>
</div>
</div>
{receipt != null && <ReceiptRow receipt={receipt} />}
</div>
{peekOpen && <TranscriptPeek id={panelId} messages={state.messages} />}
</>
);
}

function ActivityLine(props: { activity: StatusActivity }) {
return (
<div
role="status"
aria-live="polite"
data-testid="agent-interface-status-activity"
className="flex min-w-0 flex-[1_1_12rem] items-center gap-1.5"
>
<ActivityIcon activity={props.activity} />
<span className="min-w-0 truncate">
<span className="text-foreground font-medium">{props.activity.label}</span>
{props.activity.detail != null && (
<span className="text-muted">
{" · "}
{props.activity.detail}
</span>
)}
</span>
</div>
);
}

/** Start Here from the agent's last reply, as on the reply in the chat; off while a turn runs. */
function StartHereButton(props: {
workspaceId: string;
messages: WorkspaceState["messages"];
turnActive: boolean;
}) {
const source = findStartHereSource(props.messages);
// No eligible reply counts as compacted, which keeps the hook's modal from opening.
// mutation-equivalent: an eligible reply always has text, so ?? and || agree
const startHere = useStartHere(props.workspaceId, source?.content ?? "", source == null, {
Comment thread
ibetitsmike marked this conversation as resolved.
// Preserve legacy plan/exec markers so Start Here keeps plan→exec handoff for old history.
// mutation-equivalent: agent ids are never empty strings, so ?? and || agree
sourceAgentId: source?.agentId ?? source?.mode,
// Off while a turn runs, in the confirmation too: it may have opened before the turn started.
turnActive: props.turnActive,
// The last reply can change under the open confirmation (a monitor wakes the agent).
sourceKey: source?.id,
});
// SECURITY AUDIT: unlike the peek's chord, the status page's frame does not forward this one.
// The host honours a forwarded chord during any live gesture in the frame, so the page's
// script could open the confirmation while the user types in the page, and their next key
// would land in the dialog, where Y confirms.
useBarShortcut(KEYBINDS.STATUS_START_HERE, startHere.disabled ? null : startHere.openModal);
return (
<>
<TooltipIfPresent
tooltip={`Start a new context from the agent's last reply and keep earlier history (${formatKeybind(KEYBINDS.STATUS_START_HERE)})`}
side="bottom"
align="end"
>
<button
type="button"
disabled={startHere.disabled}
onClick={startHere.openModal}
Comment thread
ibetitsmike marked this conversation as resolved.
Comment thread
ibetitsmike marked this conversation as resolved.
className="text-muted hover:bg-hover hover:text-foreground focus-visible:ring-accent flex h-6 items-center gap-1 rounded px-1.5 focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50"
>
<ListStart aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
Start here
</button>
</TooltipIfPresent>
{startHere.modal}
</>
);
}

function ReceiptRow(props: { receipt: LastMessageReceipt }) {
const { receipt } = props;
const chip = RECEIPT_CHIPS[receipt.state];
return (
<div
data-testid="agent-interface-status-receipt"
className="flex min-w-0 items-center gap-2 px-3 pb-1"
>
<span className="text-muted shrink-0">Last message</span>
<span className="min-w-0 flex-1 truncate">{receipt.preview}</span>
<span className={cn("shrink-0 font-medium", chip.className)}>
{chip.label}
{/* The time the transcript shows on the row. A relative time would go stale: nothing
re-renders the bar while the workspace is idle, so it would read "just now" for good. */}
{receipt.state === "in-transcript" && receipt.timestamp != null && (
<span className="text-muted font-normal">
{" · "}
{formatTimestamp(receipt.timestamp)}
</span>
)}
</span>
{receipt.otherHeldCount > 0 && (
<span className="text-error shrink-0 font-medium">+{receipt.otherHeldCount} held</span>
)}
</div>
);
}

/**
* The transcript's newest rows, one line each. A sibling of the bar in the status section, so
* its 40% cap resolves against the section and the page keeps the rest. column-reverse anchors
* the scroll at the bottom: it follows new rows while the user is at the bottom, with no effect.
*/
function TranscriptPeek(props: { id: string; messages: WorkspaceState["messages"] }) {
const rows = recentTranscriptRows(props.messages, TRANSCRIPT_PEEK_ROWS);
return (
<div
id={props.id}
role="region"
aria-label="Recent transcript"
data-testid="agent-interface-transcript-peek"
className="border-border-light flex max-h-[40%] shrink-0 flex-col-reverse overflow-y-auto border-b text-[11px]"
>
{rows.length === 0 ? (
<div className="text-muted px-3 py-1.5">No messages yet</div>
) : (
<ol className="flex flex-col py-1">
{rows.map((row) =>
row.kind === "boundary" ? (
<li
key={row.id}
className="text-muted before:bg-border-light after:bg-border-light flex items-center gap-2 px-3 py-0.5 before:h-px before:flex-1 after:h-px after:flex-1"
>
{row.label}
</li>
) : (
<li key={row.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-2 px-3 py-0.5">
<span
className={cn(
"max-w-[12rem] truncate font-medium",
row.kind === "error" ? "text-error" : "text-muted"

Check warning on line 430 in src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: === -> !==

No unit test failed when TranscriptPeek had === -> !==. Add a test that tells them apart, or say why they are equivalent.
)}
>
{row.label}
</span>
<span className="truncate">{row.preview}</span>
</li>
)
)}
</ol>
)}
</div>
);
}

function StatusPage(props: StatusViewProps) {
const { workspaceId } = props;
const read = useStatusArtifactRead(workspaceId);
Expand All @@ -148,7 +450,7 @@
const interactions = useArtifactInteractions(workspaceId, STATUS_TARGET);
const sectionRef = useComposerFocusOnUnmount(props.composerApiRef);
const vimEnabled = useUserPreferences(
(preferences) => preferences.appearance?.vimEnabled === true

Check warning on line 453 in src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: true -> false

No unit test failed when StatusPage had true -> false. Add a test that tells them apart, or say why they are equivalent.
);

return (
Expand All @@ -168,6 +470,7 @@
interactions.handleKeyDown(e);
}}
>
<AgentInterfaceStatusBar workspaceId={workspaceId} />
{interactions.strip}
<div className="min-h-0 flex-1 overflow-auto">
{read == null ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ describe("ArtifactViewer renderers", () => {
const globalKeyboardEvent = globalThis.KeyboardEvent;
globalThis.KeyboardEvent = window.KeyboardEvent;
try {
const postChord = (chord: "sidebar" | "agentInterface") =>
const postChord = (chord: "sidebar" | "agentInterface" | "peek") =>
act(() => {
const event = new window.Event("message");
Object.defineProperty(event, "data", {
Expand All @@ -483,8 +483,9 @@ describe("ArtifactViewer renderers", () => {
activation = { isActive: true };
postChord("sidebar");
expect(replayed).toEqual([sidebarKey]);
// Only the main pane's status page forwards the view switch: here the page's script posted it.
// Only the main pane's status page forwards its chords: here the page's script posted them.
postChord("agentInterface");
postChord("peek");
expect(replayed).toEqual([sidebarKey]);

// A frame that stopped rendering keeps focus in the browser; that focus does not count.
Expand Down
Loading
Loading