diff --git a/src/browser/components/TodoList/TodoList.tsx b/src/browser/components/TodoList/TodoList.tsx index 2dd25907fd..3467d6965a 100644 --- a/src/browser/components/TodoList/TodoList.tsx +++ b/src/browser/components/TodoList/TodoList.tsx @@ -68,6 +68,13 @@ function calculateTextOpacity( interface TodoListProps { todos: TodoItem[]; + /** + * Orientation of the list: + * - "vertical" (default): stacked rows — used by the pinned list and tool history. + * - "horizontal": a single horizontally-scrolling row of compact chips, used by the + * immersive review status bar to keep the plan to one row of vertical space. + */ + layout?: "vertical" | "horizontal"; } function getStatusIcon(status: TodoItem["status"]): React.ReactNode { @@ -87,7 +94,8 @@ function getStatusIcon(status: TodoItem["status"]): React.ReactNode { * - TodoToolCall (in expanded tool history) * - PinnedTodoList (pinned at bottom of chat) */ -export const TodoList: React.FC = ({ todos }) => { +export const TodoList: React.FC = ({ todos, layout = "vertical" }) => { + const isHorizontal = layout === "horizontal"; // Count completed and pending items for fade effects const completedCount = todos.filter((t) => t.status === "completed").length; const pendingCount = todos.filter((t) => t.status === "pending").length; @@ -95,7 +103,16 @@ export const TodoList: React.FC = ({ todos }) => { let pendingIndex = 0; return ( -
+
{todos.map((todo, index) => { const currentCompletedIndex = todo.status === "completed" ? completedIndex++ : undefined; const currentPendingIndex = todo.status === "pending" ? pendingIndex++ : undefined; @@ -111,14 +128,19 @@ export const TodoList: React.FC = ({ todos }) => { return (
-
{getStatusIcon(todo.status)}
+
+ {getStatusIcon(todo.status)} +
(); +const subscribers = new Map void>>(); + +function getSubscribers(workspaceId: string): Set<() => void> { + let set = subscribers.get(workspaceId); + if (!set) { + set = new Set(); + subscribers.set(workspaceId, set); + } + return set; +} + +function buildState(workspaceId: string, input: SeedInput): WorkspaceState { + return { + name: workspaceId, + messages: [], + queuedMessage: null, + canInterrupt: input.canInterrupt ?? false, + isCompacting: false, + isStreamStarting: input.isStarting ?? false, + awaitingUserQuestion: input.awaitingUserQuestion ?? false, + loading: false, + isHydratingTranscript: false, + hasOlderHistory: false, + loadingOlderHistory: false, + muxMessages: [], + currentModel: null, + currentThinkingLevel: null, + recencyTimestamp: null, + todos: input.todos, + loadedSkills: [], + skillLoadErrors: [], + agentStatus: undefined, + lastAbortReason: null, + pendingStreamStartTime: null, + pendingStreamModel: null, + runtimeStatus: null, + autoRetryStatus: null, + }; +} + +function seed(workspaceId: string, input: SeedInput): void { + seeds.set(workspaceId, buildState(workspaceId, input)); +} + +function notify(workspaceId: string): void { + getSubscribers(workspaceId).forEach((cb) => cb()); +} + +/** + * Replace the cached state with a NEW object reference whose watched fields + * (todos/canInterrupt/isStreamStarting/awaitingUserQuestion) are byte-identical + * (todos keeps the same array ref), then notify subscribers — models an + * unrelated WorkspaceState bump such as a streamed message arriving. + */ +function bumpUnrelated(workspaceId: string): void { + const prev = seeds.get(workspaceId); + if (!prev) throw new Error(`Missing seed for ${workspaceId}`); + seeds.set(workspaceId, { ...prev, name: `${prev.name}-bump`, messages: [...prev.messages] }); + notify(workspaceId); +} + +/** Patch a watched field on the cached state and notify subscribers. */ +function patchState(workspaceId: string, patch: Partial): void { + const prev = seeds.get(workspaceId); + if (!prev) throw new Error(`Missing seed for ${workspaceId}`); + seeds.set(workspaceId, { ...prev, ...patch }); + notify(workspaceId); +} + +// Minimal fake exposing only the store methods the bar calls. Cast through +// unknown because the bar uses just this slice of the WorkspaceStore surface. +const fakeStore = { + hasRegisteredWorkspace: (id: string) => seeds.has(id), + subscribeKey: (id: string, cb: () => void) => { + const set = getSubscribers(id); + set.add(cb); + return () => { + set.delete(cb); + }; + }, + getWorkspaceState: (id: string) => { + const state = seeds.get(id); + if (!state) throw new Error(`Missing seed for ${id}`); + return state; + }, +} as unknown as WorkspaceStore; + +function renderBar(workspaceId: string): RenderResult { + return render(); +} + +describe("ImmersiveReviewAgentStatusBar", () => { + // installDom snapshots + fully restores all DOM globals (window, document, + // localStorage, CustomEvent, …), so this file stays hermetic even when other + // test files in the same process leave the globals in a partial state. + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + globalThis.localStorage.clear(); + seeds.clear(); + subscribers.clear(); + + spyOn(WorkspaceStoreModule, "useWorkspaceStoreRaw").mockReturnValue(fakeStore); + }); + + afterEach(() => { + cleanup(); + mock.restore(); + cleanupDom?.(); + cleanupDom = null; + seeds.clear(); + subscribers.clear(); + }); + + const todos: TodoItem[] = [ + { content: "Wire up status bar", status: "in_progress" }, + { content: "Add tests", status: "pending" }, + ]; + + test("renders the TODO plan (expanded) when todos exist", () => { + seed("ws-todos", { todos }); + const result = renderBar("ws-todos"); + // The horizontal TodoList strip is visible by default. + expect(result.getByText("Wire up status bar")).toBeTruthy(); + expect(result.getByText("Add tests")).toBeTruthy(); + // Summary reflects the counts. + expect(result.getByText(/1 in progress/)).toBeTruthy(); + }); + + test("renders nothing when there is no plan and no active stream", () => { + seed("ws-idle", { todos: [] }); + const result = renderBar("ws-idle"); + expect(result.container.firstChild).toBeNull(); + }); + + test("shows a streaming chip even when there is no plan yet", () => { + seed("ws-streaming", { todos: [], canInterrupt: true }); + const result = renderBar("ws-streaming"); + expect(result.getByText("Streaming…")).toBeTruthy(); + // No plan means no TODO summary / expand toggle. + expect(result.queryByText("TODO")).toBeNull(); + }); + + test("shows a starting chip during pre-stream startup", () => { + seed("ws-starting", { todos: [], isStarting: true }); + const result = renderBar("ws-starting"); + expect(result.getByText("Starting…")).toBeTruthy(); + }); + + test("surfaces a prominent prompt when the agent awaits a question", () => { + seed("ws-question", { todos, awaitingUserQuestion: true }); + const result = renderBar("ws-question"); + expect(result.getByText("Mux has a question")).toBeTruthy(); + // The question chip wins over the streaming label. + expect(result.queryByText("Streaming…")).toBeNull(); + }); + + test("collapsing hides the plan and the collapsed choice persists across remounts", () => { + const workspaceId = "ws-collapse"; + seed(workspaceId, { todos }); + const first = renderBar(workspaceId); + + // Plan is expanded by default; collapsing hides the horizontal strip. + expect(first.getByText("Wire up status bar")).toBeTruthy(); + fireEvent.click(first.getByRole("button", { name: /todo/i })); + expect(first.queryByText("Wire up status bar")).toBeNull(); + + // Remounting the bar for the same workspace must restore the collapsed + // choice (asserted via the user-visible re-render rather than reading the + // raw localStorage value, which keeps this resilient to the test-runner's + // shared-global quirks). + first.unmount(); + const second = renderBar(workspaceId); + expect(second.queryByText("Wire up status bar")).toBeNull(); + + // Re-expanding brings the plan back, proving the toggle round-trips. + fireEvent.click(second.getByRole("button", { name: /todo/i })); + expect(second.getByText("Wire up status bar")).toBeTruthy(); + }); + + test("does not re-render on unrelated workspace-state bumps, only on watched fields", () => { + const workspaceId = "ws-stable"; + seed(workspaceId, { todos, canInterrupt: true }); + + let commits = 0; + const result = render( + { + commits += 1; + }} + > + + + ); + expect(result.getByText("Streaming…")).toBeTruthy(); + + // An unrelated state bump (new WorkspaceState ref, same watched fields) + // must NOT re-render the bar — this is the leaf-subscription guarantee that + // keeps streamed-message churn off the immersive diff's sibling bar. + const committedAfterMount = commits; + act(() => { + bumpUnrelated(workspaceId); + bumpUnrelated(workspaceId); + }); + expect(commits).toBe(committedAfterMount); + + // Changing a field the bar actually reads DOES re-render it. + act(() => { + patchState(workspaceId, { awaitingUserQuestion: true }); + }); + expect(commits).toBeGreaterThan(committedAfterMount); + expect(result.getByText("Mux has a question")).toBeTruthy(); + }); +}); diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.tsx new file mode 100644 index 0000000000..9ba9b50aa2 --- /dev/null +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewAgentStatusBar.tsx @@ -0,0 +1,205 @@ +/** + * ImmersiveReviewAgentStatusBar — pinned to the top of full-screen immersive + * review. While the user reviews code in immersive mode the chat transcript and + * composer-adjacent status (TODO plan, streaming barrier) are hidden behind the + * opaque overlay, so a common workflow — reviewing while waiting on the agent — + * loses all signal about what the agent is doing. + * + * This bar restores that signal without leaving immersive: + * - the agent's TODO plan as a single horizontal strip (collapsible, + * persisted) so it reserves minimal review height, and + * - live streaming status (starting / streaming / awaiting a question). + * + * Design notes: + * - Subscriptions live in this leaf component (not in ImmersiveReviewView) so + * per-token streaming/todo churn doesn't re-render the large diff tree. + * - Flash-free: the streaming chip is gated on the *held* phase from + * useWorkspaceStreamingStatusPhase (150ms), so brief starting<->streaming + * handoffs don't blink. Because TODO plans persist across streams, the bar + * stays mounted between turns and only unmounts once both the held phase + * clears AND there are no todos left to show — no mid-review flicker. + * - Crash-safe: when the workspace isn't registered in the store yet (tests, + * storybook, teardown) the subscriptions fall back to empty/idle instead of + * throwing, so the bar simply renders nothing. + */ + +import React, { useSyncExternalStore } from "react"; +import { ChevronDown, ChevronRight, CircleHelp, List, Loader2 } from "lucide-react"; +import { TodoList } from "@/browser/components/TodoList/TodoList"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { + getWorkspaceStreamingStatusPhase, + useWorkspaceStreamingStatusPhase, +} from "@/browser/hooks/useWorkspaceStreamingStatusPhase"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { getImmersiveReviewAgentBarExpandedKey } from "@/common/constants/storage"; +import { cn } from "@/common/lib/utils"; +import type { TodoItem } from "@/common/types/tools"; + +// Stable empty-plan reference for the unregistered case (tests, storybook, +// teardown). Module-level so the `todos` snapshot stays referentially stable +// and useSyncExternalStore's "getSnapshot should be cached" guard doesn't loop. +const EMPTY_TODOS: TodoItem[] = []; + +interface ImmersiveReviewAgentStatusBarProps { + workspaceId: string; +} + +export const ImmersiveReviewAgentStatusBar: React.FC = ({ + workspaceId, +}) => { + const [expanded, setExpanded] = usePersistedState( + getImmersiveReviewAgentBarExpandedKey(workspaceId), + true + ); + + // Subscribe to each field this bar uses as its OWN snapshot rather than + // returning the whole WorkspaceState object. getWorkspaceState is version- + // cached, so its reference changes on EVERY state bump (e.g. each streamed + // message) — returning it wholesale would re-render the bar on every token. + // Per-field selectors keep the bar stable: primitives compare by value, and + // `todos` keeps a stable reference from the aggregator (same basis as + // PinnedTodoList reading only `.todos`). + const workspaceStore = useWorkspaceStoreRaw(); + const subscribe = (callback: () => void) => + workspaceStore.hasRegisteredWorkspace(workspaceId) + ? workspaceStore.subscribeKey(workspaceId, callback) + : () => undefined; + const todos = useSyncExternalStore(subscribe, () => + workspaceStore.hasRegisteredWorkspace(workspaceId) + ? workspaceStore.getWorkspaceState(workspaceId).todos + : EMPTY_TODOS + ); + const canInterrupt = useSyncExternalStore(subscribe, () => + workspaceStore.hasRegisteredWorkspace(workspaceId) + ? workspaceStore.getWorkspaceState(workspaceId).canInterrupt + : false + ); + // Sidebar derives `isStarting` directly from `isStreamStarting`. + const isStarting = useSyncExternalStore(subscribe, () => + workspaceStore.hasRegisteredWorkspace(workspaceId) + ? workspaceStore.getWorkspaceState(workspaceId).isStreamStarting + : false + ); + const awaitingUserQuestion = useSyncExternalStore(subscribe, () => + workspaceStore.hasRegisteredWorkspace(workspaceId) + ? workspaceStore.getWorkspaceState(workspaceId).awaitingUserQuestion + : false + ); + + // Held phase keeps the streaming chip steady across the starting->streaming + // handoff so it doesn't blink out for a frame between adjacent state settles. + const phase = getWorkspaceStreamingStatusPhase({ canInterrupt, isStarting }); + const phaseSource = canInterrupt ? "streaming" : isStarting ? "pre-stream" : null; + const { displayPhase } = useWorkspaceStreamingStatusPhase(phase, phaseSource); + + const hasTodos = todos.length > 0; + const isStreamingStatusVisible = displayPhase !== null || awaitingUserQuestion; + + // Nothing to surface: don't reserve any vertical space in the review viewport. + if (!hasTodos && !isStreamingStatusVisible) { + return null; + } + + const inProgressCount = todos.filter((todo) => todo.status === "in_progress").length; + const pendingCount = todos.filter((todo) => todo.status === "pending").length; + const completedCount = todos.length - inProgressCount - pendingCount; + const summaryParts: string[] = []; + if (inProgressCount > 0) { + summaryParts.push(`${inProgressCount} in progress`); + } + if (pendingCount > 0) { + summaryParts.push(`${pendingCount} pending`); + } + if (summaryParts.length === 0 && hasTodos) { + summaryParts.push(`${completedCount} completed`); + } + + // role=status + aria-live so screen readers announce streaming/question + // transitions while the user is focused on the diff. + const statusChip = (() => { + if (awaitingUserQuestion) { + return ( + + + ); + } + if (displayPhase === "starting") { + return ( + + + ); + } + if (displayPhase === "streaming") { + return ( + + + ); + } + return null; + })(); + + // `alignEnd` pushes the chip to the right with the TODO summary on its left; + // without a plan there's nothing on the left, so the chip is left-aligned + // instead of floating alone on the far right of an otherwise-empty bar. + const renderStatusChip = (alignEnd: boolean) => ( +
+ {statusChip} +
+ ); + + return ( +
+ {hasTodos ? ( + + ) : ( + // Streaming/question only (no plan yet): static row, nothing to expand. + // Chip is left-aligned here so it reads as a status label rather than + // hugging the far right of an otherwise-empty bar. +
+ {renderStatusChip(false)} +
+ )} + {hasTodos && expanded && ( + // Horizontal strip: one row tall (the list scrolls sideways), so the + // plan costs minimal vertical space in the review viewport. + + )} +
+ ); +}; diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx index 586b44160e..8481b727c3 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.stories.tsx @@ -9,6 +9,9 @@ import { ExperimentsProvider } from "@/browser/contexts/ExperimentsContext"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import { createMockORPCClient } from "@/browser/stories/mocks/orpc"; import { createReview } from "@/browser/stories/helpers/reviews"; +import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import type { TodoItem } from "@/common/types/tools"; import type { DiffHunk, Review } from "@/common/types/review"; import { extractAllHunks, parseDiff } from "@/common/utils/git/diffParser"; import { @@ -265,6 +268,92 @@ function createImmersiveStoryClient(): APIClient { }); } +interface AgentStatusSeed { + /** + * TODO plan the agent has written (drives the horizontal TODO strip + + * summary). Omit (or pass an empty array) to model "streaming before any plan + * is written", where the bar shows only the streaming chip. + */ + todos?: TodoItem[]; + /** + * When true, leaves the seeded stream open so the bar shows the live + * "Streaming…" chip. When false the stream is left un-started, so only the + * persisted (incomplete) plan shows. + */ + streaming?: boolean; +} + +/** + * Register a workspace in the singleton WorkspaceStore and push a `todo_write` + * tool call through its aggregator so the immersive view's + * ImmersiveReviewAgentStatusBar has real plan + streaming state to render. + * Seeds exactly once per workspace (addWorkspace is idempotent and the stream + * events are replayed only when the aggregator is freshly created), and runs + * during render before the child subscribes so the bar paints populated on the + * first frame (flash-free for Chromatic). + */ +function seedAgentStatus( + workspaceStore: ReturnType, + workspaceId: string, + seed: AgentStatusSeed +): void { + const alreadyRegistered = workspaceStore.hasRegisteredWorkspace(workspaceId); + workspaceStore.addWorkspace({ + id: workspaceId, + name: workspaceId, + projectName: "story-project", + projectPath: "/story/project", + namedWorkspacePath: "/story/workspace", + createdAt: new Date(0).toISOString(), + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }); + if (alreadyRegistered) { + return; + } + + const aggregator = workspaceStore.getAggregator(workspaceId); + if (!aggregator) { + return; + } + + const messageId = `${workspaceId}-stream`; + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId, + messageId, + historySequence: 1, + model: "anthropic:claude-sonnet-4", + startTime: 0, + }); + // Only push a plan when the seed has one; omitting it models "streaming + // before any TODO is written" so the bar renders the chip-only state. + if (seed.todos && seed.todos.length > 0) { + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId, + messageId, + toolCallId: `${workspaceId}-todo`, + toolName: "todo_write", + args: { todos: seed.todos }, + tokens: 10, + timestamp: 1, + }); + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId, + messageId, + toolCallId: `${workspaceId}-todo`, + toolName: "todo_write", + result: { success: true }, + timestamp: 2, + }); + } + if (seed.streaming !== true) { + // Collapse the active stream so the chip clears; incomplete todos persist. + aggregator.clearActiveStreams(); + } +} + const ImmersiveStoryShell: FC<{ client: APIClient; children: ReactNode }> = ({ client, children, @@ -289,6 +378,15 @@ interface ImmersiveReviewStoryProps { assistedHunkIds?: ReadonlySet; /** Per-hunk agent comments — drives the immersive assisted-review banner. */ assistedCommentByHunkId?: Map; + /** Whether the Assisted worklist filter is active — drives the header badge. */ + assistedOnly?: boolean; + assistedCount?: number; + assistedUnreadCount?: number; + /** + * Seeds the singleton WorkspaceStore so the immersive view's top status bar + * (ImmersiveReviewAgentStatusBar) renders a real TODO plan + streaming chip. + */ + agentStatusSeed?: AgentStatusSeed; } function ImmersiveReviewStory(props: ImmersiveReviewStoryProps) { @@ -300,6 +398,13 @@ function ImmersiveReviewStory(props: ImmersiveReviewStoryProps) { () => new Set(props.initialReadHunkIds ?? []) ); + // Seed agent status synchronously during render (before the child subscribes) + // so the top status bar paints with its plan/stream on the first frame. + const workspaceStore = useWorkspaceStoreRaw(); + if (props.agentStatusSeed) { + seedAgentStatus(workspaceStore, props.workspaceId, props.agentStatusSeed); + } + return ( ); @@ -547,3 +655,109 @@ export const ImmersiveWithAssistedBanner: Story = { ); }, }; + +/** + * Immersive review with the Assisted worklist filter active. Locks in the + * header "Assisted unread/total" badge (Sparkles + review-accent) that tells + * the user the diff is filtered to agent-flagged hunks — the control bar that + * normally hosts this toggle is hidden behind the immersive overlay. + */ +export const ImmersiveWithAssistedMode: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor( + () => { + canvas.getByTestId("immersive-review-view"); + canvas.getByTestId("immersive-assisted-mode-badge"); + }, + { timeout: 10_000 } + ); + }, +}; + +const IMMERSIVE_AGENT_STATUS_WORKSPACE_ID = "ws-review-immersive-agent-status"; +const IMMERSIVE_AGENT_STATUS_TODOS: TodoItem[] = [ + { content: "Audit immersive review layout", status: "completed" }, + { content: "Scope assisted comment to the diff column", status: "completed" }, + { content: "Add the top status bar (TODO + streaming)", status: "in_progress" }, + { content: "Wire up flash-free loading states", status: "pending" }, + { content: "Add Storybook + tests", status: "pending" }, +]; + +/** + * Immersive review with the top agent status bar populated: the agent's TODO + * plan as a horizontal strip plus the live "Streaming…" chip. This is the piece + * that keeps chat status visible while the user reviews code behind the + * full-screen overlay. Seeds the WorkspaceStore so the bar has real state. + */ +export const ImmersiveWithAgentStatusBar: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor( + () => { + canvas.getByTestId("immersive-review-view"); + // The collapsible TODO bar + its horizontal plan strip render. + canvas.getByTestId("immersive-agent-status-bar"); + canvas.getByText("Add the top status bar (TODO + streaming)"); + // Live streaming chip is visible alongside the plan. + canvas.getByText("Streaming…"); + }, + { timeout: 10_000 } + ); + }, +}; + +const IMMERSIVE_STREAMING_NO_TODO_WORKSPACE_ID = "ws-review-immersive-streaming-no-todo"; + +/** + * Immersive review while the agent is streaming but has not written a TODO plan + * yet. Locks in the chip-only state of the status bar: with no plan on the left + * the "Streaming…" chip is left-aligned (reads as a status label) instead of + * floating alone on the far right of an otherwise-empty bar, and the bar stays + * a single row tall. Seeds an open stream with no `todo_write` call. + */ +export const ImmersiveWithStreamingNoTodo: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor( + () => { + canvas.getByTestId("immersive-review-view"); + // The bar renders the streaming chip with no TODO toggle/summary. + canvas.getByTestId("immersive-agent-status-bar"); + canvas.getByText("Streaming…"); + if (canvas.queryByText("TODO")) { + throw new Error("Expected the chip-only status bar with no TODO plan."); + } + }, + { timeout: 10_000 } + ); + }, +}; diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx index 1b99068951..7f9903e2a5 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.test.tsx @@ -213,6 +213,25 @@ describe("ImmersiveReviewView", () => { expect(mockApi.workspace.executeBash).not.toHaveBeenCalled(); }); + test("shows the assisted-mode badge only while the Assisted filter is active", () => { + // Off by default: the badge must not appear when not filtering. + const off = renderImmersiveReview(); + expect(off.queryByTestId("immersive-assisted-mode-badge")).toBeNull(); + cleanup(); + + // On: the header surfaces the badge with the unread/total counts so the + // active filter mode is visible even though the control bar is hidden + // behind the immersive overlay. + const on = renderImmersiveReview({ + assistedOnly: true, + assistedCount: 3, + assistedUnreadCount: 2, + }); + const badge = on.getByTestId("immersive-assisted-mode-badge"); + expect(badge.textContent ?? "").toContain("Assisted"); + expect(badge.textContent ?? "").toContain("2/3"); + }); + test("loads full-file context for an in-budget selected hunk even when another hunk is far away", async () => { const nearHunk = createHunk({ id: "hunk-near", diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx index 81be637836..ffdca2d3ef 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -21,6 +21,7 @@ import { import { cn } from "@/common/lib/utils"; import { SelectableDiffRenderer } from "../../Shared/DiffRenderer"; import { ImmersiveMinimap } from "./ImmersiveMinimap"; +import { ImmersiveReviewAgentStatusBar } from "./ImmersiveReviewAgentStatusBar"; import { buildNewLineNumberToIndexMap, buildOldLineNumberToIndexMap, @@ -92,6 +93,18 @@ interface ImmersiveReviewViewProps { * see in the side panel. */ assistedCommentByHunkId?: Map; + /** + * Whether the "Assisted" filter (show only agent-flagged hunks) is active. + * The control bar that hosts this toggle is hidden behind the immersive + * overlay, so we surface a header badge to keep the active filter mode + * visible. Distinct from the per-hunk assisted banner: this means "the + * worklist filter is on", not "this hunk was flagged". + */ + assistedOnly?: boolean; + /** Total agent-flagged hunks (mirrors the control bar's Assisted count). */ + assistedCount?: number; + /** Agent-flagged hunks still unread (mirrors the control bar's count). */ + assistedUnreadCount?: number; } interface InlineComposerRequest { @@ -1885,6 +1898,25 @@ export const ImmersiveReviewView: React.FC = (props) = )}
)} + {/* Assisted-mode indicator — the control bar that hosts the Assisted + toggle is hidden behind the immersive overlay, so without this the + user has no way to tell the diff is filtered to agent-flagged hunks. + ml-auto anchors it to the row's trailing edge as a mode indicator. */} + {props.assistedOnly === true && ( +
+
+ )} {allHunks.length > 0 && (
= (props) = )}
- {/* Assisted-review banner — surfaces the agent's flag + comment when - the selected hunk is one the agent pinned for review. We render it - between the header and the diff so the focus signal is impossible - to miss after entering immersive mode, where the side-panel cues - aren't visible. */} - {isSelectedAssisted && ( -
-
- )} + {/* Agent status bar — keeps the TODO plan + live streaming status visible + while reviewing, since the chat transcript/composer are hidden behind + the immersive overlay. Self-subscribes so its updates don't re-render + the diff tree; renders nothing when there's no plan and no stream. */} + {/* Unified whole-file diff with hunk overlays + notes sidebar */}
- {/* Avoid top padding here; it reads as a blank block between the controls and diff. */} -
- {props.isLoading && currentFileHunks.length === 0 ? ( -
- Loading diff... + {/* Diff column. The assisted-review banner lives INSIDE this column (not + above the whole body) so the agent's per-hunk comment spans only the + diff width and lines up with the code it refers to — rather than + stretching across the minimap and notes sidebar. */} +
+ {/* Assisted-review banner — surfaces the agent's flag + comment when + the selected hunk is one the agent pinned for review. Pinned to the + top of the diff column so the focus signal is impossible to miss + after entering immersive mode, where the side-panel cues aren't + visible. */} + {isSelectedAssisted && ( +
+
- ) : isReviewComplete ? ( -
-
-
-
-
-

Review complete

-

- You have already reviewed all {reviewedHunkLabel} in this diff. Return to chat - to keep going, or reopen reviewed hunks from the review panel if you want - another pass. -

-
- +
+
+
+

Review complete

+

+ You have already reviewed all {reviewedHunkLabel} in this diff. Return to chat + to keep going, or reopen reviewed hunks from the review panel if you want + another pass. +

+
+ +
-
- ) : currentFileHunks.length === 0 ? ( -
- {activeFilePath ? "No hunks for this file" : "No files to review"} -
- ) : ( -
- {isActiveFileRevealPending && ( -
- Loading file... + ) : currentFileHunks.length === 0 ? ( +
+ {activeFilePath ? "No hunks for this file" : "No files to review"} +
+ ) : ( +
+ {isActiveFileRevealPending && ( +
+ Loading file... +
+ )} +
+
- )} -
-
-
- )} + )} +
{!isReviewComplete && !isTouchExperience && ( diff --git a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx index 25fba12254..83f673291e 100644 --- a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx @@ -2595,6 +2595,9 @@ export const ReviewPanel: React.FC = ({ firstSeenMap={firstSeenMap} assistedHunkIds={assistedHunkIdSet} assistedCommentByHunkId={assistedCommentByHunkId} + assistedOnly={filters.assistedOnly} + assistedCount={assistedHunks.length} + assistedUnreadCount={unreadAssistedInDiff} />, root ); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c6440a2aaf..eced9cc41e 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -636,6 +636,18 @@ export function getReviewImmersiveKey(workspaceId: string): string { return `review-immersive:${workspaceId}`; } +/** + * Get the localStorage key for the immersive-review agent status bar expansion + * state (the TODO plan + streaming-status bar pinned to the top of immersive + * review). Persisted per workspace so a user's collapse choice survives + * navigation. Mirrors getPinnedTodoExpandedKey — a transient UI preference, so + * intentionally NOT copied on fork. + * Format: "review-immersive-agentbar-expanded:{workspaceId}" + */ +export function getImmersiveReviewAgentBarExpandedKey(workspaceId: string): string { + return `review-immersive-agentbar-expanded:${workspaceId}`; +} + /** * Get the localStorage key for auto-compaction enabled preference per workspace * Format: "autoCompaction:enabled:{workspaceId}" @@ -704,6 +716,7 @@ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getPendingWorkspaceSendErrorKey, getPlanContentKey, // Cache only, no need to preserve on fork getPostCompactionStateKey, // Cache only, no need to preserve on fork + getImmersiveReviewAgentBarExpandedKey, // Transient UI pref; clean up on removal, don't carry on fork ]; /**