From a26eceba283ce3c02645ab4ef516be4ae2241da8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:12:14 +0000 Subject: [PATCH 01/18] feat(artifacts): add a status bar to the Agent interface status view Shows the agent's live activity, the last message's delivery state, a collapsible transcript peek and Start here from the agent's last reply, above the sandboxed status page. --- docs/workspaces/artifacts.mdx | 2 + .../AgentInterfaceStatusView.test.tsx | 136 +++++++++- .../ArtifactsTab/AgentInterfaceStatusView.tsx | 213 +++++++++++++++- .../agentInterfaceStatusBar.test.ts | 237 ++++++++++++++++++ .../ArtifactsTab/agentInterfaceStatusBar.ts | 237 ++++++++++++++++++ .../stories/App.agentInterface.stories.tsx | 103 +++++++- src/common/constants/storage.ts | 4 + .../builtInSkillContent.generated.ts | 2 + 8 files changed, 927 insertions(+), 7 deletions(-) create mode 100644 src/browser/features/RightSidebar/ArtifactsTab/agentInterfaceStatusBar.test.ts create mode 100644 src/browser/features/RightSidebar/ArtifactsTab/agentInterfaceStatusBar.ts diff --git a/docs/workspaces/artifacts.mdx b/docs/workspaces/artifacts.mdx index 34be8790ed8..10929bd3350 100644 --- a/docs/workspaces/artifacts.mdx +++ b/docs/workspaces/artifacts.mdx @@ -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** opens the newest transcript rows below the bar, and Xum remembers whether it is open. **Start here** 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. + ### 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. diff --git a/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.test.tsx b/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.test.tsx index b99e3be6d8c..3e9a80a86f1 100644 --- a/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.test.tsx +++ b/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.test.tsx @@ -1,13 +1,15 @@ // Bootstrap Happy DOM before react-dom evaluates (see MemoryTab.test.tsx). import "../../../../../tests/ui/dom"; -import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"; import { act, cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; import { useRef, useState, type ReactNode } from "react"; import { installDom } from "../../../../../tests/ui/dom"; import { APIProvider, type APIClient } from "@/browser/contexts/API"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; -import { workspaceStore } from "@/browser/stores/WorkspaceStore"; +import * as WorkspaceStoreModule from "@/browser/stores/WorkspaceStore"; +import { workspaceStore, type WorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { overlayWorkspaceStoreRaw } from "@/browser/stores/workspaceStoreTestOverlay"; import { KEYBINDS } from "@/browser/utils/ui/keybinds"; import { createTestApiClient, @@ -18,6 +20,7 @@ import { import { AGENT_INTERFACE_STATUS_ARTIFACT } from "@/common/constants/artifacts"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { ArtifactListing, ArtifactReadResult } from "@/common/orpc/schemas/artifacts"; +import type { DisplayedMessage } from "@/common/types/message"; import { AgentInterfaceStatusView } from "./AgentInterfaceStatusView"; import { ArtifactsPanel } from "./ArtifactsPanel"; import { useMainPaneAgentInterfaceView } from "./agentInterfaceView"; @@ -121,6 +124,99 @@ function Wrapper(props: { children: ReactNode }) { ); } +/** Workspace states the status bar reads, for workspaces the real store never registered. */ +const seededStates = new Map(); +const restoreSpies: Array<() => void> = []; + +function seedWorkspace(workspaceId: string, patch: Partial) { + if (restoreSpies.length === 0) { + const realStore = WorkspaceStoreModule.useWorkspaceStoreRaw(); + const store = overlayWorkspaceStoreRaw(realStore, { + hasRegisteredWorkspace: (id: string) => + seededStates.has(id) || realStore.hasRegisteredWorkspace(id), + getWorkspaceState: (id: string) => seededStates.get(id) ?? realStore.getWorkspaceState(id), + }); + const storeSpy = spyOn(WorkspaceStoreModule, "useWorkspaceStoreRaw").mockReturnValue(store); + // Start Here's transcript barrier: a seeded workspace has a complete replay. + const realCaughtUp = workspaceStore.isWorkspaceTranscriptCaughtUp; + const barrierSpy = spyOn(workspaceStore, "isWorkspaceTranscriptCaughtUp").mockImplementation( + (id: string) => seededStates.has(id) || realCaughtUp(id) + ); + restoreSpies.push( + () => storeSpy.mockRestore(), + () => barrierSpy.mockRestore() + ); + } + seededStates.set(workspaceId, { + name: workspaceId, + messages: [], + queuedMessage: null, + heldInputs: [], + canInterrupt: false, + isCompacting: false, + isStreamStarting: false, + awaitingUserQuestion: false, + loading: false, + isTranscriptCaughtUp: true, + transcriptReplayFailed: false, + isHydratingTranscript: false, + isTranscriptStale: false, + isIncrementalCatchUp: false, + hasOlderHistory: false, + loadingOlderHistory: false, + muxMessages: [], + currentModel: null, + currentThinkingLevel: null, + recencyTimestamp: null, + todos: [], + loadedSkills: [], + skillLoadErrors: [], + agentStatus: undefined, + activeWorkflowRunCount: 0, + activeBashMonitorCount: 0, + lastAbortReason: null, + pendingStreamStartTime: null, + pendingStreamModel: null, + runtimeStatus: null, + autoRetryStatus: null, + ...patch, + }); +} + +/** A turn mid-way: the user's prompt, the agent's reply so far, and a bash call still running. */ +const MID_TURN: DisplayedMessage[] = [ + { + type: "user", + id: "u1", + historyId: "u1", + content: "Run the slow check", + historySequence: 1, + timestamp: Date.now(), + }, + { + type: "assistant", + id: "a1", + historyId: "a1", + content: "Starting the check now.", + historySequence: 2, + isStreaming: false, + isPartial: false, + isCompacted: false, + isIdleCompacted: false, + }, + { + type: "tool", + id: "t1", + historyId: "t1", + toolCallId: "call1", + toolName: "bash", + args: { script: "sleep 20", display_name: "Slow check" }, + status: "executing", + isPartial: false, + historySequence: 3, + }, +]; + /** The main pane's wiring: the poll and shortcut hook, then the status view it selects. */ function MainPane(props: { workspaceId: string }) { const view = useMainPaneAgentInterfaceView(props.workspaceId); @@ -229,6 +325,8 @@ describe("Agent interface status view", () => { afterEach(() => { cleanup(); + for (const restore of restoreSpies.splice(0)) restore(); + seededStates.clear(); resetTestExperiments(); cleanupDom?.(); cleanupDom = null; @@ -508,4 +606,38 @@ describe("Agent interface status view", () => { expect(view.queryByTestId("agent-interface-status-view") === null).toBe(true) ); }); + test("the status bar names the running tool and its transcript peek opens and closes", async () => { + seedWorkspace("ws-bar-busy", { messages: MID_TURN, canInterrupt: true }); + const view = render(, { wrapper: Wrapper }); + await waitFor(() => expect(shownPage(view)).toContain("

AAAA

")); + const bar = view.getByTestId("agent-interface-status-bar"); + expect(within(bar).getByRole("status").textContent).toContain("Slow check"); + // Start Here would rewrite the context under the running turn. + expect(within(bar).getByRole("button", { name: "Start here" }).hasAttribute("disabled")).toBe( + true + ); + + const toggle = within(bar).getByRole("button", { name: "Show transcript" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(toggle); + const peek = view.getByTestId("agent-interface-transcript-peek"); + expect(toggle.getAttribute("aria-controls")).toBe(peek.id); + expect(peek.textContent).toContain("Run the slow check"); + expect(peek.textContent).toContain("Starting the check now."); + fireEvent.click(within(bar).getByRole("button", { name: "Hide transcript" })); + expect(view.queryByTestId("agent-interface-transcript-peek")).toBeNull(); + }); + + test("Start here from the status bar opens the Start Here confirmation once the turn ends", async () => { + seedWorkspace("ws-bar-idle", { messages: MID_TURN.slice(0, 2) }); + const view = render(, { wrapper: Wrapper }); + await waitFor(() => expect(shownPage(view)).toContain("

AAAA

")); + const startHere = within(view.getByTestId("agent-interface-status-bar")).getByRole("button", { + name: "Start here", + }); + expect(startHere.hasAttribute("disabled")).toBe(false); + fireEvent.click(startHere); + const dialog = await within(document.body).findByRole("dialog"); + expect(within(dialog).getByText("Start Here")).toBeTruthy(); + }); }); diff --git a/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx b/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx index 9f6a6b62b50..e07e1e62065 100644 --- a/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx +++ b/src/browser/features/RightSidebar/ArtifactsTab/AgentInterfaceStatusView.tsx @@ -1,9 +1,25 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { CircleAlert, CircleDot, 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 { useWorkspaceStoreRaw, type WorkspaceState } from "@/browser/stores/WorkspaceStore"; +import { formatRelativeTime } from "@/browser/utils/ui/dateTime"; +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, + recentTranscriptRows, + type LastMessageReceiptState, + type StatusActivity, +} from "./agentInterfaceStatusBar"; import { readStatusPage, recordStatusPageRead, @@ -140,6 +156,198 @@ function askBeforeEverySend( 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 = { + 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