diff --git a/AGENTS.md b/AGENTS.md
index 6d97f4dfd9..97b67d9cc1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -258,6 +258,24 @@ scripts build the required E2E bridge before running Playwright.
See [TESTING.md](TESTING.md) for the full multi-agent E2E guide.
+### Never assert a DOM element against `null` with `assert.equal`
+
+In the desktop jsdom tests (`desktop/src/**/*.test.mjs`), write
+
+```js
+assert.ok(container.querySelector(sel) === null, "no header row");
+```
+
+**not** `assert.equal(container.querySelector(sel), null, ...)`. Both pass
+identically, but when the `assert.equal` form *fails*, node serializes the
+matched element and its entire subtree to build a diff. On a real transcript
+that exhausts memory and the runner dies with SIGKILL after ~100s instead of
+printing the assertion message — so a genuine regression is unreadable and
+looks like a hang or an OOM in unrelated code. The `assert.ok(x === null)` form
+fails in milliseconds with the message you wrote.
+
+Comparing `getAttribute(...)` to `null` is fine — attributes are strings.
+
### PR Screenshots
> **Do NOT use `buzz upload`, the relay media endpoint, or any third-party
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 4c2b6cd7fc..39b57e800f 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -97,6 +97,8 @@ export default defineConfig({
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/thread-focus-mode.spec.ts",
+ "**/agent-activity-cover.spec.ts",
+ "**/agent-activity-cover-screenshots.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/reminder-click-repro.spec.ts",
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 468435e15e..4d64a1dd5a 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -75,7 +75,7 @@ import {
import { useDueReminderBadgeCount } from "@/features/reminders/hooks";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
-import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest";
+import { requestCoverDrawerClose } from "@/features/channels/coverDrawerCloseRequest";
import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
@@ -846,7 +846,7 @@ export function AppShell() {
addCommunityDialog.onOpenChange
}
onNewMessage={goNewMessage}
- onBackgroundClick={requestFocusedThreadClose}
+ onBackgroundClick={requestCoverDrawerClose}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx
index 424837a068..5a3af82346 100644
--- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx
@@ -52,10 +52,13 @@ export function CompactMessageSummary({
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
- const shouldClampBubble = !isCompactPreview;
+ const isConversation = variant === "conversation";
+ // Focus mode is a reading view, so sent messages remain fully visible there.
+ // The default activity surface keeps the compact clamp.
+ const shouldClampBubble = !isCompactPreview && !isConversation;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
- const canOpenMessage = shouldClampBubble && messageLink !== null;
+ const canOpenMessage = !isCompactPreview && messageLink !== null;
const mutedTone = compactSummaryTone();
const avatarClassName = cn(
"mr-2 mt-1 shrink-0",
@@ -135,14 +138,20 @@ export function CompactMessageSummary({
testId="transcript-agent-sent-avatar"
/>
)}
-
+
0;
const hasResult = item.result.trim().length > 0;
const canonicalToolName = item.buzzToolName ?? item.toolName;
@@ -57,7 +59,11 @@ export function ToolItem({
[],
);
- if (compactSummary.presentation === "message") {
+ // Message presentations keep their readable bubble whenever they are standalone.
+ // The conversation grouping keeps message sends standalone rather than placing
+ // them on a work block rail; this guard preserves the muted row if another
+ // transcript composition explicitly embeds one in a rail.
+ if (compactSummary.presentation === "message" && !insideWorkBlockRail) {
return (
- );
-}
-
const TranscriptItemView = React.memo(function TranscriptItemView({
agentAvatarUrl,
agentName,
diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlock.orphaned.test.mjs b/desktop/src/features/agents/ui/AgentSessionWorkBlock.orphaned.test.mjs
new file mode 100644
index 0000000000..23e0e07864
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.orphaned.test.mjs
@@ -0,0 +1,630 @@
+/**
+ * Work-block behaviour for work that was abandoned mid-flight, the rail's
+ * per-kind presentation, and the re-render cost a streaming block must not pay.
+ * The live/finished contracts live in `AgentSessionWorkBlock.test.mjs`; the
+ * shared rig is `AgentSessionWorkBlockTestRig.mjs`.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ noteStep,
+ relayStep,
+ renderBlock,
+ step,
+ thoughtStep,
+} from "./AgentSessionWorkBlockTestRig.mjs";
+
+// ── Orphaned work ────────────────────────────────────────────────────────────
+
+/**
+ * Reopened history must not present as work in progress.
+ *
+ * A tool's `executing` status is written when the step starts and never revised
+ * if the agent dies first, so an abandoned step keeps it forever. In this block
+ * that status is not a per-row detail — one `running` entry makes the whole
+ * block active, which suppresses the folded summary line, keeps the rail
+ * expanded and pulses a bullet. Scrolling back to a crashed turn therefore
+ * showed the reader live work indefinitely (Codex P2 on #6536).
+ */
+test("a block whose running step has no live session folds like finished work", async () => {
+ const view = await renderBlock(
+ [step("a"), step("b", { status: "executing", completedAt: null })],
+ { liveTurnId: null, streamingItemId: null },
+ );
+
+ const summary = view.summary();
+ assert.ok(
+ summary,
+ "history gets its folded summary line — the orphaned status must not suppress it",
+ );
+ assert.match(
+ summary.textContent,
+ /2 steps$/,
+ "an abandoned step is not known to have failed, so the count stays neutral",
+ );
+ assert.equal(await view.settleToStepCount(0), 0, "the rail folds away");
+ // Then OPEN it and look. Asserting "nothing pulses" on the folded block would
+ // be vacuous: the fold unmounts every row, so no glyph exists to carry a
+ // pulse class and the assertion would hold against a build where abandoned
+ // steps pulse forever — the exact Codex finding. The reader's complaint is
+ // about what they see when they scroll back and expand, so that is where the
+ // assertion belongs.
+ await view.expand();
+ assert.equal(view.stepCount(), 2, "the expanded rail has rows to inspect");
+ assert.deepEqual(
+ view.glyphStates(),
+ ["settled", "settled"],
+ "the abandoned step reads as settled, not running",
+ );
+ assert.deepEqual(
+ view.pulseStates(),
+ [],
+ "nothing pulses when nothing is running",
+ );
+});
+
+test("the same items still hold the block open while the turn is live", async () => {
+ // The paired half: this is what makes the gate meaningful rather than a
+ // blanket "never trust executing". Identical items, live turn.
+ const view = await renderBlock(
+ [step("a"), step("b", { status: "executing", completedAt: null })],
+ { liveTurnId: "turn-1", streamingItemId: null },
+ );
+
+ assert.equal(view.summary(), null, "a live block has no folded line");
+ assert.equal(view.stepCount(), 2, "the rail stays open");
+ assert.deepEqual(view.glyphStates(), ["settled", "running"]);
+ assert.deepEqual(view.pulseStates(), ["running"], "live work pulses");
+});
+
+test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => {
+ // The reason the signal is a turn id and not a boolean: a restarted agent is
+ // live, but not on this turn, and a global flag would keep this step spinning.
+ const view = await renderBlock(
+ [step("a"), step("b", { status: "executing", completedAt: null })],
+ { liveTurnId: "turn-2", streamingItemId: null },
+ );
+
+ assert.ok(
+ view.summary(),
+ "this turn is history even though the agent is busy",
+ );
+ assert.equal(await view.settleToStepCount(0), 0);
+ // Expanded, for the same reason as above: a folded rail has no glyph to pulse,
+ // so the negative has to be taken on rows that exist.
+ await view.expand();
+ assert.deepEqual(view.glyphStates(), ["settled", "settled"]);
+ assert.deepEqual(view.pulseStates(), []);
+});
+
+test("a block live when the session ends folds instead of spinning forever", async () => {
+ const { act } = await import("@testing-library/react");
+ // The bug as the reader meets it live: the agent dies mid-step, so the item
+ // never reaches a terminal status and the only thing that changes is that no
+ // turn is live any more.
+ const live = [
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ];
+ const view = await renderBlock(live, {
+ liveTurnId: "turn-1",
+ streamingItemId: "b",
+ });
+ assert.equal(view.stepCount(), 2, "live: the rail is open");
+ assert.equal(view.summary(), null);
+
+ // Session gone. The items are BYTE-IDENTICAL — only liveness changed.
+ await act(async () => {
+ view.stream(live, null, null);
+ });
+
+ assert.ok(view.summary(), "the block settles when its session goes away");
+ assert.match(view.summary().textContent, /2 steps$/);
+ // Taken HERE, before the fold finishes: the rail is still mounted for the
+ // settle frame, so the glyph that was pulsing a moment ago still exists and
+ // can be asked whether it stopped. Once `settleToStepCount(0)` has run there
+ // are no rows left and the same assertion proves nothing.
+ assert.equal(view.stepCount(), 2, "the rail is still mounted to inspect");
+ assert.deepEqual(view.glyphStates(), ["settled", "settled"]);
+ assert.deepEqual(
+ view.pulseStates(),
+ [],
+ "the pulse stops the moment the session goes away, not when the fold finishes",
+ );
+ assert.equal(await view.settleToStepCount(0), 0);
+});
+
+// ── The gap between two turns ─────────────────────────────────────────────────
+
+/**
+ * A finished block must stay folded through the gap before the next turn shows
+ * anything.
+ *
+ * The rendered half of the `buildConversationTurnMeta` gap contract (see
+ * `agentSessionConversationMeta.test.mjs`). The meta test proves the hints are
+ * right; this proves the reader sees the consequence, because the symptom was
+ * never a wrong id — it was a settled 6-step block re-opening, dropping to its
+ * last three steps behind a "previous steps" disclosure, and then folding back,
+ * on every single turn.
+ *
+ * Six steps rather than two on purpose: the live window only applies above
+ * three, so a smaller block would hide the loudest part of the regression.
+ */
+test("a finished block stays folded while the next turn has started but shown nothing", async () => {
+ const { act } = await import("@testing-library/react");
+ const items = ["a", "b", "c", "d", "e", "f"].map((id) => step(id));
+
+ // Finished: nothing live, so the block is folded to its summary line.
+ const view = await renderBlock(items, {
+ liveTurnId: null,
+ streamingItemId: null,
+ });
+ assert.match(view.summary().textContent, /6 steps$/);
+ assert.equal(await view.settleToStepCount(0), 0, "it starts folded");
+
+ // The next turn starts. It owns liveness (turn-2) and, having emitted nothing
+ // renderable, contributes no streaming item — which is exactly what the fixed
+ // `latestTurnId`/`streamingIdForTail` pair reports for this frame.
+ await act(async () => {
+ view.stream(items, null, "turn-2");
+ });
+
+ assert.ok(
+ view.summary(),
+ "the folded summary line survives the next turn starting",
+ );
+ assert.match(view.summary().textContent, /6 steps$/);
+ assert.equal(view.stepCount(), 0, "the rail does not re-open");
+ assert.equal(
+ view.previousSteps(),
+ null,
+ "and the live window's previous-steps disclosure never appears",
+ );
+ assert.deepEqual(view.pulseStates(), []);
+});
+
+test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => {
+ // Deliberate: we do not know what happened to the step, so it renders as the
+ // neutral step it is with whatever it recorded. A visible "interrupted"
+ // treatment would be a design addition, not part of this fix.
+ const view = await renderBlock(
+ [step("a"), step("b", { status: "executing", completedAt: null })],
+ { liveTurnId: null, streamingItemId: null },
+ );
+ await view.expand();
+ await view.settleToStepCount(2);
+
+ assert.deepEqual(
+ view.glyphStates(),
+ ["settled", "settled"],
+ "no third state was invented for an abandoned step",
+ );
+ assert.equal(
+ view.qa('[data-testid="transcript-tool-item"]').length,
+ 2,
+ "both steps still render through the normal tool presenter",
+ );
+});
+
+test("the rail bullet masks the spine with the drawer surface colour", async () => {
+ // The bullet has to mask the spine passing behind it, and the mask must match
+ // the surface the transcript is drawn on. A mask in any other colour shows as
+ // a disc of the wrong shade around every bullet (berd's BOT-1599).
+ const view = await renderBlock([step("a"), step("b")]);
+ await view.expand();
+ const bullet = view.q("[data-step-state]");
+ assert.match(bullet.className, /\bbg-background\b/);
+ assert.match(bullet.className, /\bring-background\b/);
+ assert.match(bullet.className, /\brounded-full\b/);
+});
+
+test("the spine is drawn for every step except the last", async () => {
+ const view = await renderBlock([step("a"), step("b"), step("c")]);
+ await view.expand();
+ const spines = view.qa(".w-px");
+ assert.equal(
+ spines.length,
+ 2,
+ "three steps means two connecting segments; a trailing spine would dangle",
+ );
+});
+
+test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => {
+ const view = await renderBlock([thoughtStep("thought:1"), step("a")]);
+ await view.expand();
+ const thought = view.q('[data-testid="transcript-work-block-thought"]');
+ assert.ok(thought, "reasoning renders on the rail");
+ assert.match(thought.textContent, /weighing the options/);
+ assert.equal(
+ view.q('[data-testid="transcript-thought-disclosure"]'),
+ null,
+ "the block is already one disclosure — a thought must not add a second",
+ );
+});
+
+/**
+ * An interim note is progress, not a second reply.
+ *
+ * Conversation mode renders the turn's answer as standalone prose. A rail note
+ * is the same item type, so routing it through that presenter would render an
+ * authored agent turn nested inside a muted step row—the reader would see
+ * the agent apparently reply twice, once inside the work it was doing. berd
+ * draws the same line: its `progress` entry is a plain rail row.
+ *
+ * The suppression is done on this side (a dedicated prose body) rather than by
+ * reaching into the message presenter, so #6720 keeps one rule for what a
+ * message looks like.
+ */
+test("an interim note renders as rail prose with no identity row", async () => {
+ const view = await renderBlock([
+ step("a"),
+ noteStep("msg:interim", "checked the three call sites"),
+ ]);
+ await view.expand();
+
+ const note = view.q('[data-testid="transcript-work-block-note"]');
+ assert.ok(note, "the note renders on the rail");
+ assert.match(note.textContent, /checked the three call sites/);
+
+ assert.ok(
+ view.q('[data-testid="transcript-assistant-identity"]') === null,
+ "an avatar + name row inside a muted step reads as a second reply",
+ );
+ assert.ok(
+ view.q('[data-testid="transcript-assistant-message"]') === null,
+ "the note must not go through the message presenter at all",
+ );
+});
+
+/**
+ * A relay post is a step, not a reply — the same rule as an interim note,
+ * reached by a different route.
+ *
+ * A note is an assistant *message* the block re-presents as prose. A relay post
+ * is a *tool call* that classifies as `renderClass: "message"`, so it renders
+ * through `CompactMessageSummary`: 28px avatar, bordered speech bubble,
+ * timestamp, delivery-receipt button. That is right in the activity feed, where
+ * a posted message is a destination to open; on the rail it makes the agent
+ * appear to reply in the middle of its own work — and it did, in the seeded
+ * browser preview, which is where this was caught.
+ *
+ * Suppressing it needs the presentation signal rather than the transcript
+ * variant: the same relay step OUTSIDE a block in this variant keeps its
+ * bubble, which the next test pins.
+ */
+test("a relay post on the rail is a plain step, with no bubble or avatar", async () => {
+ const view = await renderBlock([step("a"), relayStep("relay:1")]);
+ await view.expand();
+
+ assert.equal(
+ view.stepCount(),
+ 2,
+ "the relay post takes its own rail row, like any other step",
+ );
+ assert.equal(
+ view.qa('[data-work-block-entry="tool"]').length,
+ 2,
+ "a relay post is a tool step — it is something the agent did",
+ );
+ assert.equal(
+ view.q('[data-testid="transcript-tool-message-preview"]'),
+ null,
+ "a speech bubble inside a muted step reads as the agent replying mid-work",
+ );
+ assert.equal(
+ view.q('[data-testid="transcript-agent-sent-avatar"]'),
+ null,
+ "no identity avatar on the rail",
+ );
+ assert.equal(
+ view.q('[data-testid="transcript-sent-message-context-button"]'),
+ null,
+ "no delivery receipt on the rail",
+ );
+
+ // It is still a real, expandable tool row carrying its command.
+ const rows = view.qa('[data-testid="transcript-tool-item"]');
+ assert.equal(rows.length, 2, "both steps render as tool rows");
+ assert.ok(
+ rows[1].querySelector("details"),
+ "the relay step keeps the ordinary step disclosure so its args stay reachable",
+ );
+ assert.match(
+ rows[1].textContent,
+ /Sent|posted the findings/,
+ "the row still says what the step was",
+ );
+});
+
+/**
+ * The other half of the branch: outside a block the bubble is correct and must
+ * survive. Without this, suppressing the bubble everywhere in the conversation
+ * variant would pass the test above.
+ */
+test("the same relay post outside a work block keeps its message bubble", async () => {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { QueryClient, QueryClientProvider } = await import(
+ "@tanstack/react-query"
+ );
+ const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
+ await import("@tanstack/react-router");
+ const { AgentSessionTranscriptVariantProvider } = await import(
+ "./agentSessionTranscriptContext.ts"
+ );
+ const { TranscriptActivityItem } = await import(
+ "./activityRenderClasses/TranscriptActivityItem.tsx"
+ );
+
+ // `gcTime: 0`: React Query's default is 300000ms, and this is the one test
+ // that actually drives the bubble presenter's `useQuery`, so its query arms a
+ // five-minute gc timer at teardown. node:test waits that timer out before
+ // exiting — this file's tests sum to ~2s but the wall was ~303s, all passing,
+ // with no failing assertion to point at the cause.
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { gcTime: 0, retry: false } },
+ });
+ const rootRoute = createRootRoute({
+ component: () =>
+ createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ createElement(
+ AgentSessionTranscriptVariantProvider,
+ { value: "conversation" },
+ createElement(TranscriptActivityItem, {
+ agentAvatarUrl: null,
+ agentName: "Agent",
+ agentPubkey: "pk",
+ item: relayStep("relay:1"),
+ }),
+ ),
+ ),
+ });
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ routeTree: rootRoute,
+ });
+ await router.load();
+ const view = render(createElement(RouterProvider, { router }));
+
+ const bubble = view.container.querySelector(
+ '[data-testid="transcript-tool-message-preview"]',
+ );
+ assert.ok(
+ bubble,
+ "outside a block a posted message is a destination the reader can open — the bubble stays",
+ );
+ assert.match(bubble.className, /relative/);
+ assert.match(
+ bubble.className,
+ /pr-4/,
+ "focus-mode agent sends keep a full 16px inset on the right",
+ );
+ assert.doesNotMatch(
+ bubble.className,
+ /max-h-36/,
+ "focus mode shows the full sent message rather than clamping it",
+ );
+ assert.match(
+ bubble.parentElement.className,
+ /pr-9/,
+ "the left-side bubble keeps exterior space mirroring the sender avatar gutter",
+ );
+ assert.match(
+ bubble.className,
+ /(? {
+ // The exhaustive switch is the point: a note that fell through to the tool
+ // branch would wear a wrench and read as something the agent ran.
+ const view = await renderBlock([
+ thoughtStep("thought:1"),
+ noteStep("msg:interim"),
+ step("a"),
+ step("b", { isError: true, status: "failed" }),
+ ]);
+ await view.expand();
+
+ const glyphClass = (kind, index = 0) => {
+ const rows = view.qa(`[data-work-block-entry="${kind}"]`);
+ const icon = rows[index].querySelector("svg");
+ return icon.getAttribute("class") ?? "";
+ };
+
+ // lucide stamps each icon with a `lucide-` class, so the glyph
+ // identity is readable from the DOM without reaching into the icon modules.
+ assert.match(glyphClass("thought"), /lucide-message-circle/);
+ assert.match(
+ glyphClass("note"),
+ /lucide-message-circle/,
+ "prose is the agent talking, whether it is reasoning or a note",
+ );
+ assert.match(glyphClass("tool", 0), /lucide-wrench/);
+ assert.match(
+ glyphClass("tool", 1),
+ /lucide-circle/,
+ "a failed step is a filled dot, not a wrench",
+ );
+});
+
+test("the rail bullet is never red, whatever the step's outcome", async () => {
+ // A failure is carried by glyph shape and by the folded line's count. Tinting
+ // the bullet would make one bad step read as an alarm across the whole run.
+ //
+ // The running step keeps this block live, so the rail is already open — which
+ // is also the only state in which a running bullet can be observed at all.
+ const view = await renderBlock(
+ [
+ step("a"),
+ step("b", { isError: true, status: "failed" }),
+ step("c", { status: "executing", completedAt: null }),
+ ],
+ { streamingItemId: "c" },
+ );
+
+ assert.deepEqual(
+ view.glyphStates(),
+ ["settled", "failed", "running"],
+ "all three outcomes are on screen",
+ );
+ for (const bullet of view.qa("[data-step-state]")) {
+ assert.ok(
+ !/\b(text|bg|ring)-(destructive|red)/.test(bullet.className),
+ `rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`,
+ );
+ assert.match(bullet.className, /\btext-muted-foreground\b/);
+ }
+});
+
+/**
+ * berd brightens rail prose with `usePrimaryText={open}`. Here the brightening
+ * is unconditional, and this test records why that is not a divergence: a closed
+ * block unmounts its rows rather than dimming them, so there is no state in
+ * which rail prose is on screen and NOT in an open block. A `primaryText` prop
+ * would have an unreachable false branch.
+ */
+test("rail prose is primary text, and a closed block has no prose on screen at all", async () => {
+ const { act } = await import("@testing-library/react");
+ const live = [
+ thoughtStep("thought:1"),
+ noteStep("msg:interim"),
+ step("b", { status: "executing", completedAt: null }),
+ ];
+ const view = await renderBlock(live, { streamingItemId: "b" });
+
+ const prose = () =>
+ view.qa(
+ '[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]',
+ );
+
+ assert.equal(prose().length, 2, "both prose rows are on the live rail");
+ for (const node of prose()) {
+ assert.match(
+ node.className,
+ /\btext-foreground\b/,
+ "prose the reader can see is primary, not muted",
+ );
+ assert.ok(
+ !/\btext-muted-foreground\b/.test(node.className),
+ "the row must not carry both colours",
+ );
+ }
+
+ // Finish the turn: the block folds and takes its prose with it.
+ await act(async () => {
+ view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]);
+ });
+ assert.equal(await view.settleToStepCount(0), 0, "it folded");
+ assert.equal(
+ prose().length,
+ 0,
+ "a folded block renders no prose, so there is no dimmed state to test",
+ );
+
+ // And the reader reopening it brings the same primary prose back.
+ await act(async () => {
+ view.summary().click();
+ });
+ assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it");
+ assert.equal(prose().length, 2);
+ for (const node of prose()) {
+ assert.match(node.className, /\btext-foreground\b/);
+ }
+});
+
+// ── Streaming cost ───────────────────────────────────────────────────────────
+
+/**
+ * A block re-renders on every append while work streams. Unchanged steps must
+ * not re-render with it: each step's presenter rebuilds compact tool summaries,
+ * parses diffs and renders markdown/images, so an unmemoized step row makes a
+ * long block cost O(n) of that work per appended step.
+ *
+ * Counted at the presenter boundary — `TranscriptActivityItem` looks its
+ * presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so
+ * swapping in a counting presenter observes exactly the work a step row
+ * triggers, without reaching into React internals.
+ */
+async function countStepRenders(initialItems, nextItems) {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import(
+ "./activityRenderClasses/TranscriptActivityItem.tsx"
+ );
+ const { AgentSessionTranscriptTurnMetaProvider } = await import(
+ "./agentSessionTranscriptContext.ts"
+ );
+ const { AgentSessionWorkBlockSegment } = await import(
+ "./AgentSessionWorkBlock.tsx"
+ );
+
+ const renders = [];
+ const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell;
+ ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) {
+ renders.push(props.item.id);
+ return createElement("div", null, props.item.id);
+ };
+
+ try {
+ const element = (items) =>
+ createElement(
+ AgentSessionTranscriptTurnMetaProvider,
+ {
+ value: {
+ liveTurnId: items[items.length - 1].turnId,
+ streamingItemId: items[items.length - 1].id,
+ },
+ },
+ createElement(AgentSessionWorkBlockSegment, {
+ agentAvatarUrl: null,
+ agentName: "Agent",
+ agentPubkey: "pk",
+ block: {
+ id: "work-block:a",
+ items,
+ timestamp: items[0].timestamp,
+ },
+ }),
+ );
+
+ const view = render(element(initialItems));
+ renders.length = 0;
+ view.rerender(element(nextItems));
+ return renders;
+ } finally {
+ ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original;
+ }
+}
+
+test("appending a step does not re-render the steps already on the rail", async () => {
+ // The block is expanded (a live block with ≤3 steps shows them all), and the
+ // prior steps are the SAME objects across both renders, as the transcript
+ // store replaces items rather than mutating them.
+ const settled = [step("a"), step("b")];
+ const appended = [
+ ...settled,
+ step("c", { status: "executing", completedAt: null }),
+ ];
+
+ const rendered = await countStepRenders(settled, appended);
+
+ assert.deepEqual(rendered, ["c"]);
+});
+
+test("a step that actually changed does re-render", async () => {
+ // Guards the memo from being too aggressive: an executing step settling is a
+ // new object for that id, and it must re-render to drop its running glyph.
+ const a = step("a");
+ const executing = step("b", { status: "executing", completedAt: null });
+ const settled = step("b");
+
+ const rendered = await countStepRenders([a, executing], [a, settled]);
+
+ assert.deepEqual(rendered, ["b"]);
+});
diff --git a/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs b/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs
new file mode 100644
index 0000000000..1b4f185c62
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionWorkBlock.test.mjs
@@ -0,0 +1,402 @@
+/**
+ * Work-block rendering while a turn is live and when it finishes: the rail, the
+ * live window, the fold animation, and the reader's disclosure choice.
+ * Orphaned work and streaming cost live in
+ * `AgentSessionWorkBlock.orphaned.test.mjs`; the shared rig is
+ * `AgentSessionWorkBlockTestRig.mjs`.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ renderBlock,
+ setPrefersReducedMotion,
+ step,
+} from "./AgentSessionWorkBlockTestRig.mjs";
+
+// ── Live ─────────────────────────────────────────────────────────────────────
+
+test("a live block shows no header line — the rail is the status", async () => {
+ const view = await renderBlock([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(
+ view.summary(),
+ null,
+ "a header while live would only restate what the arriving steps show",
+ );
+ assert.equal(view.stepCount(), 2);
+});
+
+test("a live block windows to the last three steps with the rest behind a disclosure", async () => {
+ const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
+ items[4] = step("e", { status: "executing", completedAt: null });
+ const view = await renderBlock(items, { streamingItemId: "e" });
+
+ assert.equal(view.stepCount(), 3, "only the live window renders on the rail");
+ const disclosure = view.previousSteps();
+ assert.ok(disclosure, "older steps sit behind a disclosure");
+ assert.match(disclosure.textContent, /2 previous steps/);
+});
+
+test("expanding previous steps reveals the older steps in place", async () => {
+ const { act } = await import("@testing-library/react");
+ const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
+ items[4] = step("e", { status: "executing", completedAt: null });
+ const view = await renderBlock(items, { streamingItemId: "e" });
+
+ assert.equal(view.stepCount(), 3);
+ await act(async () => {
+ view.previousSteps().click();
+ });
+ assert.equal(
+ await view.settleToStepCount(5),
+ 5,
+ "all five steps are now on the rail",
+ );
+});
+
+// ── Finished ─────────────────────────────────────────────────────────────────
+
+test("a block that was already finished on mount folds to an N steps line", async () => {
+ const view = await renderBlock([step("a"), step("b"), step("c")]);
+ const summary = view.summary();
+ assert.ok(summary, "a finished block gets its summary line");
+ assert.match(summary.textContent, /3 steps/);
+ assert.equal(summary.getAttribute("aria-expanded"), "false");
+ assert.equal(view.stepCount(), 0, "the rail is collapsed away");
+});
+
+test("a finished block containing a failure names the failure in its folded line", async () => {
+ const view = await renderBlock([
+ step("a"),
+ step("b", { isError: true, status: "failed" }),
+ step("c"),
+ ]);
+ assert.match(
+ view.summary().textContent,
+ /3 steps · 1 failed/,
+ "a failure must never hide behind a neutral count",
+ );
+});
+
+test("clicking the folded line expands the whole rail", async () => {
+ const { act } = await import("@testing-library/react");
+ const view = await renderBlock([step("a"), step("b"), step("c")]);
+ assert.equal(view.stepCount(), 0);
+
+ await act(async () => {
+ view.summary().click();
+ });
+
+ assert.equal(await view.settleToStepCount(3), 3);
+ assert.equal(view.summary().getAttribute("aria-expanded"), "true");
+});
+
+test("a block expanded by the reader while live shows every step, not just the window", async () => {
+ const { act } = await import("@testing-library/react");
+ const items = ["a", "b", "c", "d", "e"].map((id) => step(id));
+ items[4] = step("e", { status: "executing", completedAt: null });
+ const view = await renderBlock(items, { streamingItemId: "e" });
+
+ assert.equal(view.stepCount(), 3);
+ await act(async () => {
+ view.previousSteps().click();
+ });
+ assert.equal(
+ await view.settleToStepCount(5),
+ 5,
+ "a reader who asked to see the work sees all of it",
+ );
+
+ // And the window does NOT come back as more work streams in: the reader's
+ // choice is not re-decided on every append.
+ await act(async () => {
+ view.stream(
+ [...items, step("f", { status: "executing", completedAt: null })],
+ "f",
+ );
+ });
+ assert.equal(
+ await view.settleToStepCount(6),
+ 6,
+ "a reader-expanded live block keeps showing everything as it grows",
+ );
+});
+
+// ── Fold animation ───────────────────────────────────────────────────────────
+
+test("a block that finishes while mounted stays open for a paint so the collapse is visible", async () => {
+ const { act } = await import("@testing-library/react");
+ const live = [
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ];
+ const view = await renderBlock(live);
+ assert.equal(view.summary(), null, "live: no header");
+ assert.equal(view.stepCount(), 2);
+
+ // The turn finishes: the same block id re-renders with settled steps.
+ await act(async () => {
+ view.stream([step("a"), step("b")]);
+ });
+
+ // Still open on the commit right after finishing — that open state is what
+ // gives the height animation something to collapse FROM. A block that jumped
+ // straight to closed would swap a rail for a one-line summary between frames.
+ assert.equal(
+ view.stepCount(),
+ 2,
+ "the rail is still mounted for the settle frame",
+ );
+ assert.ok(
+ view.summary(),
+ "the summary line appears as soon as work finishes",
+ );
+
+ // After the settle frames it closes — once the collapse animation has run.
+ assert.equal(await view.settleToStepCount(0), 0, "the block settles closed");
+});
+
+test("under reduced motion a finishing block folds immediately, with no settle frames", async () => {
+ const { act } = await import("@testing-library/react");
+ setPrefersReducedMotion(true);
+
+ const view = await renderBlock([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(view.stepCount(), 2);
+
+ await act(async () => {
+ view.stream([step("a"), step("b")]);
+ });
+
+ assert.equal(
+ view.stepCount(),
+ 0,
+ "reduced motion skips the animation, so there is nothing to hold open for",
+ );
+ assert.ok(
+ view.summary(),
+ "it still folds to a summary line — only the animation is skipped",
+ );
+});
+
+// ── Reader choice ────────────────────────────────────────────────────────────
+
+/**
+ * The echo trap, and why this block is structurally immune to it.
+ *
+ * `` fires `toggle` for programmatic `open` changes as well as for
+ * clicks, indistinguishably — so a policy-driven open echoes back looking like
+ * a reader choice and pins the row to its first policy state forever. That trap
+ * cost time on the tool-run card.
+ *
+ * This block cannot hit it, because its disclosure is a `
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx
index 334a1b0807..59c1cca1b3 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx
@@ -1,3 +1,6 @@
+import { Check, ListChecks, Loader } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
import { Markdown } from "@/shared/ui/markdown";
import {
ActivityRow,
@@ -5,6 +8,12 @@ import {
ActivityRowLabel,
} from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
+import {
+ formatPlanChecklistProgress,
+ parsePlanChecklist,
+ type PlanChecklistEntry,
+} from "../agentSessionPlanChecklist";
+import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import type { ActivityRenderClassItemProps } from "./types";
@@ -16,14 +25,27 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
return null;
}
- if (props.item.isUpdate) {
+ return ;
+}
+
+function PlanItem({
+ item,
+}: {
+ item: Extract;
+}) {
+ const variant = useAgentSessionTranscriptVariant();
+
+ // Plan-update markers stay a one-line "Updated plan · 3/5 complete" row in
+ // every variant: in focus mode the card below already carries current state,
+ // so the marker is a timeline note and must not compete with it.
+ if (item.isUpdate) {
return (
}
+ object={}
openToneScope="none"
verb="Updated"
/>
@@ -31,22 +53,125 @@ export function PlanActivity(props: ActivityRenderClassItemProps) {
);
}
+ if (variant === "conversation") {
+ return ;
+ }
+
return (
);
}
+/**
+ * Focus-mode plan: a checklist card rather than a collapsed markdown row.
+ *
+ * The plan item is mutated in place by the transcript reducer (`replaceItem`
+ * keeps the same item id), so this card is the one surface that shows current
+ * plan state — re-rendering it as entries flip is the "updates in place"
+ * behavior; no local state is involved.
+ *
+ * Adapters that send free-form plan text instead of ACP `entries[]` produce no
+ * checklist lines, so the card falls back to the markdown body.
+ */
+function ConversationPlanCard({
+ item,
+}: {
+ item: Extract;
+}) {
+ const text = item.text.trim();
+ const checklist = parsePlanChecklist(text);
+ const progress = formatPlanChecklistProgress(checklist);
+
+ return (
+
+ );
+}
+
function PlanUpdateLabelObject({ text }: { text: string }) {
return (
<>
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
index 8dfaea86ef..7c66e02750 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx
@@ -16,17 +16,30 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) {
return null;
}
+ return ;
+}
+
+/**
+ * The `default`/`compactPreview` thought row.
+ *
+ * The `conversation` variant does not reach this presenter: focus mode renders
+ * reasoning as a row on the work block's rail (`AgentSessionWorkBlock`), so the
+ * separate per-thought disclosure that used to live here is gone rather than
+ * duplicated. Its "Thought for Ns" label logic moved with it.
+ */
+function ThoughtItem({
+ item,
+}: {
+ item: Extract;
+}) {
return (
-
+
-
+
);
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
index 181e4febf5..634c69ce24 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
@@ -33,24 +33,48 @@ export function UserMessageBubble({
const { goChannel } = useAppNavigation();
const { openProfilePanel } = useProfilePanel();
const isCompactPreview = variant === "compactPreview";
- const shouldClampBubble = !isCompactPreview;
+ const isConversation = variant === "conversation";
+ // Focus mode shows the whole prompt: the reader is here to read the turn, and
+ // the channel-context affordance already lives in the footer, so clamping the
+ // bubble would hide the one thing they came for.
+ const shouldClampBubble = !isCompactPreview && !isConversation;
const [bubbleRef, hasBubbleOverflow] =
useTranscriptBubbleOverflow(shouldClampBubble);
const text = item.text.trim();
+ // The bubble stays a link back to the originating channel message in both
+ // polished variants; only the dense preview drops it.
const messageLink =
- shouldClampBubble && item.channelId && item.messageId
+ !isCompactPreview && item.channelId && item.messageId
? { channelId: item.channelId, messageId: item.messageId }
: null;
const authorProfile = item.authorPubkey
? profiles?.[item.authorPubkey.toLowerCase()]
: null;
- const authorLabel = item.authorPubkey
+ // The other variants seed the avatar from `resolveUserLabel(…, fallbackName:
+ // item.title)`, whose last resort is the prompt item's title. That title
+ // describes the *trigger* that started the turn ("@Mention", "Prompt", "Buzz
+ // event"), never a person — harmless when it only picks avatar initials, so
+ // this chain is left exactly as it was for `default`/`compactPreview`.
+ const triggerSeededLabel = item.authorPubkey
? resolveUserLabel({
pubkey: item.authorPubkey,
fallbackName: item.title,
profiles,
})
: item.title || "User";
+ // Focus mode promotes the author to displayed text, where a trigger title
+ // would read as a false identity — an unresolved sender showing up as
+ // "@Mention". Identity resolution here therefore stops at the profile
+ // (display name, then NIP-05 handle, then the truncated pubkey): a truncated
+ // pubkey is a real, if terse, identity, and it keeps the utterance attributed
+ // in a full-cover view. `item.title` stays available as trigger chrome in the
+ // footer; it is never a name. Only when the item carries no author at all does
+ // the row fall back to a generic placeholder.
+ const authorLabel = isConversation
+ ? item.authorPubkey
+ ? resolveUserLabel({ pubkey: item.authorPubkey, profiles })
+ : "User"
+ : triggerSeededLabel;
const handleBubbleClick = React.useCallback(
(event: React.MouseEvent) => {
if (!messageLink || isNestedInteractiveTarget(event)) return;
@@ -130,9 +154,23 @@ export function UserMessageBubble({
className={cn(
"group relative flex min-w-0 flex-1 flex-col items-end gap-1",
isCompactPreview && "items-start",
+ // berd caps the user turn at a fixed measure, not a percentage of the
+ // column (`--chat-user-message-max-width: 640px`,
+ // MessageBubble.tsx:956): a percentage keeps re-wrapping the prompt as
+ // the cover width changes, while a fixed measure holds one stable
+ // reading line length. `max-w-prompt-bubble` carries the 640px token.
+ isConversation && "max-w-prompt-bubble flex-initial",
className,
)}
>
+ {isConversation ? (
+
+ {authorLabel}
+
+ ) : null}
- {footer}
+ {isConversation && footer ? (
+ // Timestamp/context row is chrome, not content: focus mode keeps it
+ // out of the reading rhythm until the row is hovered or
+ // keyboard-focused. Other variants render `footer` bare so their
+ // markup is unchanged.
+
+ {footer}
+
+ ) : (
+ footer
+ )}
);
diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs
new file mode 100644
index 0000000000..d5e2d3a752
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.test.mjs
@@ -0,0 +1,549 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildConversationTurnMeta } from "./agentSessionConversationMeta.ts";
+import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext.ts";
+import { buildTranscriptDisplayBlocks } from "./agentSessionTranscriptGrouping.ts";
+import {
+ formatPlanChecklistProgress,
+ parsePlanChecklist,
+} from "./agentSessionPlanChecklist.ts";
+
+function item(overrides) {
+ return {
+ channelId: "chan-1",
+ sessionId: "sess-1",
+ turnId: "turn-1",
+ ...overrides,
+ };
+}
+
+const RAW_TIMESTAMP = "2026-06-14T19:00:00.000Z";
+
+/**
+ * Raw transcript items, for the tests that go through the real grouping rather
+ * than asserting on hand-written blocks. `acpSource` is what decides whether an
+ * item is setup lifecycle, a prompt, or work, so these carry it.
+ */
+function lifecycleItem(id, acpSource, turnId) {
+ return item({
+ id,
+ type: "lifecycle",
+ renderClass: "status",
+ title: acpSource,
+ text: "",
+ timestamp: RAW_TIMESTAMP,
+ acpSource,
+ turnId,
+ });
+}
+
+function promptItem(id, turnId) {
+ return item({
+ id,
+ type: "message",
+ role: "user",
+ renderClass: "message",
+ title: "Buzz event",
+ text: "please look into this",
+ timestamp: RAW_TIMESTAMP,
+ acpSource: "session/prompt:user",
+ turnId,
+ });
+}
+
+function toolItem(id, turnId) {
+ return item({
+ id,
+ type: "tool",
+ renderClass: "shell",
+ title: id,
+ toolName: "shell",
+ buzzToolName: null,
+ status: "completed",
+ args: {},
+ result: "ok",
+ isError: false,
+ timestamp: RAW_TIMESTAMP,
+ startedAt: RAW_TIMESTAMP,
+ completedAt: RAW_TIMESTAMP,
+ descriptor: {
+ renderClass: "shell",
+ label: "Ran command",
+ preview: id,
+ source: "shell",
+ groupKey: "shell:command",
+ },
+ turnId,
+ });
+}
+
+function systemPromptItem(id) {
+ return item({
+ id,
+ type: "metadata",
+ renderClass: "metadata",
+ title: "System prompt",
+ sections: [],
+ timestamp: RAW_TIMESTAMP,
+ acpSource: "session/new",
+ turnId: null,
+ });
+}
+
+function turnBlock(segments) {
+ return { kind: "turn", turnId: "turn-1", segments };
+}
+
+/**
+ * The leaf items a block is built from, in wire order — including setup
+ * lifecycle items, which carry a turn id in the real stream even though they
+ * contribute nothing to the streaming tail.
+ *
+ * `buildConversationTurnMeta` reads the item stream as well as the blocks (it
+ * has to: a turn that has only emitted setup rows produces no block at all), so
+ * a test that passed blocks alone would be describing a transcript that cannot
+ * exist. This keeps the two arguments consistent by construction.
+ */
+function blockItems(blocks) {
+ return blocks.flatMap((block) => {
+ if (block.kind === "single") return [block.item];
+ if (block.kind !== "turn") return [];
+ return block.segments.flatMap((segment) => {
+ if (segment.kind === "prompt") return [segment.user];
+ if (segment.kind === "setup") return segment.items;
+ if (segment.kind === "summary") return segment.summary.items;
+ return [segment.item];
+ });
+ });
+}
+
+/** `buildConversationTurnMeta` with the item stream derived from the blocks. */
+function metaFor(blocks, { items, ...options }) {
+ return buildConversationTurnMeta(blocks, {
+ ...options,
+ items: items ?? blockItems(blocks),
+ });
+}
+
+function itemSegment(id, overrides) {
+ return {
+ kind: "item",
+ item: item({
+ id,
+ timestamp: "2026-06-14T19:00:02.000Z",
+ ...overrides,
+ }),
+ };
+}
+
+const thoughtSegment = (id, timestamp) =>
+ itemSegment(id, {
+ type: "thought",
+ renderClass: "thought",
+ title: "Thinking",
+ text: "…",
+ timestamp,
+ });
+
+const messageSegment = (id, timestamp, role = "assistant") =>
+ itemSegment(id, {
+ type: "message",
+ renderClass: "message",
+ role,
+ title: role === "user" ? "Ada" : "Agent",
+ text: "done",
+ timestamp,
+ });
+
+const toolSegment = (id, timestamp) =>
+ itemSegment(id, {
+ type: "tool",
+ renderClass: "shell",
+ title: "Ran a command",
+ text: "",
+ descriptor: { label: "Ran a command", preview: "cargo test" },
+ timestamp,
+ });
+
+// ---- buildConversationTurnMeta ----
+
+test("buildConversationTurnMeta returns the shared empty value for other variants", () => {
+ for (const variant of ["default", "compactPreview"]) {
+ assert.equal(
+ metaFor([turnBlock([])], {
+ isTurnLive: true,
+ variant,
+ }),
+ EMPTY_TRANSCRIPT_TURN_META,
+ `${variant} must allocate nothing so its render output is untouched`,
+ );
+ }
+});
+
+test("buildConversationTurnMeta reports nothing streaming when the turn is idle", () => {
+ // The hint exists to tell the work block it is still working. A finished turn
+ // has no tail, and reporting one would pin the block open forever.
+ assert.equal(
+ metaFor(
+ [
+ turnBlock([
+ thoughtSegment("thought:1", "2026-06-14T19:00:02.000Z"),
+ messageSegment("msg:1", "2026-06-14T19:00:14.000Z"),
+ ]),
+ ],
+ { isTurnLive: false, variant: "conversation" },
+ ),
+ EMPTY_TRANSCRIPT_TURN_META,
+ );
+});
+
+test("buildConversationTurnMeta names the trailing item of a live turn", () => {
+ const blocks = [
+ turnBlock([thoughtSegment("thought:tail", "2026-06-14T19:00:02.000Z")]),
+ ];
+
+ assert.equal(
+ metaFor(blocks, {
+ isTurnLive: true,
+ variant: "conversation",
+ }).streamingItemId,
+ "thought:tail",
+ "a thought carries no status of its own, so the hint is how the block knows it is live",
+ );
+});
+
+test("buildConversationTurnMeta skips setup segments when finding the tail", () => {
+ // Setup renders as a quiet divider, not as work, so it must never be reported
+ // as the streaming item — a lifecycle row would hold the block open.
+ const meta = metaFor(
+ [
+ turnBlock([
+ thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
+ {
+ kind: "setup",
+ items: [
+ item({
+ id: "life:1",
+ type: "lifecycle",
+ renderClass: "status",
+ title: "Turn started",
+ text: "",
+ timestamp: "2026-06-14T19:00:00.000Z",
+ }),
+ ],
+ },
+ ]),
+ ],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.streamingItemId, "thought:1");
+});
+
+test("buildConversationTurnMeta counts a mid-turn steer prompt as the tail", () => {
+ const meta = metaFor(
+ [
+ turnBlock([
+ thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z"),
+ messageSegment("msg:steer", "2026-06-14T19:00:04.000Z", "user"),
+ ]),
+ ],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.streamingItemId, "msg:steer");
+});
+
+test("buildConversationTurnMeta expands a summary segment to reach its last tool", () => {
+ // A summary segment holds several leaf items; the tail is the last of them,
+ // not the summary itself, which has no item id the work block could match.
+ const meta = metaFor(
+ [
+ turnBlock([
+ {
+ kind: "summary",
+ summary: {
+ id: "summary:shell:tool:1",
+ label: "Ran 2 commands",
+ count: 2,
+ items: [
+ toolSegment("tool:1", "2026-06-14T19:00:04.000Z").item,
+ toolSegment("tool:2", "2026-06-14T19:00:05.000Z").item,
+ ],
+ renderClass: "shell",
+ variant: "same-kind",
+ timestamp: "2026-06-14T19:00:04.000Z",
+ },
+ },
+ ]),
+ ],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.streamingItemId, "tool:2");
+});
+
+test("buildConversationTurnMeta reads a trailing single block directly", () => {
+ const meta = metaFor(
+ [
+ turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]),
+ {
+ kind: "single",
+ item: item({
+ id: "life:orphan",
+ type: "lifecycle",
+ renderClass: "status",
+ title: "Context compacted",
+ text: "",
+ timestamp: "2026-06-14T19:00:20.000Z",
+ }),
+ },
+ ],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.streamingItemId, "life:orphan");
+});
+
+// ---- buildConversationTurnMeta: liveTurnId ----
+
+/**
+ * The work block cannot tell an abandoned step from a running one on its own: a
+ * tool keeps its `executing` status forever if the agent dies mid-step. Turn
+ * ownership is knowledge only the list has, so it is published here.
+ */
+test("buildConversationTurnMeta names the live turn so the block can gate running steps", () => {
+ const meta = metaFor(
+ [turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.liveTurnId, "turn-1");
+});
+
+test("buildConversationTurnMeta reports no live turn when the turn is idle", () => {
+ // This is the case the orphaned-step bug lived in: history reopened, nothing
+ // live, but an item still claiming `executing`.
+ const meta = metaFor(
+ [turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")])],
+ { isTurnLive: false, variant: "conversation" },
+ );
+
+ assert.equal(meta.liveTurnId, null);
+ assert.equal(
+ meta,
+ EMPTY_TRANSCRIPT_TURN_META,
+ "an idle turn still allocates nothing",
+ );
+});
+
+test("buildConversationTurnMeta names the live turn even when a lifecycle row trails it", () => {
+ // A compaction notice arrives as a `single` block AFTER the turn it belongs
+ // to, so "the last block" is not always the live turn — but the last turn is.
+ // Reading the last block's kind alone would report no live turn and gate every
+ // running step off, which is the mirror image of the bug being fixed.
+ const meta = metaFor(
+ [
+ turnBlock([thoughtSegment("thought:1", "2026-06-14T19:00:01.000Z")]),
+ {
+ kind: "single",
+ item: item({
+ id: "life:compact",
+ type: "lifecycle",
+ renderClass: "status",
+ title: "Context compacted",
+ text: "",
+ timestamp: "2026-06-14T19:00:20.000Z",
+ }),
+ },
+ ],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.liveTurnId, "turn-1");
+});
+
+test("buildConversationTurnMeta reports no live turn when there is no turn at all", () => {
+ // `turnId: null` explicitly: a session-started row arrives before any turn
+ // exists, so it belongs to nobody. The shared `item()` helper defaults to
+ // `turn-1`, which would have made this fixture describe a transcript that
+ // does have a turn — and the assertion would then be checking that a turn
+ // with no BLOCK reports no live turn, which is the opposite of the rule.
+ const meta = metaFor(
+ [
+ {
+ kind: "single",
+ item: item({
+ id: "life:1",
+ type: "lifecycle",
+ renderClass: "status",
+ title: "Session started",
+ text: "",
+ timestamp: "2026-06-14T19:00:00.000Z",
+ turnId: null,
+ }),
+ },
+ ],
+ { isTurnLive: true, variant: "conversation" },
+ );
+
+ assert.equal(meta.liveTurnId, null);
+});
+
+// ---- buildConversationTurnMeta: the gap between two turns ----
+
+/**
+ * The next turn owns liveness the moment it appears, even before it has anything
+ * to show.
+ *
+ * A turn that has only emitted setup lifecycle rows (`turn_started`,
+ * `session_resolved`) classifies to zero segments and therefore produces NO
+ * block — `agentSessionTranscriptGrouping` only pushes a turn block
+ * `if (segments.length > 0)`. So "the newest turn with a block" is the turn that
+ * ALREADY ENDED for the whole gap between `turn_started` and the new turn's
+ * first prompt or thought, and the ended turn's own trailing item then gets
+ * reported as the streaming item. Its finished work block reads as live again:
+ * the folded summary is replaced by the open rail, a 6-step block drops to its
+ * last three with a previous-steps disclosure, and then it all folds back.
+ *
+ * That gap is real observer-stream latency, and `turn_started` fires on every
+ * turn (`activeAgentTurnsStore`), so this was not an edge case — it was every
+ * turn, for as long as the agent took to emit its first renderable item.
+ *
+ * Built from a raw item stream through the real grouping rather than from
+ * hand-written blocks, because the whole bug is which turns DO and DO NOT
+ * produce a block: hand-built blocks would assume the answer away.
+ *
+ * The frames pin the PLAIN next turn (`turn_started`, then `session_resolved`,
+ * with no `session/new` card) because that is the common path and the only one
+ * that pins the bug. On a session restart the `session/new` card lands as a
+ * trailing `single` block, which moves `streamingItemId` off turn-1's work block
+ * on its own — so a restart-only fixture would pass against the old
+ * block-walking code. Both sequences are covered; only the plain one is
+ * load-bearing.
+ */
+test("buildConversationTurnMeta hands liveness to a next turn that has no block yet", () => {
+ const stream = (...extra) => [
+ lifecycleItem("life:start:1", "turn_started", "turn-1"),
+ promptItem("prompt:1", "turn-1"),
+ toolItem("tool:1", "turn-1"),
+ // turn-1's last act is a completed tool: the agent answered by posting
+ // through buzz-cli, so no assistant message trails its work.
+ toolItem("tool:2", "turn-1"),
+ ...extra,
+ ];
+
+ const started = lifecycleItem("life:start:2", "turn_started", "turn-2");
+ const resolved = lifecycleItem(
+ "life:resolved:2",
+ "session_resolved",
+ "turn-2",
+ );
+ const sysPrompt = systemPromptItem("meta:sysprompt");
+
+ const frames = [
+ // The plain next turn — no session restart, so no `session/new` card. This
+ // is both the common path and the only one that pins the bug: a
+ // `session/new` card renders as a trailing `single` block, which moves the
+ // streaming item off turn-1's work block by itself and so lets the old
+ // block-walking code pass for the wrong reason.
+ {
+ label: "turn-2 has only started",
+ items: stream(started),
+ },
+ {
+ label: "the session has resolved with no restart card",
+ items: stream(started, resolved),
+ },
+ // The restarting next turn, where a system-prompt card lands between the
+ // lifecycle rows. A different route through the grouping, kept because it
+ // has to settle too.
+ {
+ label: "the restarting turn's system prompt has landed",
+ items: stream(started, sysPrompt),
+ },
+ {
+ label: "the restarted session has resolved",
+ items: stream(started, sysPrompt, resolved),
+ },
+ ];
+
+ for (const frame of frames) {
+ const meta = buildConversationTurnMeta(
+ buildTranscriptDisplayBlocks(frame.items),
+ { isTurnLive: true, items: frame.items, variant: "conversation" },
+ );
+ assert.equal(
+ meta.liveTurnId,
+ "turn-2",
+ `the newest turn owns liveness once ${frame.label}`,
+ );
+ assert.equal(
+ meta.streamingItemId,
+ null,
+ `turn-1's tail must not read as streaming once ${frame.label}`,
+ );
+ }
+
+ // Final frame: turn-2 finally has something renderable, so it gets a block
+ // and its own prompt becomes the tail. Included so the sequence ends in the
+ // steady state rather than stopping at the gap.
+ const renderable = stream(
+ started,
+ resolved,
+ promptItem("prompt:2", "turn-2"),
+ );
+ const meta = buildConversationTurnMeta(
+ buildTranscriptDisplayBlocks(renderable),
+ { isTurnLive: true, items: renderable, variant: "conversation" },
+ );
+ assert.equal(meta.liveTurnId, "turn-2");
+ assert.equal(meta.streamingItemId, "prompt:2");
+});
+
+test("buildConversationTurnMeta still names the live turn's own trailing item", () => {
+ // The other half of the ownership check: when the newest turn IS the turn that
+ // owns the trailing item, that item is the streaming one. Without this the
+ // gate would be a blanket "never report a tail" and every live block would
+ // fold while the reader watched it arrive.
+ const items = [
+ lifecycleItem("life:start:1", "turn_started", "turn-1"),
+ promptItem("prompt:1", "turn-1"),
+ toolItem("tool:1", "turn-1"),
+ ];
+ const meta = buildConversationTurnMeta(buildTranscriptDisplayBlocks(items), {
+ isTurnLive: true,
+ items,
+ variant: "conversation",
+ });
+
+ assert.equal(meta.liveTurnId, "turn-1");
+ assert.equal(meta.streamingItemId, "tool:1");
+});
+
+// ---- parsePlanChecklist ----
+
+test("parsePlanChecklist reads the checkbox markdown the transcript builds", () => {
+ const checklist = parsePlanChecklist(
+ [
+ "- [x] read the transcript",
+ "- [ ] write the summary (in progress)",
+ "- [ ] ship it",
+ ].join("\n"),
+ );
+
+ assert.deepEqual(checklist.entries, [
+ { label: "read the transcript", status: "completed" },
+ { label: "write the summary", status: "in_progress" },
+ { label: "ship it", status: "pending" },
+ ]);
+ assert.equal(checklist.completedCount, 1);
+ assert.equal(formatPlanChecklistProgress(checklist), "1/3 complete");
+});
+
+test("parsePlanChecklist yields nothing for free-form plan text", () => {
+ const checklist = parsePlanChecklist("We will read, then write, then ship.");
+ assert.deepEqual(checklist.entries, []);
+ assert.equal(formatPlanChecklistProgress(checklist), null);
+});
diff --git a/desktop/src/features/agents/ui/agentSessionConversationMeta.ts b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts
new file mode 100644
index 0000000000..7992caa5df
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionConversationMeta.ts
@@ -0,0 +1,153 @@
+import type {
+ AgentSessionTranscriptTurnMeta,
+ AgentSessionTranscriptVariant,
+} from "./agentSessionTranscriptContext";
+import { EMPTY_TRANSCRIPT_TURN_META } from "./agentSessionTranscriptContext";
+import type {
+ TranscriptDisplayBlock,
+ TranscriptTurnSegment,
+} from "./agentSessionTranscriptGrouping";
+import type { TranscriptItem } from "./agentSessionTypes";
+
+/**
+ * Flatten a turn's segments into the leaf items that the reader actually sees
+ * in the order they appear. Prompt segments contribute their user message;
+ * setup segments contribute nothing (they render as a quiet divider, not work);
+ * summary segments contribute their collapsed tool items.
+ *
+ * This walks the SHARED segment union deliberately. The conversation variant's
+ * additive work-block transform runs later, at the list's render boundary
+ * (`TranscriptDisplayBlockView`), so no `work-block` segment can reach here —
+ * and the streaming tail must be computed from the true item order regardless
+ * of how the variant later groups those items for presentation.
+ */
+function turnSegmentItems(segment: TranscriptTurnSegment): TranscriptItem[] {
+ if (segment.kind === "prompt") return [segment.user];
+ if (segment.kind === "setup") return [];
+ if (segment.kind === "summary") return segment.summary.items;
+ return [segment.item];
+}
+
+/**
+ * Derive the `conversation` variant's live-turn hints from the display blocks.
+ *
+ * Two things the work block cannot see for itself:
+ *
+ * - **`streamingItemId`** — the trailing leaf item of the final turn, and only
+ * when the agent's turn is actually live. The block reads this to decide
+ * whether it is still working: a thought or note streaming in carries no
+ * status of its own, so without this hint a block whose last step is prose
+ * would fold while the reader was still watching it arrive.
+ * - **`liveTurnId`** — which turn a session currently owns. A tool item keeps
+ * its `executing` status forever if the agent dies mid-step, so status alone
+ * cannot tell a running step from an abandoned one; the block needs to know
+ * whose turn is live to decide (see `AgentSessionTranscriptTurnMeta`).
+ *
+ * Returns the shared empty value for non-conversation variants so the default
+ * and compactPreview paths allocate nothing and stay byte-identical.
+ */
+export function buildConversationTurnMeta(
+ displayBlocks: TranscriptDisplayBlock[],
+ options: {
+ isTurnLive: boolean;
+ items: TranscriptItem[];
+ variant: AgentSessionTranscriptVariant;
+ },
+): AgentSessionTranscriptTurnMeta {
+ if (options.variant !== "conversation" || !options.isTurnLive) {
+ return EMPTY_TRANSCRIPT_TURN_META;
+ }
+
+ const lastBlock = displayBlocks[displayBlocks.length - 1];
+ // The live turn is the newest turn in the ITEM stream, not the newest turn
+ // that produced a block. See `latestTurnId` for why the distinction is
+ // load-bearing. Read from the transcript rather than from the active-turn
+ // store because the store's turn ids and the transcript's are populated by
+ // different paths, and a mismatch would silently gate every step off.
+ const liveTurnId = latestTurnId(options.items, displayBlocks);
+
+ if (lastBlock?.kind === "single") {
+ return {
+ liveTurnId,
+ streamingItemId: streamingIdForTail(lastBlock.item, liveTurnId),
+ };
+ }
+ if (lastBlock?.kind !== "turn") {
+ return { liveTurnId, streamingItemId: null };
+ }
+
+ const items = lastBlock.segments.flatMap(turnSegmentItems);
+ const tail = items[items.length - 1];
+ return {
+ liveTurnId,
+ streamingItemId: tail ? streamingIdForTail(tail, liveTurnId) : null,
+ };
+}
+
+/**
+ * The trailing item counts as streaming only if the LIVE turn owns it.
+ *
+ * Without this check the newest block's tail is reported as streaming no matter
+ * whose turn it belongs to, so a finished turn's last step would hold that
+ * turn's work block open while a *different* turn is the live one. That is the
+ * same class of bug as an abandoned `executing` step: presenting settled history
+ * as work in flight.
+ */
+function streamingIdForTail(
+ tail: TranscriptItem,
+ liveTurnId: string | null,
+): string | null {
+ // Compared rather than tested for truthiness, for the same reason as
+ // `toolEntryState`: an item with no turn id is owned by nobody, and
+ // `null === null` must not read as ownership.
+ if (liveTurnId === null || tail.turnId !== liveTurnId) return null;
+ return tail.id;
+}
+
+/**
+ * The id of the newest turn the transcript has seen — including a turn that has
+ * arrived but has nothing renderable in it yet.
+ *
+ * Read from the items rather than the blocks because a turn that has only
+ * emitted setup lifecycle rows (`turn_started`, `session_resolved`) classifies
+ * to zero segments and so produces NO block at all
+ * (`agentSessionTranscriptGrouping`: `if (segments.length > 0)`). Walking the
+ * blocks backwards for the last `turn` therefore skipped straight past the new
+ * turn and returned the turn that had already ended — which then made its own
+ * trailing item the streaming item, and a settled block visibly re-opened,
+ * dropped to its last three steps and re-expanded for the whole gap between
+ * `turn_started` and the new turn's first prompt or thought. That gap is real
+ * observer-stream latency and happens on every turn, so this was not an edge
+ * case.
+ *
+ * Items are in wire order, so the last one carrying a turn id names the newest
+ * turn. Falls back to the last turn block for an item stream that carries no
+ * turn ids at all.
+ */
+function latestTurnId(
+ items: TranscriptItem[],
+ displayBlocks: TranscriptDisplayBlock[],
+): string | null {
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ const turnId = items[index]?.turnId;
+ if (turnId) return turnId;
+ }
+ return lastTurnBlockId(displayBlocks);
+}
+
+/**
+ * The id of the last turn block, ignoring anything that trails it.
+ *
+ * A lifecycle row (a compaction notice, a session boundary) can land after the
+ * turn it belongs to and arrives as a `single` block, so the last block is not
+ * always the live turn — but the last *turn* is.
+ */
+function lastTurnBlockId(
+ displayBlocks: TranscriptDisplayBlock[],
+): string | null {
+ for (let index = displayBlocks.length - 1; index >= 0; index -= 1) {
+ const block = displayBlocks[index];
+ if (block.kind === "turn") return block.turnId;
+ }
+ return null;
+}
diff --git a/desktop/src/features/agents/ui/agentSessionPlanChecklist.ts b/desktop/src/features/agents/ui/agentSessionPlanChecklist.ts
new file mode 100644
index 0000000000..2b295e270c
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionPlanChecklist.ts
@@ -0,0 +1,54 @@
+/**
+ * Parse the markdown checklist a `plan` transcript item carries into structured
+ * entries, so the `conversation` variant can render it as a checklist card
+ * instead of a block of markdown.
+ *
+ * The text is produced by `formatPlanEntry` in agentSessionTranscriptHelpers:
+ * `- [x] content` for completed entries, `- [ ] content` otherwise, with an
+ * ` (in progress)` suffix when the ACP entry status is `in_progress`. Adapters
+ * that send free-form `content` text instead of `entries[]` yield text with no
+ * checklist lines at all — those return no entries and the card falls back to
+ * rendering the raw markdown.
+ */
+export type PlanChecklistEntry = {
+ label: string;
+ status: "completed" | "in_progress" | "pending";
+};
+
+export type PlanChecklist = {
+ completedCount: number;
+ entries: PlanChecklistEntry[];
+};
+
+const CHECKLIST_LINE = /^\s*[-*]\s+\[([ xX])\]\s*(.*)$/;
+const IN_PROGRESS_SUFFIX = /\s*\(in progress\)\s*$/i;
+
+export function parsePlanChecklist(text: string): PlanChecklist {
+ const entries: PlanChecklistEntry[] = [];
+
+ for (const line of text.split(/\r?\n/)) {
+ const match = line.match(CHECKLIST_LINE);
+ if (!match) continue;
+ const completed = match[1].toLowerCase() === "x";
+ const rawLabel = match[2].trim();
+ const inProgress = !completed && IN_PROGRESS_SUFFIX.test(rawLabel);
+ entries.push({
+ label: rawLabel.replace(IN_PROGRESS_SUFFIX, "").trim(),
+ status: completed ? "completed" : inProgress ? "in_progress" : "pending",
+ });
+ }
+
+ return {
+ completedCount: entries.filter((entry) => entry.status === "completed")
+ .length,
+ entries,
+ };
+}
+
+/** "2/5 complete" progress caption, or null when there is nothing to count. */
+export function formatPlanChecklistProgress(
+ checklist: PlanChecklist,
+): string | null {
+ if (checklist.entries.length === 0) return null;
+ return `${checklist.completedCount}/${checklist.entries.length} complete`;
+}
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts
index fd55543901..aab5ed1d20 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptContext.ts
@@ -1,6 +1,19 @@
import * as React from "react";
-export type AgentSessionTranscriptVariant = "default" | "compactPreview";
+/**
+ * Presentation modes for the observer transcript.
+ *
+ * - `default` — the polished activity feed (agent panel, thread panel).
+ * - `compactPreview` — dense, single-column preview (profile panel cards).
+ * - `conversation` — full-cover focus-mode reading view: prompts as
+ * right-aligned bubbles, agent messages as unboxed prose,
+ * thoughts/plans as disclosures, lifecycle as quiet
+ * dividers. Tool items render exactly as `default`.
+ */
+export type AgentSessionTranscriptVariant =
+ | "default"
+ | "compactPreview"
+ | "conversation";
const AgentSessionTranscriptVariantContext =
React.createContext("default");
@@ -11,3 +24,78 @@ export const AgentSessionTranscriptVariantProvider =
export function useAgentSessionTranscriptVariant() {
return React.useContext(AgentSessionTranscriptVariantContext);
}
+
+/**
+ * Per-render derivation the `conversation` variant needs but individual render
+ * classes cannot see on their own: which item is currently streaming (the
+ * trailing item of a live turn). Derived once by the list from the display
+ * blocks — see `buildConversationTurnMeta`.
+ */
+export type AgentSessionTranscriptTurnMeta = {
+ /** Trailing item of a live turn, or null when nothing is streaming. */
+ streamingItemId: string | null;
+ /**
+ * The turn that is actually live, or null when no turn is.
+ *
+ * A tool item's `executing`/`pending` status is not evidence that work is
+ * happening: an agent that dies after emitting a tool start leaves that
+ * status on the item permanently, so reopened history still claims to be
+ * mid-step. Whether a session owns that step is knowledge only the list has,
+ * which is why it is published here rather than re-derived per row.
+ *
+ * A turn id rather than a boolean, because "some turn is live" is not the
+ * question. An agent that crashed during turn 1 and is now working on turn 2
+ * is live, yet turn 1's abandoned step is no more running than before — a
+ * global flag would keep it spinning in exactly the case a restarted agent
+ * makes common.
+ */
+ liveTurnId: string | null;
+};
+
+export const EMPTY_TRANSCRIPT_TURN_META: AgentSessionTranscriptTurnMeta = {
+ liveTurnId: null,
+ streamingItemId: null,
+};
+
+/**
+ * Whether the surrounding subtree is a work block's rail.
+ *
+ * The rail's job is to show *what the agent did*, one muted row per step. Two
+ * tool presentations disagree with that: a relay `messages send` classifies as
+ * `renderClass: "message"` and therefore renders through
+ * `CompactMessageSummary` — a 28px avatar, a bordered speech bubble, a
+ * timestamp and a delivery-receipt button. That treatment is right in the
+ * activity feed, where a posted message is a destination the reader may want to
+ * open, and wrong on the rail, where the same markup reads as the agent
+ * *replying* in the middle of its own work.
+ *
+ * This is the same failure the interim-note case avoids (see
+ * `WorkBlockProseBody`), reached by a different route: there the item is an
+ * assistant message, here it is a tool call that merely classifies as one. The
+ * signal is explicit and additive rather than inferred from the transcript
+ * variant, because `conversation` alone is not the condition — the same relay
+ * step rendered outside a block in that variant should keep its bubble.
+ *
+ * Defaults to `false`, so `default` and `compactPreview` cannot observe it and
+ * their markup stays byte-identical.
+ */
+const AgentSessionWorkBlockRailContext = React.createContext(false);
+
+export const AgentSessionWorkBlockRailProvider =
+ AgentSessionWorkBlockRailContext.Provider;
+
+export function useIsInsideWorkBlockRail() {
+ return React.useContext(AgentSessionWorkBlockRailContext);
+}
+
+const AgentSessionTranscriptTurnMetaContext =
+ React.createContext(
+ EMPTY_TRANSCRIPT_TURN_META,
+ );
+
+export const AgentSessionTranscriptTurnMetaProvider =
+ AgentSessionTranscriptTurnMetaContext.Provider;
+
+export function useAgentSessionTranscriptTurnMeta() {
+ return React.useContext(AgentSessionTranscriptTurnMetaContext);
+}
diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockDisclosure.tsx b/desktop/src/features/agents/ui/agentSessionWorkBlockDisclosure.tsx
new file mode 100644
index 0000000000..1b5483bd3a
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionWorkBlockDisclosure.tsx
@@ -0,0 +1,121 @@
+import * as React from "react";
+
+/**
+ * The reader's fold/unfold choices for focus-mode work blocks, keyed by the
+ * STEP ids a choice was taken on rather than by block id.
+ *
+ * ## Why this cannot be `useState` inside the block
+ *
+ * A work block's id is derived from its first step (`work-block:${items[0].id}`,
+ * see `groupConversationWorkBlocks`), and block membership is recomputed from
+ * scratch on every render. `findFinalAnswerId` exempts only the LAST assistant
+ * message from the block, so when a second assistant message arrives the first
+ * one stops being the answer, becomes work, and the run of steps around it
+ * merges: two blocks become one.
+ *
+ * frame 2 work-block:th:1[th:1,tool:1] msg:1 work-block:th:2[th:2,tool:2]
+ * frame 3 work-block:th:1[th:1,tool:1,msg:1,th:2,tool:2] msg:2
+ *
+ * `work-block:th:2` ceases to exist, so React unmounts it and any state it
+ * owned goes with it. A reader who had opened it to read the steps was folded
+ * shut by an event they did not cause — the agent posting another message.
+ *
+ * Keying on step ids survives that, because the steps are what the reader's
+ * intent was actually about: they are still on screen after the merge, just
+ * inside a different block.
+ */
+export type WorkBlockDisclosureChoices = ReadonlyMap;
+
+/**
+ * The reader's choice for a block, or `null` when they have not taken one and
+ * policy still owns the fold.
+ *
+ * An open choice wins over a folded one. After a merge the block can carry both
+ * — one constituent opened, another folded — and the two are not symmetric:
+ * showing steps a reader asked to see costs them a scroll, while hiding steps a
+ * reader asked to see loses the thing they were reading. Same reason the
+ * absorbed block's own choice cannot simply be dropped.
+ */
+export function readWorkBlockChoice(
+ choices: WorkBlockDisclosureChoices,
+ itemIds: readonly string[],
+): boolean | null {
+ let folded: boolean | null = null;
+ for (const itemId of itemIds) {
+ const choice = choices.get(itemId);
+ if (choice === true) return true;
+ if (choice === false) folded = false;
+ }
+ return folded;
+}
+
+/**
+ * Record one choice against every step the block currently holds.
+ *
+ * Written across all of them, not just the first, because the block this choice
+ * was taken on may later be absorbed into a block that begins with a different
+ * step — and the reader's intent has to be findable from whichever step the
+ * merged block happens to start with.
+ */
+export function recordWorkBlockChoice(
+ choices: WorkBlockDisclosureChoices,
+ itemIds: readonly string[],
+ choice: boolean,
+): WorkBlockDisclosureChoices {
+ const next = new Map(choices);
+ for (const itemId of itemIds) next.set(itemId, choice);
+ return next;
+}
+
+const EMPTY_CHOICES: WorkBlockDisclosureChoices = new Map();
+
+export type WorkBlockDisclosureStore = {
+ choices: WorkBlockDisclosureChoices;
+ choose: (itemIds: readonly string[], choice: boolean) => void;
+};
+
+/**
+ * `null` when no transcript is providing a store.
+ *
+ * Deliberately not a no-op store: a work block rendered on its own — which is
+ * how most of its tests mount it — would then swallow every click silently and
+ * look like a component whose disclosure is broken. `useWorkBlockDisclosure`
+ * falls back to component-local state instead, which is correct in isolation
+ * (nothing is regrouping the block) and is the behaviour a reader of that
+ * component would expect. A provider emits no DOM, so `default` and
+ * `compactPreview` markup is unaffected either way.
+ */
+const WorkBlockDisclosureContext =
+ React.createContext(null);
+
+export function AgentSessionWorkBlockDisclosureProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const store = useWorkBlockDisclosureState();
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** The store's state and updater, so the fallback path can reuse it verbatim. */
+export function useWorkBlockDisclosureState(): WorkBlockDisclosureStore {
+ const [choices, setChoices] =
+ React.useState(EMPTY_CHOICES);
+ const choose = React.useCallback(
+ (itemIds: readonly string[], choice: boolean) => {
+ setChoices((current) => recordWorkBlockChoice(current, itemIds, choice));
+ },
+ [],
+ );
+
+ return React.useMemo(() => ({ choices, choose }), [choices, choose]);
+}
+
+export function useWorkBlockDisclosureStore() {
+ return React.useContext(WorkBlockDisclosureContext);
+}
diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs
new file mode 100644
index 0000000000..2029411c9e
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.test.mjs
@@ -0,0 +1,581 @@
+/**
+ * Work-block grouping: which of a turn's items fold onto the rail, where the
+ * block splits, and what its folded line says.
+ */
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ formatPreviousStepsLabel,
+ formatWorkBlockSummaryLabel,
+ groupConversationWorkBlocks,
+ projectWorkBlockEntries,
+ summarizeWorkBlock,
+ windowWorkBlockEntries,
+ WORK_BLOCK_LIVE_WINDOW_SIZE,
+} from "./agentSessionWorkBlockGrouping.ts";
+
+const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
+
+/**
+ * Project the block's items with the block's own turn live — i.e. the agent is
+ * working on `turn-1` right now, which is the turn every fixture here belongs
+ * to. Most projection cases are about what a step looks like while its own turn
+ * is being worked, so this is the default lens.
+ */
+const projectLive = (items) =>
+ projectWorkBlockEntries(items, { liveTurnId: "turn-1" });
+
+/**
+ * Project with no live turn — reopened history, or a session that has ended.
+ * The distinction matters because a tool item's `executing` status is written
+ * once at the start and never revised if the agent dies, so it is only evidence
+ * of work in flight when a session still owns the turn.
+ */
+const projectHistory = (items) =>
+ projectWorkBlockEntries(items, { liveTurnId: null });
+
+function thought(id, timestamp = "2026-06-14T19:00:02.000Z") {
+ return {
+ ...SHARED,
+ id,
+ type: "thought",
+ renderClass: "thought",
+ title: "Thinking",
+ text: "weighing the options",
+ timestamp,
+ };
+}
+
+function tool(id, overrides = {}) {
+ return {
+ ...SHARED,
+ id,
+ type: "tool",
+ renderClass: "shell",
+ descriptor: {
+ renderClass: "shell",
+ label: "Ran a command",
+ preview: "cargo test",
+ tone: "neutral",
+ source: "shell",
+ },
+ title: "Ran a command",
+ toolName: "shell",
+ buzzToolName: null,
+ status: "completed",
+ args: { command: "cargo test" },
+ result: "ok",
+ isError: false,
+ timestamp: "2026-06-14T19:00:04.000Z",
+ startedAt: "2026-06-14T19:00:04.000Z",
+ completedAt: "2026-06-14T19:00:05.000Z",
+ ...overrides,
+ };
+}
+
+function message(id, role, overrides = {}) {
+ return {
+ ...SHARED,
+ id,
+ type: "message",
+ renderClass: "message",
+ role,
+ title: role === "user" ? "Ada" : "Test Agent",
+ text: "hello",
+ timestamp: "2026-06-14T19:00:09.000Z",
+ ...overrides,
+ };
+}
+
+function plan(id) {
+ return {
+ ...SHARED,
+ id,
+ type: "plan",
+ renderClass: "plan",
+ title: "Plan",
+ text: "- [ ] ship it",
+ timestamp: "2026-06-14T19:00:07.000Z",
+ };
+}
+
+function lifecycle(id, renderClass) {
+ return {
+ ...SHARED,
+ id,
+ type: "lifecycle",
+ renderClass,
+ title:
+ renderClass === "permission"
+ ? "Permission requested"
+ : "Context compacted",
+ text: "",
+ timestamp: "2026-06-14T19:00:06.000Z",
+ };
+}
+
+const itemSegments = (...items) =>
+ items.map((item) => ({ kind: "item", item }));
+const kinds = (segments) => segments.map((segment) => segment.kind);
+const blockItemIds = (segment) => segment.block.items.map((item) => item.id);
+
+// ---- membership ----
+
+test("thinking, tool steps and the prompt/answer split fold into one block", () => {
+ const prompt = {
+ kind: "prompt",
+ user: message("msg:user", "user"),
+ context: null,
+ setup: [],
+ };
+ const answer = message("msg:answer", "assistant");
+ const grouped = groupConversationWorkBlocks([
+ prompt,
+ ...itemSegments(thought("thought:1"), tool("tool:1"), tool("tool:2")),
+ { kind: "item", item: answer },
+ ]);
+
+ assert.deepEqual(kinds(grouped), ["prompt", "work-block", "item"]);
+ assert.deepEqual(blockItemIds(grouped[1]), ["thought:1", "tool:1", "tool:2"]);
+ assert.equal(
+ grouped[2].item.id,
+ "msg:answer",
+ "the turn's answer stays outside the block as prose",
+ );
+});
+
+test("a single work item still becomes a block", () => {
+ // No minimum: a lone step must not render through a different mechanism than
+ // a run of steps, or the rail would appear and disappear by count.
+ const grouped = groupConversationWorkBlocks(itemSegments(tool("tool:1")));
+ assert.deepEqual(kinds(grouped), ["work-block"]);
+ assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
+});
+
+test("an interim agent note is work but the final answer is not", () => {
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(
+ tool("tool:1"),
+ message("msg:interim", "assistant"),
+ tool("tool:2"),
+ message("msg:answer", "assistant"),
+ ),
+ );
+
+ assert.deepEqual(kinds(grouped), ["work-block", "item"]);
+ assert.deepEqual(
+ blockItemIds(grouped[0]),
+ ["tool:1", "msg:interim", "tool:2"],
+ "a mid-turn note reads as progress and belongs on the rail",
+ );
+ assert.equal(grouped[1].item.id, "msg:answer");
+});
+
+test("only the LAST assistant message is treated as the answer", () => {
+ // Two trailing notes: the earlier one is still work.
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(
+ tool("tool:1"),
+ message("msg:first", "assistant"),
+ message("msg:last", "assistant"),
+ ),
+ );
+ assert.deepEqual(kinds(grouped), ["work-block", "item"]);
+ assert.deepEqual(blockItemIds(grouped[0]), ["tool:1", "msg:first"]);
+ assert.equal(grouped[1].item.id, "msg:last");
+});
+
+test("a turn with no answer folds all of its work", () => {
+ // Mid-turn: nothing has been answered yet, so every item is work and the
+ // block must not hold back its most recent step waiting for an answer.
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(thought("thought:1"), tool("tool:1")),
+ );
+ assert.deepEqual(kinds(grouped), ["work-block"]);
+ assert.deepEqual(blockItemIds(grouped[0]), ["thought:1", "tool:1"]);
+});
+
+// ---- what stays out ----
+
+test("the plan stays a sibling and splits the work around it", () => {
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(tool("tool:1"), plan("plan:1"), tool("tool:2")),
+ );
+
+ assert.deepEqual(kinds(grouped), ["work-block", "item", "work-block"]);
+ assert.equal(grouped[1].item.id, "plan:1");
+ assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
+ assert.deepEqual(blockItemIds(grouped[2]), ["tool:2"]);
+});
+
+test("message sends stay outside the work block as chat bubbles", () => {
+ const send = tool("tool:send", {
+ args: { content: "Posted an update" },
+ buzzToolName: "send_message",
+ descriptor: {
+ renderClass: "message",
+ label: "Sent message",
+ preview: "Posted an update",
+ tone: "neutral",
+ source: "buzz",
+ },
+ renderClass: "message",
+ toolName: "send_message",
+ });
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(tool("tool:1"), send, tool("tool:2")),
+ );
+
+ assert.deepEqual(kinds(grouped), ["work-block", "item", "work-block"]);
+ assert.deepEqual(blockItemIds(grouped[0]), ["tool:1"]);
+ assert.equal(
+ grouped[1].item.id,
+ "tool:send",
+ "the existing message presenter can render the send as a readable bubble",
+ );
+ assert.deepEqual(blockItemIds(grouped[2]), ["tool:2"]);
+});
+
+test("permission gates and errors stay loud, in position", () => {
+ for (const renderClass of ["permission", "error", "status"]) {
+ const grouped = groupConversationWorkBlocks(
+ itemSegments(
+ tool("tool:1"),
+ lifecycle(`life:${renderClass}`, renderClass),
+ tool("tool:2"),
+ ),
+ );
+ assert.deepEqual(
+ kinds(grouped),
+ ["work-block", "item", "work-block"],
+ `${renderClass} must never fold into a collapsed block`,
+ );
+ assert.equal(
+ grouped[1].item.id,
+ `life:${renderClass}`,
+ `${renderClass} must stay where it happened, not be lifted out of order`,
+ );
+ }
+});
+
+test("the user's prompt is never work", () => {
+ const grouped = groupConversationWorkBlocks([
+ {
+ kind: "prompt",
+ user: message("msg:user", "user"),
+ context: null,
+ setup: [],
+ },
+ ]);
+ assert.deepEqual(kinds(grouped), ["prompt"]);
+});
+
+test("setup segments neither join a block nor split one", () => {
+ // Setup renders as a quiet divider, not as work, and contributes no items —
+ // so it must not sit between two blocks that ought to be one.
+ const grouped = groupConversationWorkBlocks([
+ { kind: "item", item: tool("tool:1") },
+ { kind: "setup", items: [] },
+ { kind: "item", item: tool("tool:2") },
+ ]);
+ assert.deepEqual(kinds(grouped), ["work-block", "setup", "work-block"]);
+});
+
+test("a default-variant summary is expanded back to its leaf steps", () => {
+ // The other variants' "Read 3 files" summaries are a competing answer to the
+ // same grouping problem. Nesting one inside the block would cost the reader a
+ // second click to reach a step.
+ const grouped = groupConversationWorkBlocks([
+ {
+ kind: "summary",
+ summary: {
+ id: "summary:shell:tool:1",
+ label: "Ran 2 commands",
+ count: 2,
+ items: [tool("tool:1"), tool("tool:2")],
+ renderClass: "shell",
+ variant: "same-kind",
+ timestamp: "2026-06-14T19:00:04.000Z",
+ },
+ },
+ ]);
+
+ assert.deepEqual(kinds(grouped), ["work-block"]);
+ assert.deepEqual(blockItemIds(grouped[0]), ["tool:1", "tool:2"]);
+});
+
+// ---- identity ----
+
+test("the block id is derived from its first step so appends do not remount it", () => {
+ const first = groupConversationWorkBlocks(itemSegments(tool("tool:1")));
+ const grown = groupConversationWorkBlocks(
+ itemSegments(tool("tool:1"), tool("tool:2"), tool("tool:3")),
+ );
+ assert.equal(
+ grown[0].block.id,
+ first[0].block.id,
+ "a growing block must keep its identity or the reader's disclosure choice is lost",
+ );
+});
+
+// ---- projection ----
+
+test("every item projects to exactly one rail kind, paired with its own item type", () => {
+ // The closed set is the point: glyph and body render off a switch over these
+ // three, so an item that projected to nothing (or to two things) is what let
+ // a note pick up a wrench on the abandoned card.
+ //
+ // The pairing half is what makes the body switch able to read `item.text`
+ // directly. `WorkBlockEntry` is a discriminated union, so `{ kind: "note",
+ // item: }` does not type-check — but a hand-written swap between the
+ // two prose branches is the easy mistake, and TypeScript is stripped at
+ // runtime, so the pairing is asserted here as well.
+ const entries = projectLive([
+ thought("thought:1"),
+ message("msg:interim", "assistant"),
+ tool("tool:1"),
+ ]);
+ assert.deepEqual(
+ entries.map((entry) => entry.kind),
+ ["thought", "note", "tool"],
+ );
+ assert.deepEqual(
+ entries.map((entry) => [entry.kind, entry.item.type]),
+ [
+ ["thought", "thought"],
+ ["note", "message"],
+ ["tool", "tool"],
+ ],
+ "a kind always carries its own item type — the body switch reads the item without re-checking",
+ );
+ assert.deepEqual(
+ entries.map((entry) => entry.item.id),
+ ["thought:1", "msg:interim", "tool:1"],
+ "projection preserves arrival order",
+ );
+});
+
+test("only a tool step can be running or failed", () => {
+ const stateOf = (item) => projectLive([item])[0].state;
+
+ assert.equal(stateOf(tool("t", { status: "executing" })), "running");
+ assert.equal(stateOf(tool("t", { status: "pending" })), "running");
+ assert.equal(
+ stateOf(tool("t", { status: "failed" })),
+ "failed",
+ "a failed status is a failure even without isError",
+ );
+ assert.equal(
+ stateOf(tool("t", { isError: true })),
+ "failed",
+ "an error result is a failure even when the status reads completed",
+ );
+ assert.equal(stateOf(tool("t")), "settled");
+
+ // Prose has no outcome of its own. A thought whose text happens to mention a
+ // failure, or a note, must not colour the rail or the folded count.
+ assert.equal(stateOf(thought("thought:1")), "settled");
+ assert.equal(stateOf(message("msg:1", "assistant")), "settled");
+});
+
+test("a step that is both running and errored still reads as running", () => {
+ // Order matters: a tool can carry a stale isError from a retry while the new
+ // attempt executes. Reporting it as failed would fold a live block's count to
+ // "N steps · 1 failed" while the work is still in flight.
+ assert.equal(
+ projectLive([tool("t", { status: "executing", isError: true })])[0].state,
+ "running",
+ );
+});
+
+// ---- liveness ----
+
+/**
+ * An abandoned step is not a running step.
+ *
+ * `executing` is written when a step starts and never revised if the agent dies
+ * first, so reopened history keeps that status forever. In this block `running`
+ * is not just a glyph — one running entry makes `summarizeWorkBlock` report
+ * `isActive`, which suppresses the folded summary line and holds the rail open.
+ * Ungated, a single orphaned step therefore renders finished history as work in
+ * progress, pulsing indefinitely.
+ */
+test("an executing step whose turn is not live reads as settled, not running", () => {
+ const orphan = tool("t", { status: "executing", completedAt: null });
+
+ assert.equal(
+ projectLive([orphan])[0].state,
+ "running",
+ "the same item IS running while a session owns its turn",
+ );
+ assert.equal(
+ projectHistory([orphan])[0].state,
+ "settled",
+ "with no live turn the status only says the step began, not that it is happening",
+ );
+ assert.equal(
+ projectWorkBlockEntries([orphan], { liveTurnId: "turn-2" })[0].state,
+ "settled",
+ "an agent live on a LATER turn does not resurrect an earlier turn's abandoned step",
+ );
+});
+
+test("a pending step whose turn is not live reads as settled too", () => {
+ // Both in-flight statuses go through the same gate; `pending` is the one a
+ // crash between queue and start leaves behind.
+ const queued = tool("t", { status: "pending", completedAt: null });
+ assert.equal(projectLive([queued])[0].state, "running");
+ assert.equal(projectHistory([queued])[0].state, "settled");
+});
+
+test("an abandoned step is settled rather than failed, so it never inflates the failure count", () => {
+ // We do not know an abandoned step failed — only that nobody finished it.
+ // Counting it as a failure would put "1 failed" on the folded line for work
+ // that may well have succeeded without its terminal update being recorded.
+ const status = summarizeWorkBlock(
+ projectHistory([
+ tool("tool:1"),
+ tool("tool:2", { status: "executing", completedAt: null }),
+ ]),
+ { streamingItemId: null },
+ );
+ assert.deepEqual(status, { count: 2, failedCount: 0, isActive: false });
+ assert.equal(
+ formatWorkBlockSummaryLabel(status),
+ "2 steps",
+ "reopened history folds to a neutral count",
+ );
+});
+
+test("a genuinely failed step is still failed when its turn is not live", () => {
+ // The gate is only about the in-flight statuses. A step that recorded a
+ // failure recorded a fact, and history must keep reporting it.
+ const failed = () => tool("t", { isError: true, status: "failed" });
+ assert.equal(projectHistory([failed()])[0].state, "failed");
+ assert.equal(
+ summarizeWorkBlock(projectHistory([failed()]), { streamingItemId: null })
+ .failedCount,
+ 1,
+ );
+});
+
+test("a step with no turn id is not owned by a live turn", () => {
+ // `null === null` must not read as ownership: an item that never recorded a
+ // turn cannot be shown to belong to the live one.
+ assert.equal(
+ projectWorkBlockEntries(
+ [tool("t", { status: "executing", completedAt: null, turnId: null })],
+ { liveTurnId: null },
+ )[0].state,
+ "settled",
+ );
+});
+
+// ---- status ----
+
+test("summarizeWorkBlock counts steps and failures", () => {
+ const status = summarizeWorkBlock(
+ projectLive([
+ tool("tool:1"),
+ tool("tool:2", { isError: true, status: "failed" }),
+ thought("thought:1"),
+ ]),
+ { streamingItemId: null },
+ );
+ assert.deepEqual(status, { count: 3, failedCount: 1, isActive: false });
+});
+
+test("a block is active when a step is running OR when it holds the streaming item", () => {
+ assert.equal(
+ summarizeWorkBlock(
+ projectLive([tool("tool:1", { status: "executing", completedAt: null })]),
+ { streamingItemId: null },
+ ).isActive,
+ true,
+ "a step reporting itself as executing is enough",
+ );
+
+ // A thought streaming in carries no tool status, so status alone would miss it.
+ const streamingThought = projectLive([thought("thought:1")]);
+ assert.equal(
+ summarizeWorkBlock(streamingThought, { streamingItemId: "thought:1" })
+ .isActive,
+ true,
+ "the list's streaming hint covers work that carries no status of its own",
+ );
+ assert.equal(
+ summarizeWorkBlock(streamingThought, { streamingItemId: "other" }).isActive,
+ false,
+ "a streaming item in a DIFFERENT block must not make this one live",
+ );
+});
+
+// ---- labels ----
+
+test("the folded line names a failure rather than hiding it behind a count", () => {
+ assert.equal(
+ formatWorkBlockSummaryLabel({ count: 6, failedCount: 0, isActive: false }),
+ "6 steps",
+ );
+ assert.equal(
+ formatWorkBlockSummaryLabel({ count: 6, failedCount: 1, isActive: false }),
+ "6 steps · 1 failed",
+ );
+ assert.equal(
+ formatWorkBlockSummaryLabel({ count: 6, failedCount: 2, isActive: false }),
+ "6 steps · 2 failed",
+ );
+ assert.equal(
+ formatWorkBlockSummaryLabel({ count: 1, failedCount: 0, isActive: false }),
+ "1 step",
+ "a single step must not read as '1 steps'",
+ );
+});
+
+test("the previous-steps label is singular for one step", () => {
+ assert.equal(formatPreviousStepsLabel(1), "1 previous step");
+ assert.equal(formatPreviousStepsLabel(4), "4 previous steps");
+});
+
+// ---- live window ----
+
+const entryIds = (entries) => entries.map((entry) => entry.item.id);
+
+test("a live block shows the last N steps in true order and hides the rest", () => {
+ const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id)));
+ const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
+ isActive: true,
+ });
+
+ assert.equal(visibleEntries.length, WORK_BLOCK_LIVE_WINDOW_SIZE);
+ assert.deepEqual(
+ entryIds(visibleEntries),
+ ["c", "d", "e"],
+ "the window is chronological — arrival order, not reversed",
+ );
+ assert.deepEqual(entryIds(hiddenEntries), ["a", "b"]);
+});
+
+test("a finished block shows every step", () => {
+ const entries = projectLive(["a", "b", "c", "d", "e"].map((id) => tool(id)));
+ const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
+ isActive: false,
+ });
+ assert.equal(hiddenEntries.length, 0);
+ assert.equal(visibleEntries.length, 5);
+});
+
+test("a live block at or under the window size hides nothing", () => {
+ const entries = projectLive(["a", "b", "c"].map((id) => tool(id)));
+ const { hiddenEntries, visibleEntries } = windowWorkBlockEntries(entries, {
+ isActive: true,
+ });
+ assert.equal(
+ hiddenEntries.length,
+ 0,
+ "no disclosure until there is a step to hide",
+ );
+ assert.equal(visibleEntries.length, 3);
+});
diff --git a/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
new file mode 100644
index 0000000000..abdefb7109
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionWorkBlockGrouping.ts
@@ -0,0 +1,434 @@
+import type {
+ TranscriptTurnSegment,
+ TranscriptDisplayBlock,
+} from "./agentSessionTranscriptGrouping";
+import { buildCompactToolSummary } from "./agentSessionToolSummary";
+import type { TranscriptItem } from "./agentSessionTypes";
+
+/** Reasoning. */
+type WorkBlockThoughtItem = Extract;
+/**
+ * An interim agent note: an assistant message that is not the turn's answer.
+ * The `role` intersection is what makes a *user* message unrepresentable as a
+ * note, so the rail cannot present the reader's own prompt as agent progress.
+ */
+type WorkBlockNoteItem = Extract & {
+ role: "assistant";
+};
+/** A step the agent ran. */
+type WorkBlockToolItem = Extract;
+
+/**
+ * The items a work block admits, as a closed union.
+ *
+ * This is the type-level half of `isWorkItem`: a block cannot hold a plan, a
+ * lifecycle row or the turn's answer, so no downstream code has to consider
+ * what those would look like on the rail. Adding a `TranscriptItem` variant to
+ * this union is a deliberate act that forces a projection decision (see
+ * `projectWorkBlockEntry`) rather than letting the new type inherit another
+ * kind's presentation.
+ */
+export type WorkBlockItem =
+ | WorkBlockThoughtItem
+ | WorkBlockNoteItem
+ | WorkBlockToolItem;
+
+/**
+ * One turn's work block: the thinking, tool steps and interim agent notes that
+ * happened between the reader's prompt and the agent's answer, presented as a
+ * single rail rather than as a row per item.
+ */
+export type TranscriptWorkBlock = {
+ /**
+ * Stable identity, derived from the FIRST item in the block. A block grows in
+ * place as later steps stream in, so keying on the first item keeps the id
+ * append-stable — anything derived from the last item would remount the block
+ * on every append and throw away the reader's disclosure choice.
+ */
+ id: string;
+ /** Steps in true arrival order. */
+ items: WorkBlockItem[];
+ /** Timestamp of the block's first step. */
+ timestamp: string;
+};
+
+/**
+ * A turn's segments as the `conversation` variant renders them: the shared
+ * segment union plus the work block, which only this variant produces.
+ */
+export type TranscriptConversationSegment =
+ | TranscriptTurnSegment
+ | { kind: "work-block"; block: TranscriptWorkBlock };
+
+/**
+ * Which items are *work* — the material the block absorbs.
+ *
+ * - **thoughts** — reasoning is a step on the rail, not a sibling disclosure.
+ * - **tool steps**, including failed ones, except message sends. A failure
+ * belongs to the work it happened in; the folded line reports it
+ * (`N steps · 1 failed`) so it is never hidden behind a neutral count.
+ * Message sends stay outside as chat bubbles: they are communication the
+ * reader should be able to read and open, not generic work telemetry.
+ * - **interim agent notes** — an assistant message that is not the turn's
+ * answer. Unlike a message-send tool, a note is narration within the turn,
+ * so it stays on the rail as progress.
+ *
+ * Everything else stays outside and keeps its own row. Plans stay a sibling
+ * (Buzz's checklist is a first-class surface, not a step), and lifecycle rows —
+ * permission gates, errors, status/compaction notices — are exactly the rows a
+ * reader may need to act on or reinterpret the rest of the turn through, so
+ * they never fold into a collapsed block.
+ *
+ * A type guard rather than a predicate, so the admitted set is checked once
+ * here and every later stage receives `WorkBlockItem` — the reason the entry
+ * projection can be exhaustive at all.
+ */
+function isWorkItem(
+ item: TranscriptItem,
+ finalAnswerId: string | null,
+): item is WorkBlockItem {
+ if (item.type === "thought") return true;
+ if (item.type === "tool") {
+ return buildCompactToolSummary(item).presentation !== "message";
+ }
+ return (
+ item.type === "message" &&
+ item.role === "assistant" &&
+ item.id !== finalAnswerId
+ );
+}
+
+/**
+ * The turn's answer: its LAST assistant message.
+ *
+ * Deliberately positional rather than liveness-derived. While a turn streams,
+ * the message currently arriving is already the presumptive answer, so the same
+ * rule holds mid-turn and after it settles — the block does not reshuffle its
+ * membership at the moment a turn completes.
+ */
+function findFinalAnswerId(segments: TranscriptTurnSegment[]): string | null {
+ for (let index = segments.length - 1; index >= 0; index -= 1) {
+ const segment = segments[index];
+ if (
+ segment.kind === "item" &&
+ segment.item.type === "message" &&
+ segment.item.role === "assistant"
+ ) {
+ return segment.item.id;
+ }
+ }
+ return null;
+}
+
+/**
+ * The leaf items a segment contributes as candidate work, in reading order.
+ *
+ * Summary segments are expanded back to their leaf tool rows: the default
+ * variant's "Read 3 files" summaries are a *different* answer to the same
+ * grouping problem, and nesting them inside the block would give the reader two
+ * collapsed layers to open before reaching a step. The block is the one
+ * grouping in this variant.
+ */
+function segmentWorkCandidates(
+ segment: TranscriptTurnSegment,
+): TranscriptItem[] {
+ if (segment.kind === "item") return [segment.item];
+ if (segment.kind === "summary") return segment.summary.items;
+ return [];
+}
+
+/**
+ * The segment's candidates as admitted work items, or `null` when the segment
+ * is not (entirely) work and therefore keeps its own row.
+ *
+ * All-or-nothing per segment, as before: a summary segment is one grouping
+ * decision the shared code already made, so admitting half of it would emit its
+ * remaining rows outside the block they belong to. Returning the narrowed array
+ * rather than a boolean is what carries `WorkBlockItem` into the block — an
+ * `.every()` guard cannot narrow the array it tested.
+ */
+function admittedWorkItems(
+ segment: TranscriptTurnSegment,
+ finalAnswerId: string | null,
+): WorkBlockItem[] | null {
+ const candidates = segmentWorkCandidates(segment);
+ if (candidates.length === 0) return null;
+
+ const admitted: WorkBlockItem[] = [];
+ for (const item of candidates) {
+ if (!isWorkItem(item, finalAnswerId)) return null;
+ admitted.push(item);
+ }
+ return admitted;
+}
+
+/**
+ * Collapse each maximal run of consecutive work items in a turn into one work
+ * block.
+ *
+ * ## Why runs, and not "everything between the prompt and the answer"
+ *
+ * A turn is normally one block: prompt → (thinking, tools, notes) → answer, with
+ * the plan checklist as a sibling. The span reading only differs when a
+ * non-work row lands *inside* the work — a permission gate, an error, a
+ * mid-turn plan update — and there the two readings disagree about order:
+ * producing a single block would mean lifting that row out and re-emitting it
+ * somewhere it did not happen.
+ *
+ * Splitting instead keeps every row where it occurred. That matters most for
+ * exactly the rows this is about: a permission gate is a question asked at a
+ * moment, and an error is the reason the steps after it look the way they do.
+ * Moving either away from its position would cost the reader the thing that
+ * makes it legible. So an interruption genuinely splits the work, and the seam
+ * is the interruption itself.
+ */
+export function groupConversationWorkBlocks(
+ segments: TranscriptTurnSegment[],
+): TranscriptConversationSegment[] {
+ const finalAnswerId = findFinalAnswerId(segments);
+ const grouped: TranscriptConversationSegment[] = [];
+
+ let pending: WorkBlockItem[] = [];
+ const flush = () => {
+ if (pending.length === 0) return;
+ grouped.push({
+ kind: "work-block",
+ block: {
+ id: `work-block:${pending[0].id}`,
+ items: pending,
+ timestamp: pending[0].timestamp,
+ },
+ });
+ pending = [];
+ };
+
+ for (const segment of segments) {
+ const work = admittedWorkItems(segment, finalAnswerId);
+
+ if (work !== null) {
+ pending.push(...work);
+ continue;
+ }
+
+ flush();
+ grouped.push(segment);
+ }
+
+ flush();
+ return grouped;
+}
+
+/** Apply work-block grouping to every turn in a display block. */
+export function conversationSegmentsForBlock(
+ block: Extract,
+): TranscriptConversationSegment[] {
+ return groupConversationWorkBlocks(block.segments);
+}
+
+/** A tool step's rail state. Only a tool step can be in flight or have failed. */
+export type WorkBlockEntryState = "running" | "failed" | "settled";
+
+/**
+ * One projected rail row: what a row *is*, as a closed set.
+ *
+ * - `thought` — reasoning.
+ * - `note` — an interim agent message: prose the agent addressed to the
+ * reader mid-turn, which is not the turn's answer. berd models this as a
+ * distinct `progress` entry and gives it the same speech-bubble glyph as a
+ * thought, because it reads as the agent talking, not as the agent acting.
+ * - `tool` — a step the agent ran.
+ *
+ * Projection happens ONCE per item, and everything downstream — the glyph, the
+ * body, the folded line's counts — reads this rather than re-deriving it. Two
+ * independent classifications of the same item is exactly how the headline and
+ * the chain eligibility drifted apart on the abandoned tool-chain card, and
+ * asking `item.type === ...` at each render site is what let a note fall
+ * through to the tool branch and pick up a wrench.
+ *
+ * A discriminated union rather than a product of independent
+ * `{ item, kind, state }` fields, so the model itself rules out the
+ * combinations a product type leaves representable — and which every render
+ * site would otherwise have to defend against:
+ *
+ * - a `note` kind carrying a thought item (or vice versa), which is why the
+ * body switch previously had to re-check `item.type` and had a silent
+ * empty-string branch for the mismatch it could not otherwise handle;
+ * - prose that claims to be `running` or `failed`. Only a tool step has an
+ * outcome, so `state` is fixed to `settled` on the prose kinds — an edit
+ * that let a thought report a failure would not compile, rather than
+ * quietly adding to the folded line's `N failed`.
+ */
+export type WorkBlockEntry =
+ | { kind: "thought"; item: WorkBlockThoughtItem; state: "settled" }
+ | { kind: "note"; item: WorkBlockNoteItem; state: "settled" }
+ | { kind: "tool"; item: WorkBlockToolItem; state: WorkBlockEntryState };
+
+/**
+ * A tool step's outcome.
+ *
+ * Order matters: a tool can carry a stale `isError` from a retry while the new
+ * attempt executes, and reporting that as failed would fold a live block's
+ * count to "N steps · 1 failed" while the work is still in flight.
+ *
+ * `executing`/`pending` only counts as *running* when a session actually owns
+ * the step's turn. That status is written when the step starts and is never
+ * revised if the agent dies first, so on its own it says "this step began", not
+ * "this step is happening" — reopened history full of abandoned steps would
+ * otherwise present as work in progress forever. See `liveTurnId`.
+ *
+ * An abandoned step is reported as `settled`, not as a new state: we do not
+ * know that it failed, so it must not count toward the folded line's
+ * `N failed`, and inventing a third outcome would put a marker on the rail for
+ * something the reader cannot act on. It renders as the neutral step it is,
+ * with whatever detail it managed to record.
+ */
+function toolEntryState(
+ item: WorkBlockToolItem,
+ liveTurnId: string | null,
+): WorkBlockEntryState {
+ if (item.status === "executing" || item.status === "pending") {
+ // Compared rather than tested for truthiness: an item with no turn id
+ // cannot be owned by a live turn, and `null === null` must not read as
+ // ownership.
+ return liveTurnId !== null && item.turnId === liveTurnId
+ ? "running"
+ : "settled";
+ }
+ if (item.isError || item.status === "failed") return "failed";
+ return "settled";
+}
+
+/**
+ * Project one admitted item into its rail entry.
+ *
+ * The switch is exhaustive over `WorkBlockItem` with no default: adding a
+ * variant to that union without deciding what it looks like on the rail leaves
+ * this function without an ending return, which is a type error. That is the
+ * whole point of the closed union — a new item type must fail loudly here
+ * instead of falling through to the tool branch and wearing a wrench.
+ */
+function projectWorkBlockEntry(
+ item: WorkBlockItem,
+ liveTurnId: string | null,
+): WorkBlockEntry {
+ switch (item.type) {
+ case "thought":
+ return { item, kind: "thought", state: "settled" };
+ case "message":
+ return { item, kind: "note", state: "settled" };
+ case "tool":
+ return { item, kind: "tool", state: toolEntryState(item, liveTurnId) };
+ }
+}
+
+/**
+ * Project a block's items into rail entries, in true arrival order.
+ *
+ * `liveTurnId` is required rather than optional: whether a step is running is
+ * not a property of the step alone, and a default would let a caller that has
+ * not thought about liveness get the old spins-forever behaviour silently.
+ */
+export function projectWorkBlockEntries(
+ items: WorkBlockItem[],
+ options: { liveTurnId: string | null },
+): WorkBlockEntry[] {
+ return items.map((item) => projectWorkBlockEntry(item, options.liveTurnId));
+}
+
+export type WorkBlockStatus = {
+ /** Total steps on the rail. */
+ count: number;
+ /** Steps that failed. */
+ failedCount: number;
+ /**
+ * Whether work is still happening: a step is pending/executing, or the turn
+ * is live and its streaming item belongs to this block.
+ */
+ isActive: boolean;
+};
+
+/**
+ * Aggregate state of a block, read off the SAME projection the rail renders.
+ *
+ * `isActive` is what decides live-vs-finished presentation, so it accepts both
+ * evidence sources: an entry projected as `running`, and the list's
+ * streaming-item hint. Either alone leaves a real gap — a thought streaming in
+ * carries no tool status, and a tool left executing when the observer stream
+ * drops would otherwise pin the block open forever if we trusted status alone.
+ */
+export function summarizeWorkBlock(
+ entries: WorkBlockEntry[],
+ options: { streamingItemId: string | null },
+): WorkBlockStatus {
+ let failedCount = 0;
+ let isActive = false;
+
+ for (const entry of entries) {
+ if (entry.state === "failed") failedCount += 1;
+ if (entry.state === "running") isActive = true;
+ if (
+ options.streamingItemId !== null &&
+ entry.item.id === options.streamingItemId
+ ) {
+ isActive = true;
+ }
+ }
+
+ return { count: entries.length, failedCount, isActive };
+}
+
+function pluralSteps(count: number) {
+ return count === 1 ? "1 step" : `${count} steps`;
+}
+
+/**
+ * The folded line for a finished block.
+ *
+ * berd's is a bare count. The failure clause is a deliberate departure: a count
+ * alone is the one thing a reader cannot tell a clean run from a broken one by,
+ * and a fold that hides a failure behind a neutral number invites them not to
+ * open it.
+ */
+export function formatWorkBlockSummaryLabel(status: WorkBlockStatus): string {
+ const steps = pluralSteps(status.count);
+ if (status.failedCount === 0) return steps;
+ return `${steps} · ${status.failedCount} failed`;
+}
+
+/** Label for the older steps tucked above the live window. */
+export function formatPreviousStepsLabel(count: number): string {
+ return count === 1 ? "1 previous step" : `${count} previous steps`;
+}
+
+/**
+ * How many steps stay on the rail while work is in flight. The rest go behind
+ * the "N previous steps" disclosure, so a long run does not push the answer off
+ * screen while the reader is watching it arrive.
+ */
+export const WORK_BLOCK_LIVE_WINDOW_SIZE = 3;
+
+export type WorkBlockWindow = {
+ /** Entries rendered on the rail, in true order. */
+ visibleEntries: WorkBlockEntry[];
+ /** Older entries behind the disclosure, in true order. */
+ hiddenEntries: WorkBlockEntry[];
+};
+
+/**
+ * Chronological window over a live block: the last N steps in true arrival
+ * order, with everything older behind the disclosure. A finished (or reader-
+ * expanded) block shows every step, so windowing only applies while live.
+ */
+export function windowWorkBlockEntries(
+ entries: WorkBlockEntry[],
+ options: { isActive: boolean },
+): WorkBlockWindow {
+ if (!options.isActive || entries.length <= WORK_BLOCK_LIVE_WINDOW_SIZE) {
+ return { hiddenEntries: [], visibleEntries: entries };
+ }
+ const splitAt = entries.length - WORK_BLOCK_LIVE_WINDOW_SIZE;
+ return {
+ hiddenEntries: entries.slice(0, splitAt),
+ visibleEntries: entries.slice(splitAt),
+ };
+}
diff --git a/desktop/src/features/channels/coverDrawerCloseRequest.test.mjs b/desktop/src/features/channels/coverDrawerCloseRequest.test.mjs
new file mode 100644
index 0000000000..75a3bf70db
--- /dev/null
+++ b/desktop/src/features/channels/coverDrawerCloseRequest.test.mjs
@@ -0,0 +1,21 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ requestCoverDrawerClose,
+ subscribeToCoverDrawerCloseRequest,
+} from "./coverDrawerCloseRequest.ts";
+
+test("cover drawer close requests reach active subscribers only", () => {
+ let calls = 0;
+ const unsubscribe = subscribeToCoverDrawerCloseRequest(() => {
+ calls += 1;
+ });
+
+ requestCoverDrawerClose();
+ assert.equal(calls, 1);
+
+ unsubscribe();
+ requestCoverDrawerClose();
+ assert.equal(calls, 1);
+});
diff --git a/desktop/src/features/channels/coverDrawerCloseRequest.ts b/desktop/src/features/channels/coverDrawerCloseRequest.ts
new file mode 100644
index 0000000000..6e8f2b705e
--- /dev/null
+++ b/desktop/src/features/channels/coverDrawerCloseRequest.ts
@@ -0,0 +1,22 @@
+const listeners = new Set<() => void>();
+
+/**
+ * Request dismissal of the channel's open cover drawer.
+ *
+ * One channel of a channel pane is covered at a time (focus-mode thread or
+ * agent activity), so this needs no discriminator — whichever drawer is open
+ * subscribes and closes.
+ */
+export function requestCoverDrawerClose(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+/** Subscribe the active cover drawer to external dismissal requests. */
+export function subscribeToCoverDrawerCloseRequest(
+ listener: () => void,
+): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs b/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs
deleted file mode 100644
index 6f30d7ec6d..0000000000
--- a/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs
+++ /dev/null
@@ -1,21 +0,0 @@
-import assert from "node:assert/strict";
-import test from "node:test";
-
-import {
- requestFocusedThreadClose,
- subscribeToFocusedThreadCloseRequest,
-} from "./focusedThreadCloseRequest.ts";
-
-test("focus thread close requests reach active subscribers only", () => {
- let calls = 0;
- const unsubscribe = subscribeToFocusedThreadCloseRequest(() => {
- calls += 1;
- });
-
- requestFocusedThreadClose();
- assert.equal(calls, 1);
-
- unsubscribe();
- requestFocusedThreadClose();
- assert.equal(calls, 1);
-});
diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.ts b/desktop/src/features/channels/focusedThreadCloseRequest.ts
deleted file mode 100644
index 3628d70767..0000000000
--- a/desktop/src/features/channels/focusedThreadCloseRequest.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-const listeners = new Set<() => void>();
-
-/** Request dismissal of an open focus-mode thread drawer. */
-export function requestFocusedThreadClose(): void {
- for (const listener of listeners) {
- listener();
- }
-}
-
-/** Subscribe the active channel surface to focus-mode dismissal requests. */
-export function subscribeToFocusedThreadCloseRequest(
- listener: () => void,
-): () => void {
- listeners.add(listener);
- return () => listeners.delete(listener);
-}
diff --git a/desktop/src/features/channels/lib/agentSessionPanelPresentation.test.mjs b/desktop/src/features/channels/lib/agentSessionPanelPresentation.test.mjs
new file mode 100644
index 0000000000..114466d176
--- /dev/null
+++ b/desktop/src/features/channels/lib/agentSessionPanelPresentation.test.mjs
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getAgentSessionPanelPresentation } from "./agentSessionPanelPresentation.ts";
+
+test("the cover drawer owns motion and gets standalone, opaque chrome", () => {
+ assert.deepEqual(
+ getAgentSessionPanelPresentation({
+ isCoverDrawer: true,
+ isSinglePanelView: false,
+ useSplitAuxiliaryPane: true,
+ }),
+ {
+ enterMotion: false,
+ isSinglePanelView: true,
+ layout: "standalone",
+ transcriptVariant: "conversation",
+ transparentChrome: false,
+ },
+ );
+});
+
+test("the split pane keeps docked chrome and its own enter motion", () => {
+ assert.deepEqual(
+ getAgentSessionPanelPresentation({
+ isCoverDrawer: false,
+ isSinglePanelView: false,
+ useSplitAuxiliaryPane: true,
+ }),
+ {
+ enterMotion: true,
+ isSinglePanelView: false,
+ layout: "split",
+ transcriptVariant: undefined,
+ transparentChrome: true,
+ },
+ );
+});
+
+test("narrow viewports keep today's overlay and single-panel presentations", () => {
+ for (const isSinglePanelView of [false, true]) {
+ assert.deepEqual(
+ getAgentSessionPanelPresentation({
+ isCoverDrawer: false,
+ isSinglePanelView,
+ useSplitAuxiliaryPane: false,
+ }),
+ {
+ enterMotion: true,
+ isSinglePanelView,
+ layout: "standalone",
+ transcriptVariant: undefined,
+ transparentChrome: false,
+ },
+ );
+ }
+});
diff --git a/desktop/src/features/channels/lib/agentSessionPanelPresentation.ts b/desktop/src/features/channels/lib/agentSessionPanelPresentation.ts
new file mode 100644
index 0000000000..a22ad8d0c8
--- /dev/null
+++ b/desktop/src/features/channels/lib/agentSessionPanelPresentation.ts
@@ -0,0 +1,65 @@
+import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext";
+
+/**
+ * `AnimatePresence` key shared by every agent activity presentation.
+ *
+ * The split pane and the cover drawer are two containers for one session, so
+ * presence is a property of the session, not of either container — crossing the
+ * viewport breakpoint changes how it is shown, not whether it is open.
+ */
+export const AGENT_SESSION_SURFACE_KEY = "agent-session-surface";
+
+export type AgentSessionPanelPresentation = {
+ enterMotion: boolean;
+ isSinglePanelView: boolean;
+ layout: "standalone" | "split";
+ transcriptVariant: AgentSessionTranscriptVariant | undefined;
+ transparentChrome: boolean;
+};
+
+type AgentSessionPanelPresentationOptions = {
+ /** The panel is rendered inside the agent activity cover drawer. */
+ isCoverDrawer: boolean;
+ isSinglePanelView: boolean;
+ useSplitAuxiliaryPane: boolean;
+};
+
+/**
+ * Maps channel presentation into the agent session panel's layout props.
+ *
+ * The transcript variant is pinned here rather than inferred from panel width.
+ * The cover drawer is the reading surface, so it gets `conversation`; every
+ * other host keeps the dense activity feed. Width is a proxy that breaks — the
+ * split pane can be dragged wide and a narrow overlay can be tall — so the
+ * presentation that decided to cover is what decides the reading view too.
+ */
+export function getAgentSessionPanelPresentation({
+ isCoverDrawer,
+ isSinglePanelView,
+ useSplitAuxiliaryPane,
+}: AgentSessionPanelPresentationOptions): AgentSessionPanelPresentation {
+ if (isCoverDrawer) {
+ return {
+ // The drawer animates itself; a second slide inside it would compound.
+ enterMotion: false,
+ // Fills the drawer, and selects the standalone header chrome that owns
+ // its own backdrop — the drawer is not sharing the channel's header, and
+ // it has no resizable neighbour to draw a resize border against.
+ isSinglePanelView: true,
+ layout: "standalone",
+ transcriptVariant: "conversation",
+ transparentChrome: false,
+ };
+ }
+
+ return {
+ enterMotion: true,
+ isSinglePanelView: useSplitAuxiliaryPane ? false : isSinglePanelView,
+ layout: useSplitAuxiliaryPane ? "split" : "standalone",
+ // Undefined, not `"default"`: the panel already defaults, and naming it
+ // here would claim this function decides the non-cover variant when the
+ // profile panel's `compactPreview` is chosen at its own call site.
+ transcriptVariant: undefined,
+ transparentChrome: useSplitAuxiliaryPane,
+ };
+}
diff --git a/desktop/src/features/channels/lib/channelAuxiliarySurface.test.mjs b/desktop/src/features/channels/lib/channelAuxiliarySurface.test.mjs
new file mode 100644
index 0000000000..bbad854850
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelAuxiliarySurface.test.mjs
@@ -0,0 +1,182 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ resolveChannelAuxiliarySurface,
+ resolveChannelCoverDrawer,
+} from "./channelAuxiliarySurface.ts";
+
+const NO_SURFACE = {
+ channelManagementOpen: false,
+ hasActiveChannel: true,
+ hasProfilePanel: false,
+ hasSelectedAgent: false,
+ hasThreadHead: false,
+ shouldShowThreadSkeleton: false,
+};
+
+test("no candidate surface resolves to nothing", () => {
+ assert.equal(resolveChannelAuxiliarySurface(NO_SURFACE), null);
+});
+
+test("every candidate open at once still resolves to exactly one surface", () => {
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ channelManagementOpen: true,
+ hasActiveChannel: true,
+ hasProfilePanel: true,
+ hasSelectedAgent: true,
+ hasThreadHead: true,
+ shouldShowThreadSkeleton: true,
+ }),
+ "channel-management",
+ );
+});
+
+test("surfaces resolve in priority order as higher ones drop away", () => {
+ const all = {
+ channelManagementOpen: true,
+ hasActiveChannel: true,
+ hasProfilePanel: true,
+ hasSelectedAgent: true,
+ hasThreadHead: true,
+ shouldShowThreadSkeleton: true,
+ };
+
+ assert.equal(
+ resolveChannelAuxiliarySurface({ ...all, channelManagementOpen: false }),
+ "thread",
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...all,
+ channelManagementOpen: false,
+ hasThreadHead: false,
+ }),
+ "thread-skeleton",
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...all,
+ channelManagementOpen: false,
+ hasThreadHead: false,
+ shouldShowThreadSkeleton: false,
+ }),
+ "agent-session",
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...all,
+ channelManagementOpen: false,
+ hasSelectedAgent: false,
+ hasThreadHead: false,
+ shouldShowThreadSkeleton: false,
+ }),
+ "profile",
+ );
+});
+
+test("channel-scoped surfaces need an active channel", () => {
+ const withoutChannel = { ...NO_SURFACE, hasActiveChannel: false };
+
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...withoutChannel,
+ channelManagementOpen: true,
+ }),
+ null,
+ );
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...withoutChannel,
+ hasSelectedAgent: true,
+ }),
+ null,
+ );
+ // The profile panel is identity-scoped, so it survives without a channel.
+ assert.equal(
+ resolveChannelAuxiliarySurface({
+ ...withoutChannel,
+ hasProfilePanel: true,
+ }),
+ "profile",
+ );
+});
+
+test("agent activity always covers at wide viewports, whatever the thread preference", () => {
+ for (const threadViewMode of ["focus", "split"]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface: "agent-session",
+ threadViewMode,
+ useSplitAuxiliaryPane: true,
+ }),
+ "agent-session",
+ );
+ }
+});
+
+test("threads cover only in focus mode", () => {
+ for (const surface of ["thread", "thread-skeleton"]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: true,
+ }),
+ "thread",
+ );
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "split",
+ useSplitAuxiliaryPane: true,
+ }),
+ null,
+ );
+ }
+});
+
+test("narrow and single-panel viewports never cover", () => {
+ for (const surface of ["agent-session", "thread", "thread-skeleton"]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: false,
+ }),
+ null,
+ );
+ }
+});
+
+test("split-only surfaces never cover", () => {
+ for (const surface of ["channel-management", "profile", null]) {
+ assert.equal(
+ resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: true,
+ }),
+ null,
+ );
+ }
+});
+
+test("only one drawer can cover, because only one surface resolves", () => {
+ // Thread and agent activity both requested: the surface resolution picks the
+ // thread, so the agent drawer cannot also be covering.
+ const surface = resolveChannelAuxiliarySurface({
+ ...NO_SURFACE,
+ hasSelectedAgent: true,
+ hasThreadHead: true,
+ });
+ const drawer = resolveChannelCoverDrawer({
+ surface,
+ threadViewMode: "focus",
+ useSplitAuxiliaryPane: true,
+ });
+
+ assert.equal(surface, "thread");
+ assert.equal(drawer, "thread");
+});
diff --git a/desktop/src/features/channels/lib/channelAuxiliarySurface.ts b/desktop/src/features/channels/lib/channelAuxiliarySurface.ts
new file mode 100644
index 0000000000..c461146ca6
--- /dev/null
+++ b/desktop/src/features/channels/lib/channelAuxiliarySurface.ts
@@ -0,0 +1,88 @@
+import type { ThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
+
+/**
+ * The one auxiliary surface a channel shows beside (or over) its timeline.
+ *
+ * Exactly one at a time, in the fixed priority order below.
+ *
+ * This priority is a **safety net, not the product rule.** Last-opened-wins is
+ * implemented by the open handlers, which clear the competing state as they open
+ * (`useChannelAgentSessions`: `openAgentSession` clears the thread head, and
+ * `openThreadAndCloseAgentSession` clears the agent session). By the time this
+ * resolver runs, at most one candidate should normally be live.
+ *
+ * The ordering only decides cases the handlers cannot: two candidates present at
+ * once with no ordering information between them — a restored/hand-edited URL
+ * carrying both `agentSession` and a thread param, or a stale param that has not
+ * been reconciled yet. Then it picks deterministically instead of rendering two
+ * surfaces. Do not read priority as "thread beats agent" in the UX; a thread
+ * opened while activity is up wins because the handler cleared the agent
+ * session, and activity opened over a thread wins for the same reason.
+ */
+export type ChannelAuxiliarySurface =
+ | "agent-session"
+ | "channel-management"
+ | "profile"
+ | "thread"
+ | "thread-skeleton";
+
+type ChannelAuxiliarySurfaceOptions = {
+ channelManagementOpen: boolean;
+ hasActiveChannel: boolean;
+ hasProfilePanel: boolean;
+ hasSelectedAgent: boolean;
+ hasThreadHead: boolean;
+ shouldShowThreadSkeleton: boolean;
+};
+
+/** Which auxiliary surface the channel pane should render, if any. */
+export function resolveChannelAuxiliarySurface({
+ channelManagementOpen,
+ hasActiveChannel,
+ hasProfilePanel,
+ hasSelectedAgent,
+ hasThreadHead,
+ shouldShowThreadSkeleton,
+}: ChannelAuxiliarySurfaceOptions): ChannelAuxiliarySurface | null {
+ if (channelManagementOpen && hasActiveChannel) return "channel-management";
+ if (hasThreadHead) return "thread";
+ if (shouldShowThreadSkeleton) return "thread-skeleton";
+ if (hasActiveChannel && hasSelectedAgent) return "agent-session";
+ if (hasProfilePanel) return "profile";
+ return null;
+}
+
+/** A cover drawer overlays the channel content area instead of splitting it. */
+export type ChannelCoverDrawer = "agent-session" | "thread";
+
+type ChannelCoverDrawerOptions = {
+ surface: ChannelAuxiliarySurface | null;
+ threadViewMode: ThreadViewMode;
+ useSplitAuxiliaryPane: boolean;
+};
+
+/**
+ * Which surface, if any, presents as a cover drawer.
+ *
+ * Threads honour the user's view-mode preference. Agent activity does not and
+ * deliberately offers no toggle: its transcript is tool calls, diffs, and
+ * command output, which a 380px side pane cannot show usefully — so at any
+ * viewport wide enough for two panes it always covers. Narrow/overlay and
+ * single-panel viewports keep their existing presentations for both.
+ *
+ * Returning a single value is what makes the two drawers mutually exclusive:
+ * there is one covered slot, and the resolved surface owns it.
+ */
+export function resolveChannelCoverDrawer({
+ surface,
+ threadViewMode,
+ useSplitAuxiliaryPane,
+}: ChannelCoverDrawerOptions): ChannelCoverDrawer | null {
+ if (!useSplitAuxiliaryPane) return null;
+
+ if (surface === "thread" || surface === "thread-skeleton") {
+ return threadViewMode === "focus" ? "thread" : null;
+ }
+
+ return surface === "agent-session" ? "agent-session" : null;
+}
diff --git a/desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs b/desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs
new file mode 100644
index 0000000000..33dee31be3
--- /dev/null
+++ b/desktop/src/features/channels/lib/coverDrawerFocusSlot.test.mjs
@@ -0,0 +1,65 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ claimCoverDrawerFocus,
+ hasCoverDrawerFocusClaim,
+ releaseCoverDrawerFocus,
+} from "./coverDrawerFocusSlot.ts";
+
+test("a fresh claim holds the slot", () => {
+ const claim = claimCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(claim), true);
+});
+
+test("a successor's claim supersedes the outgoing drawer's", () => {
+ // The replacement case: the outgoing drawer's restore is deferred a frame,
+ // and by the time it runs the incoming drawer has claimed and taken focus.
+ const outgoing = claimCoverDrawerFocus();
+ const incoming = claimCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(outgoing), false);
+ assert.equal(hasCoverDrawerFocusClaim(incoming), true);
+});
+
+test("only the newest claim holds the slot across a chain of replacements", () => {
+ const claims = [
+ claimCoverDrawerFocus(),
+ claimCoverDrawerFocus(),
+ claimCoverDrawerFocus(),
+ ];
+
+ const newest = claims.at(-1);
+ for (const claim of claims.slice(0, -1)) {
+ assert.equal(hasCoverDrawerFocusClaim(claim), false);
+ }
+ assert.equal(hasCoverDrawerFocusClaim(newest), true);
+});
+
+test("releasing invalidates the outstanding claim without granting a new one", () => {
+ // The view-mode switch case: nothing replaces the drawer, but the caller has
+ // already placed focus, so the drawer's own restore must not fire.
+ const claim = claimCoverDrawerFocus();
+ releaseCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(claim), false);
+});
+
+test("a claim taken after a release holds the slot again", () => {
+ releaseCoverDrawerFocus();
+ const claim = claimCoverDrawerFocus();
+
+ assert.equal(hasCoverDrawerFocusClaim(claim), true);
+});
+
+test("claims are never reused, so a stale claim cannot alias a live one", () => {
+ const first = claimCoverDrawerFocus();
+ releaseCoverDrawerFocus();
+ claimCoverDrawerFocus();
+ releaseCoverDrawerFocus();
+ const later = claimCoverDrawerFocus();
+
+ assert.notEqual(first, later);
+ assert.equal(hasCoverDrawerFocusClaim(first), false);
+});
diff --git a/desktop/src/features/channels/lib/coverDrawerFocusSlot.ts b/desktop/src/features/channels/lib/coverDrawerFocusSlot.ts
new file mode 100644
index 0000000000..21a7c802af
--- /dev/null
+++ b/desktop/src/features/channels/lib/coverDrawerFocusSlot.ts
@@ -0,0 +1,52 @@
+/**
+ * Single-slot coordinator for cover drawer focus restoration.
+ *
+ * There is one covered slot in a channel, so there is one focus claim. A drawer
+ * takes a claim when it captures focus and checks it back at teardown: it hands
+ * focus to whatever it stole it from only if its claim is still the current one.
+ *
+ * This exists because restoration is deferred a frame (the drawer has to let the
+ * exit animation start before moving focus), and a lot can happen in that frame.
+ * When one drawer replaces another the successor mounts and focuses itself while
+ * the outgoing one is still animating out, so an unconditional restore would
+ * yank focus out of the new drawer and into the channel that is now inert —
+ * unreachable by keyboard, with no visible focus ring anywhere.
+ *
+ * A monotonic generation answers "was I superseded?" without anyone having to
+ * name their successor or reason about mount ordering: any newer claim, from any
+ * source, invalidates every older one. That keeps the decision out of the drawer
+ * primitive, which cannot see the surrounding presentation and should not be
+ * interpreting it.
+ */
+
+let generation = 0;
+
+/**
+ * Take the focus slot for a drawer that has just captured focus.
+ *
+ * The returned claim is opaque; pass it to {@link hasCoverDrawerFocusClaim} at
+ * teardown to find out whether this drawer is still the one that owes focus back.
+ */
+export function claimCoverDrawerFocus(): number {
+ generation += 1;
+ return generation;
+}
+
+/** Whether `claim` is still the current claim, i.e. nothing has superseded it. */
+export function hasCoverDrawerFocusClaim(claim: number): boolean {
+ return claim === generation;
+}
+
+/**
+ * Invalidate the outstanding claim because focus has been placed deliberately
+ * elsewhere.
+ *
+ * For transitions that retire a drawer without another drawer replacing it, and
+ * that have already decided where focus belongs — switching a thread from the
+ * focus drawer to the split pane, which moves focus to the view-mode toggle or
+ * the thread body itself. Without this the drawer's own restore would fire a
+ * frame later and pull focus back to whatever opened the thread.
+ */
+export function releaseCoverDrawerFocus(): void {
+ generation += 1;
+}
diff --git a/desktop/src/features/channels/lib/coverDrawerLayout.ts b/desktop/src/features/channels/lib/coverDrawerLayout.ts
new file mode 100644
index 0000000000..00aa710f0f
--- /dev/null
+++ b/desktop/src/features/channels/lib/coverDrawerLayout.ts
@@ -0,0 +1,34 @@
+/**
+ * Layout constants shared by the channel's cover drawers.
+ *
+ * A cover drawer overlays the channel content area with a right-anchored
+ * surface rather than splitting the row into two resizable panes. Both the
+ * focus-mode thread drawer and the agent activity drawer are the same
+ * geometry — only their contents and their open condition differ.
+ */
+
+/**
+ * Width of the channel sliver left visible to the left of a cover drawer.
+ *
+ * Wide enough to read a truncated `‹ #channel` label and to be a comfortable,
+ * full-height click target back to the channel, but narrow enough that the
+ * drawer still reads as the primary surface. The sliver keeps showing the real,
+ * still-mounted channel timeline (dimmed by the scrim) so the user never loses
+ * their place.
+ */
+export const COVER_DRAWER_SLIVER_WIDTH_PX = 72;
+
+/**
+ * Horizontal distance a cover drawer travels on enter/exit.
+ *
+ * Deliberately a fraction of the drawer's own width rather than a true slide
+ * from off-screen: opening a thread is a high-frequency act — threads are chat
+ * sessions and get flipped between constantly — and full-width travel turns a
+ * routine move into ceremony. Short travel keeps it light and repeatable.
+ *
+ * The floor matters as much as the ceiling: the shared 24px side-panel nudge is
+ * only ~3% of this drawer's width, which reads as no movement at all, leaving
+ * the opacity fade as the only perceptible change. This is large enough for the
+ * eye to track a direction and for the ease to have somewhere to decelerate.
+ */
+export const COVER_DRAWER_TRAVEL_PX = 120;
diff --git a/desktop/src/features/channels/lib/threadFocusLayout.ts b/desktop/src/features/channels/lib/threadFocusLayout.ts
index f14f3399bd..edfbb3a0cd 100644
--- a/desktop/src/features/channels/lib/threadFocusLayout.ts
+++ b/desktop/src/features/channels/lib/threadFocusLayout.ts
@@ -5,17 +5,6 @@
* rather than splitting the row into two resizable panes.
*/
-/**
- * Width of the channel sliver left visible to the left of the focus drawer.
- *
- * Wide enough to read a truncated `‹ #channel` label and to be a comfortable,
- * full-height click target back to the channel, but narrow enough that the
- * drawer still reads as the primary surface. The sliver keeps showing the real,
- * still-mounted channel timeline (dimmed by the scrim) so the user never loses
- * their place.
- */
-export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
-
/**
* Max width of the centered message column inside the focus drawer.
*
@@ -26,21 +15,6 @@ export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72;
*/
export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880;
-/**
- * Horizontal distance the focus drawer travels on enter/exit.
- *
- * Deliberately a fraction of the drawer's own width rather than a true slide
- * from off-screen: opening a thread is a high-frequency act — threads are chat
- * sessions and get flipped between constantly — and full-width travel turns a
- * routine move into ceremony. Short travel keeps it light and repeatable.
- *
- * The floor matters as much as the ceiling: the shared 24px side-panel nudge is
- * only ~3% of this drawer's width, which reads as no movement at all, leaving
- * the opacity fade as the only perceptible change. This is large enough for the
- * eye to track a direction and for the ease to have somewhere to decelerate.
- */
-export const THREAD_FOCUS_DRAWER_TRAVEL_PX = 120;
-
/**
* `AnimatePresence` key shared by both thread layouts.
*
diff --git a/desktop/src/features/channels/ui/AgentActivityDrawer.tsx b/desktop/src/features/channels/ui/AgentActivityDrawer.tsx
new file mode 100644
index 0000000000..e0121baa44
--- /dev/null
+++ b/desktop/src/features/channels/ui/AgentActivityDrawer.tsx
@@ -0,0 +1,43 @@
+import type * as React from "react";
+
+import { CoverDrawer } from "@/features/channels/ui/CoverDrawer";
+
+type AgentActivityDrawerProps = {
+ channelName: string;
+ children: React.ReactNode;
+ onClose: () => void;
+};
+
+/**
+ * The agent activity presentation at wide viewports: a {@link CoverDrawer}
+ * holding the channel-scoped agent session panel.
+ *
+ * Unlike the thread, activity has no split/focus choice — a transcript of tool
+ * calls, diffs, and command output is only legible at this width, so it always
+ * covers and never offers a presentation toggle. That also means it needs no
+ * conditional focus-restore rule: closing it is always a real dismissal, so
+ * focus returns to whatever opened it.
+ *
+ * Escape stays with the panel rather than being claimed by the drawer. The
+ * panel already closes on Escape in this presentation, and routing the key
+ * through its `useEscapeKey` keeps the settings menu's own dismissal first —
+ * the thread drawer claims the key instead because its composer's mention
+ * autocomplete would otherwise swallow a press meant for the thread.
+ */
+export function AgentActivityDrawer({
+ channelName,
+ children,
+ onClose,
+}: AgentActivityDrawerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
index c1933f14bb..7609757dfc 100644
--- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
+++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
@@ -16,6 +16,7 @@ import {
scopeByChannel,
} from "@/features/agents/ui/agentSessionPanelLayout";
import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping";
+import type { AgentSessionTranscriptVariant } from "@/features/agents/ui/agentSessionTranscriptContext";
import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
import {
@@ -67,6 +68,12 @@ type AgentSessionThreadPanelProps = {
channel: Channel | null;
channelId?: string | null;
canInterruptTurn: boolean;
+ /**
+ * When false, the panel skips its own slide-in. Set by the cover drawer,
+ * which already animates itself, so the two don't compound into a double
+ * slide. Defaults to animating.
+ */
+ enterMotion?: boolean;
layout?: "standalone" | "split";
isSinglePanelView?: boolean;
profiles?: UserProfileLookup;
@@ -81,6 +88,15 @@ type AgentSessionThreadPanelProps = {
onClose: () => void;
widthPx: number;
transparentChrome?: boolean;
+ /**
+ * Transcript presentation. Caller-pinned only: the panel keeps the dense
+ * activity feed unless a host explicitly asks for `conversation` (the
+ * full-cover focus view does). There is deliberately no width or layout
+ * heuristic here — an automatic wide-pane mode would be a separate product
+ * decision, and swapping the whole presentation as a reader drags a resize
+ * handle across a threshold is not one we want to make implicitly.
+ */
+ transcriptVariant?: AgentSessionTranscriptVariant;
};
export function AgentSessionThreadPanel({
@@ -88,6 +104,7 @@ export function AgentSessionThreadPanel({
canInterruptTurn,
channel,
channelId = null,
+ enterMotion = true,
layout = "standalone",
isSinglePanelView = false,
profiles,
@@ -95,6 +112,7 @@ export function AgentSessionThreadPanel({
onClose,
widthPx,
transparentChrome = false,
+ transcriptVariant = "default",
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const isOverlay = useIsThreadPanelOverlay();
@@ -458,6 +476,7 @@ export function AgentSessionThreadPanel({
return (
diff --git a/desktop/src/features/channels/ui/ChannelAgentSessionSurface.tsx b/desktop/src/features/channels/ui/ChannelAgentSessionSurface.tsx
new file mode 100644
index 0000000000..64589bc316
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelAgentSessionSurface.tsx
@@ -0,0 +1,105 @@
+import type * as React from "react";
+
+import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
+import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
+import { getAgentSessionPanelPresentation } from "@/features/channels/lib/agentSessionPanelPresentation";
+import { AgentActivityDrawer } from "@/features/channels/ui/AgentActivityDrawer";
+import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
+import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import type { Channel } from "@/shared/api/types";
+
+type ChannelAgentSessionSurfaceProps = {
+ activeChannel: Channel;
+ activeChannelId: string | null;
+ activityAgents: BotActivityAgent[];
+ agent: ChannelAgentSessionAgent;
+ /** Render the panel inside the cover drawer instead of the split pane. */
+ isCoverDrawer: boolean;
+ isSinglePanelView: boolean;
+ onBack?: () => void;
+ onClose: () => void;
+ openAgentSessionChannelId: string | null;
+ profiles?: UserProfileLookup;
+ useSplitAuxiliaryPane: boolean;
+ widthPx: number;
+ /**
+ * Applies the split-pane presentation, including its resize affordances.
+ * Supplied by `ChannelPane` because that pane owns the resize state; the
+ * cover-drawer presentation is applied here.
+ */
+ wrapSplitPane: (panel: React.ReactNode) => React.ReactNode;
+};
+
+/**
+ * The channel's agent activity surface: the session panel plus the channel
+ * re-scoping its content and actions depend on.
+ *
+ * Split out of `ChannelPane` so the re-scoping rule below has one home and is
+ * not another branch inside that component's auxiliary-surface chain. Which
+ * presentation this lands in is decided upstream and applied through `wrap`.
+ */
+export function ChannelAgentSessionSurface({
+ activeChannel,
+ activeChannelId,
+ activityAgents,
+ agent,
+ isCoverDrawer,
+ isSinglePanelView,
+ onBack,
+ onClose,
+ openAgentSessionChannelId,
+ profiles,
+ useSplitAuxiliaryPane,
+ widthPx,
+ wrapSplitPane,
+}: ChannelAgentSessionSurfaceProps) {
+ // When the panel was opened from a different channel than the currently
+ // active one, re-scope it to the active channel so that both the
+ // content/header AND channel-backed actions (e.g. Stop current turn) operate
+ // on the same channel object.
+ const effectiveAgentSessionChannelId =
+ openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId
+ ? activeChannelId
+ : openAgentSessionChannelId;
+ const channel = effectiveAgentSessionChannelId
+ ? effectiveAgentSessionChannelId === activeChannel.id
+ ? activeChannel
+ : null
+ : agentSessionSelection.isAgentInActivityList({
+ activityAgents,
+ selectedAgent: agent,
+ })
+ ? activeChannel
+ : null;
+
+ const layoutProps = getAgentSessionPanelPresentation({
+ isCoverDrawer,
+ isSinglePanelView,
+ useSplitAuxiliaryPane,
+ });
+ const panel = (
+
+ );
+
+ return isCoverDrawer ? (
+
+ {panel}
+
+ ) : (
+ wrapSplitPane(panel)
+ );
+}
diff --git a/desktop/src/features/channels/ui/ChannelIdleAuxiliarySurface.tsx b/desktop/src/features/channels/ui/ChannelIdleAuxiliarySurface.tsx
new file mode 100644
index 0000000000..5263e86e1f
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelIdleAuxiliarySurface.tsx
@@ -0,0 +1,86 @@
+import type * as React from "react";
+
+import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
+import {
+ IdleAuxiliaryPanel,
+ type IdleAuxiliaryHeaderControls,
+} from "@/features/channels/ui/IdleAuxiliaryPanel";
+
+type ChannelIdleAuxiliarySurfaceProps = {
+ canResetWidth: boolean;
+ channelName: string;
+ children: React.ReactNode;
+ headerControls?: IdleAuxiliaryHeaderControls;
+ /** Render the panel inside the cover drawer instead of the split pane. */
+ isCoverDrawer: boolean;
+ isSinglePanelView: boolean;
+ onClose: () => void;
+ onResetWidth: () => void;
+ onResizeStart: React.PointerEventHandler;
+ title: string;
+ useSplitAuxiliaryPane: boolean;
+ widthPx: number;
+ /**
+ * Applies the split-pane presentation, including its resize affordances.
+ * Supplied by `ChannelPane` because that pane owns the resize state; the
+ * cover-drawer presentation is applied here.
+ */
+ wrapSplitPane: (panel: React.ReactNode) => React.ReactNode;
+};
+
+/**
+ * The channel's caller-owned idle auxiliary surface, in whichever presentation
+ * was resolved for it.
+ *
+ * Split out of `ChannelPane` alongside `ChannelAgentSessionSurface` so each
+ * auxiliary surface owns its own presentation wiring rather than adding another
+ * pair of closures to that component.
+ *
+ * It reuses `FocusThreadDrawer` rather than `CoverDrawer` directly, so the two
+ * cover presentations stay one surface with one test id; only the accessible
+ * label differs, which is why that label is a prop on the thread drawer.
+ */
+export function ChannelIdleAuxiliarySurface({
+ canResetWidth,
+ channelName,
+ children,
+ headerControls,
+ isCoverDrawer,
+ isSinglePanelView,
+ onClose,
+ onResetWidth,
+ onResizeStart,
+ title,
+ useSplitAuxiliaryPane,
+ widthPx,
+ wrapSplitPane,
+}: ChannelIdleAuxiliarySurfaceProps) {
+ const panel = (
+
+ {children}
+
+ );
+
+ return isCoverDrawer ? (
+
+ {panel}
+
+ ) : (
+ wrapSplitPane(panel)
+ );
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 0d9a5eeb1e..bc23cc6453 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -26,17 +26,22 @@ import {
import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
-import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
-import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle";
+import { ChannelAgentSessionSurface } from "@/features/channels/ui/ChannelAgentSessionSurface";
+import { ChannelIdleAuxiliarySurface } from "@/features/channels/ui/ChannelIdleAuxiliarySurface";
import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer";
+import { AGENT_SESSION_SURFACE_KEY } from "@/features/channels/lib/agentSessionPanelPresentation";
+import {
+ resolveChannelAuxiliarySurface,
+ resolveChannelCoverDrawer,
+} from "@/features/channels/lib/channelAuxiliarySurface";
import { THREAD_SURFACE_KEY } from "@/features/channels/lib/threadFocusLayout";
import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout";
import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
-import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
+import { useCoverDrawerPresence } from "@/features/channels/ui/useCoverDrawerPresence";
import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
@@ -419,10 +424,6 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
- const useFocusThreadDrawer =
- threadViewMode === "focus" &&
- useSplitAuxiliaryPane &&
- (Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
const selectedAgent = React.useMemo(
() =>
agentSessionSelection.resolveSelectedAgentSession({
@@ -433,8 +434,31 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
+ // One resolution for both "which panel" and "which presentation", so the two
+ // cover drawers can never stack: there is a single covered slot and the
+ // resolved surface owns it.
+ const auxiliarySurface = resolveChannelAuxiliarySurface({
+ channelManagementOpen,
+ hasActiveChannel: Boolean(activeChannel),
+ hasProfilePanel: Boolean(profilePanelPubkey),
+ hasSelectedAgent: Boolean(selectedAgent),
+ hasThreadHead: Boolean(threadHeadMessage),
+ shouldShowThreadSkeleton,
+ });
+ const coverDrawer = resolveChannelCoverDrawer({
+ surface: auxiliarySurface,
+ threadViewMode,
+ useSplitAuxiliaryPane,
+ });
+ const useFocusThreadDrawer = coverDrawer === "thread";
+ const useAgentActivityDrawer = coverDrawer === "agent-session";
const hasIdleAuxiliary =
Boolean(idleAuxiliaryPanel) && Boolean(onCloseIdleAuxiliaryPanel);
+ // The caller-owned idle panel sits outside `auxiliarySurface`: it is not part
+ // of the last-opened-wins set, and `idleAuxiliaryOverridesThread` lets it
+ // render ahead of a thread that still owns the covered slot. It cannot
+ // contend for that slot — `shouldUseFocusIdleDrawer` requires every other
+ // candidate to be absent — so exactly one cover drawer is still possible.
const useFocusIdleDrawer = shouldUseFocusIdleDrawer({
channelManagementOpen,
hasAgentSession: Boolean(activeChannel && selectedAgent),
@@ -448,21 +472,16 @@ export const ChannelPane = React.memo(function ChannelPane({
idleAuxiliaryOverridesThread,
hasIdleAuxiliary,
);
- const { channelIsCovered, markExitComplete } = useFocusDrawerPresence(
- useFocusThreadDrawer || useFocusIdleDrawer,
+ const { channelIsCovered, markExitComplete } = useCoverDrawerPresence(
+ coverDrawer !== null || useFocusIdleDrawer,
priorityIdleAuxiliary
? (onCloseIdleAuxiliaryPanel ?? onCloseThread)
- : useFocusThreadDrawer
- ? onCloseThread
- : (onCloseIdleAuxiliaryPanel ?? onCloseThread),
+ : useAgentActivityDrawer
+ ? onCloseAgentSession
+ : useFocusThreadDrawer
+ ? onCloseThread
+ : (onCloseIdleAuxiliaryPanel ?? onCloseThread),
);
- const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
- useThreadViewModeSwitch({
- activeThreadHeadId: threadHeadMessage?.id ?? null,
- externalScrollTargetId: threadScrollTargetId,
- onExternalTargetResolved: onThreadScrollTargetResolved,
- onModeChange: markExitComplete,
- });
const {
handleEditLastOwnMainMessage,
handleEditLastOwnThreadMessage,
@@ -480,13 +499,15 @@ export const ChannelPane = React.memo(function ChannelPane({
threadMessages: threadMessages.map((entry) => entry.message),
useFocusThreadDrawer,
});
+ const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
+ useThreadViewModeSwitch({
+ activeThreadHeadId: threadHeadMessage?.id ?? null,
+ externalScrollTargetId: threadScrollTargetId,
+ onExternalTargetResolved: onThreadScrollTargetResolved,
+ onModeChange: markExitComplete,
+ });
const hasSplitAuxiliaryPane =
- useSplitAuxiliaryPane &&
- (channelManagementOpen ||
- Boolean(threadHeadMessage) ||
- shouldShowThreadSkeleton ||
- Boolean(activeChannel && selectedAgent) ||
- Boolean(profilePanelPubkey));
+ useSplitAuxiliaryPane && auxiliarySurface !== null;
const wrapAux = (
panel: React.ReactNode,
testId: string,
@@ -519,38 +540,36 @@ export const ChannelPane = React.memo(function ChannelPane({
) : (
wrapAux(panel, "message-thread-panel", { key: THREAD_SURFACE_KEY })
);
- const wrapIdlePanel = (panel: React.ReactNode) =>
- useFocusIdleDrawer && onCloseIdleAuxiliaryPanel ? (
- wrapAux(panel, "idle-auxiliary-panel")}
>
- {panel}
-
- ) : (
- wrapAux(panel, "idle-auxiliary-panel")
- );
- const idleAuxiliarySurface =
- idleAuxiliaryPanel && onCloseIdleAuxiliaryPanel
- ? wrapIdlePanel(
-
- {idleAuxiliaryPanel}
- ,
- )
- : null;
+ {idleAuxiliaryPanel}
+
+ ) : null;
+ const wrapAgentSessionSplitPane = (panel: React.ReactNode) =>
+ wrapAux(panel, "agent-session-thread-panel", {
+ key: AGENT_SESSION_SURFACE_KEY,
+ });
const threadHeaderLeading = useSplitAuxiliaryPane ? (
) : undefined;
@@ -803,9 +822,25 @@ export const ChannelPane = React.memo(function ChannelPane({
) : null}
- {/* Serialize replacements so focus drawers keep one travel direction. */}
-
- {channelManagementOpen && activeChannel ? (
+ {/*
+ * `AnimatePresence` keeps a cover drawer mounted through its exit
+ * animation — without it the drawer's own existence condition (derived
+ * from `threadHeadMessage` / the selected agent) goes false on the same
+ * frame as the close, and there is nothing left to animate. It can hold
+ * the real content through the exit rather than a frozen snapshot because
+ * both panels are fully prop-driven.
+ *
+ * Deliberately NOT `mode="wait"` (added here by #6590 to serialize
+ * replacements). Overlapping mount is load-bearing for a cover drawer:
+ * `mode="wait"` unmounts the outgoing drawer before the incoming one
+ * enters, leaving the Escape-handoff guards documented in `CoverDrawer`
+ * with nothing to coordinate, so replacing one cover surface with another
+ * would need a second Escape. Covered by
+ * `agent-activity-cover.spec.ts:349`, which fails deterministically with
+ * `mode="wait"` present.
+ */}
+
+ {auxiliarySurface === "channel-management" && activeChannel ? (
) : priorityIdleAuxiliary && idleAuxiliarySurface ? (
idleAuxiliarySurface
- ) : threadHeadMessage ? (
+ ) : auxiliarySurface === "thread" && threadHeadMessage ? (
(() => {
const panel = (
{
if (isHuddleTranscript) {
return wrapThreadPanel();
@@ -912,44 +947,26 @@ export const ChannelPane = React.memo(function ChannelPane({
);
return wrapThreadPanel(panel);
})()
- ) : activeChannel && selectedAgent ? (
- (() => {
- const effectiveAgentSessionChannelId =
- openAgentSessionChannelId &&
- activeChannel.id !== openAgentSessionChannelId
- ? activeChannelId
- : openAgentSessionChannelId;
- const panel = (
-
- );
- return wrapAux(panel, "agent-session-thread-panel");
- })()
- ) : profilePanelPubkey ? (
+ ) : auxiliarySurface === "agent-session" &&
+ activeChannel &&
+ selectedAgent ? (
+
+ ) : auxiliarySurface === "profile" && profilePanelPubkey ? (
(() => {
const panel = (
void;
+ /**
+ * Whether the drawer claims Escape for itself, ahead of anything inside it.
+ *
+ * Claiming it means a single press always leaves, even from a nested control
+ * that would otherwise handle the key. Leave this off when the drawer's own
+ * content already closes on Escape through `useEscapeKey`, which yields to
+ * nested controls that mark the event handled. Defaults to claiming.
+ */
+ ownsEscape?: boolean;
+ /**
+ * Whether content inside the drawer currently owns Escape ahead of the
+ * drawer's own claim.
+ *
+ * Only meaningful while `ownsEscape` is set. A capture-phase claim runs before
+ * anything inside the drawer, so a drawer that unconditionally closes on
+ * Escape would dismiss the whole surface out from under an in-progress edit
+ * instead of letting that edit cancel first — losing the draft. Setting this
+ * yields the press to the drawer's own subtree for exactly that case; presses
+ * from outside the drawer still close it, so it cannot be wedged open.
+ */
+ escapeYieldsToContent?: boolean;
+ /** Accessible name for the scrim, which is the click target back to the channel. */
+ scrimLabel: string;
+ /**
+ * Test id of the drawer surface. The overlay and scrim derive theirs from it
+ * (`-overlay`, `-scrim`) so one id names the whole presentation.
+ */
+ testId: string;
+};
+
+/**
+ * Scrim over the channel content area behind a cover drawer.
+ *
+ * Veil, not shadow, and no blur: the channel fades toward the surface colour
+ * rather than being darkened. A black wash is a multiply — it scales text and
+ * background down together, so dark-on-light text keeps its contrast ratio and
+ * stays readable at any opacity short of a solid bar. Fading toward
+ * `background` instead compresses text against the surface in both themes,
+ * which is what pushes the sliver back to colour and shape. Matches the shared
+ * header backdrop's `bg-background/80` vocabulary, a touch heavier because this
+ * one has to defeat body text rather than sit over a gap.
+ */
+const COVER_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
+
+/**
+ * Hover eases the veil one step in both themes.
+ *
+ * Feedback that the sliver is a target — deliberately not a peek: one step is
+ * enough to register as interactive without making the channel readable.
+ */
+const COVER_SCRIM_HOVER_CLASS =
+ "hover:bg-background/65 dark:hover:bg-background/70";
+
+/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
+const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
+
+/**
+ * Leave immediately. Shares the enter's fast-start shape rather than the
+ * conventional accelerating ease-in for exits.
+ *
+ * The "exits accelerate away" rule assumes the whole travel is visible; an
+ * ease-in spends its opening frames barely moving and pays that back at the end.
+ * Here the tail is hidden under the opacity fade, so acceleration buys nothing
+ * and those opening frames are the entire perception of responsiveness — a
+ * dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
+ * its total duration. Decisiveness comes from the duration below instead.
+ */
+const EXIT_EASE = ENTER_EASE;
+
+const SCRIM_ENTER_SECONDS = 0.2;
+
+/**
+ * Slightly ahead of the drawer's exit, and deliberately so.
+ *
+ * A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
+ * of it, which reads as lag at the exact moment the user has committed to
+ * leaving. Undimming first hands the channel back the instant it is asked for.
+ */
+const SCRIM_EXIT_SECONDS = 0.12;
+
+/**
+ * Enter: opacity front-loaded, transform long.
+ *
+ * The two channels animate over deliberately different windows, and that
+ * asymmetry is the whole point. Short travel *requires* an opacity fade — an
+ * opaque surface this large appearing 120px off its mark with no fade is a hard
+ * cut, not a slide. But pairing both properties on one timing function (as a
+ * single CSS keyframe must) welds them together for the full duration, and since
+ * opacity covers 100% of its range while transform covers ~3% of the drawer's
+ * width, the fade is what the eye reads. Resolving opacity in the first ~90ms
+ * leaves the remaining ~190ms as pure travel: the fade is over before it
+ * registers, and what's perceived is sliding.
+ *
+ * It also keeps the drawer's own entrance from exposing its contents' load
+ * order. Anything arriving late (replies resolving, media decoding) lands on an
+ * already-opaque surface and reads as "the panel is loading" rather than the UI
+ * assembling itself.
+ */
+const ENTER_TRANSITION = {
+ opacity: { duration: 0.09, ease: "linear" },
+ x: { duration: 0.28, ease: ENTER_EASE },
+} as const;
+
+/**
+ * Exit: half the enter's duration, opacity barely back-loaded.
+ *
+ * Opening and closing are not symmetric tasks. The enter has something to say —
+ * it establishes where the panel came from and that the channel is still behind
+ * it. The exit has nothing to say: attention has already left for the channel,
+ * so its only job is to get out of the way without popping. That makes duration
+ * the thing to spend, and 140ms is about the floor before the drawer reads as
+ * vanishing rather than leaving.
+ *
+ * The opacity hold shrinks with it. Its purpose is to let the drawer commit to
+ * moving before it dissolves, so it reads as sliding out — but at this duration a
+ * hold proportional to the old one would eat half the animation. 20ms is enough
+ * to register solidity in the first frame or two.
+ */
+const EXIT_TRANSITION = {
+ opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
+ x: { duration: 0.14, ease: EXIT_EASE },
+} as const;
+
+/**
+ * Reduced motion keeps a crossfade and drops the travel.
+ *
+ * Travel is the part that's motion; the fade is what makes appearing and
+ * disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
+ * timings would read as dead air on a stationary surface, so both collapse to
+ * one short symmetric fade.
+ */
+const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
+
+/**
+ * Right-anchored drawer that overlays the channel content area.
+ *
+ * Presentation only — it knows nothing about what it covers the channel with.
+ * The thread focus drawer and the agent activity drawer are both this surface
+ * with different contents and different open conditions.
+ *
+ * Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
+ * `AnimatePresence` so the exit animation can run: everything here is absolutely
+ * positioned against the channel content area, so the app sidebar is never
+ * covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
+ * of it remains visible for depth, and the whole scrim (sliver included) is one
+ * tall click target back to the channel. Orientation lives in the drawer's own
+ * header, where the eye already is — the sliver carries no label of its own.
+ *
+ * `z-41` places the drawer above the channel section (whose inner `isolate`
+ * wrapper traps the timeline's z-50 pill, z-40 composer overlay, and z-50 drop
+ * overlay) and the `z-30` shared header backdrop, while staying below the
+ * global `z-45` top chrome. Setting z-index on the positioned container also
+ * gives the drawer its own stacking context, so the panel chrome inside is
+ * isolated.
+ */
+export function CoverDrawer({
+ ariaLabel,
+ children,
+ escapeYieldsToContent = false,
+ onClose,
+ ownsEscape = true,
+ scrimLabel,
+ testId,
+}: CoverDrawerProps) {
+ const prefersReducedMotion = useReducedMotion();
+ /**
+ * False from the moment `AnimatePresence` starts this drawer's exit.
+ *
+ * The covered slot belongs to the drawer that is arriving or settled, not to
+ * one that is animating away, and this is the only signal that distinguishes
+ * them — the focus slot cannot, because a drawer that never captures focus
+ * (its content may take it instead) leaves the outgoing drawer's claim
+ * current. See the Escape handler.
+ */
+ const isPresent = useIsPresent();
+ const travelPx = prefersReducedMotion ? 0 : COVER_DRAWER_TRAVEL_PX;
+ const drawerRef = React.useRef(null);
+ const previousFocusRef = React.useRef(null);
+ /**
+ * Whether the opener has been captured for this drawer instance.
+ *
+ * Distinct from `previousFocusRef.current === null`, which is a legitimate
+ * capture (nothing was focused) and must not be retried. See the capture
+ * effect for why one attempt is all this gets.
+ */
+ const hasCapturedPreviousFocusRef = React.useRef(false);
+
+ React.useEffect(() => {
+ if (!ownsEscape) return;
+ // Stand down for the whole exit: a drawer on its way out does not own the
+ // covered slot, so the key belongs to whatever replaced it.
+ //
+ // `AnimatePresence` keeps a replaced drawer mounted through its exit
+ // animation, so during a replacement two drawers have this listener
+ // installed at once, and capture-phase listeners on the same target fire in
+ // registration order — the outgoing one registered first, so it would
+ // otherwise always win. It then consumes the press via
+ // `stopImmediatePropagation`, which is invisible to the successor, and the
+ // user has to press Escape twice to leave the drawer that just arrived.
+ //
+ // `useEscapeKey` carries the same guard for the same reason. This one is not
+ // sufficient on its own: the agent activity drawer sets `ownsEscape={false}`
+ // and routes the key through its panel, so on that path no code here runs
+ // and it is the exiting *panel*'s `preventDefault` that swallows the press.
+ //
+ // Gating on presence rather than the focus slot is deliberate. The focus
+ // slot is claimed only by a drawer that captures focus, and a successor
+ // whose content takes focus instead never claims it — which leaves the
+ // outgoing drawer's claim current and makes a slot check pass for exactly
+ // the drawer that should stand down. Presence is the state that actually
+ // distinguishes arriving from leaving.
+ if (!isPresent) return;
+
+ function handleEscape(event: KeyboardEvent) {
+ if (event.key !== "Escape") return;
+ // Yield to an in-progress edit inside the drawer: the capture-phase claim
+ // runs first, so without this the press would dismiss the whole surface
+ // and lose the draft instead of cancelling the edit. Scoped to the
+ // drawer's own subtree, so a press from outside still closes it.
+ const target = event.target;
+ if (
+ escapeYieldsToContent &&
+ target instanceof Node &&
+ drawerRef.current?.contains(target)
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ onClose();
+ }
+
+ window.addEventListener("keydown", handleEscape, { capture: true });
+ return () => {
+ window.removeEventListener("keydown", handleEscape, { capture: true });
+ };
+ }, [escapeYieldsToContent, isPresent, onClose, ownsEscape]);
+
+ React.useLayoutEffect(() => {
+ // Capture the opener exactly once per drawer instance.
+ //
+ // `React.StrictMode` replays effects in development as setup → cleanup →
+ // setup, and by that second setup this drawer has already focused itself. An
+ // unconditional read of `document.activeElement` would therefore record the
+ // drawer as its own opener, and a real close would focus a node React has
+ // since detached — leaving focus on ``, keyboard-stranded. Refs survive
+ // the replay, so a one-shot flag is enough. The flag is deliberately not
+ // reset in cleanup: the only cleanup it would see before a real close is the
+ // simulated one, which is exactly what it exists to ignore.
+ //
+ // Re-claiming the focus slot on the replayed setup is correct and stays as
+ // is — that new generation is what makes the first cleanup's deferred
+ // restore stand down.
+ if (!hasCapturedPreviousFocusRef.current) {
+ hasCapturedPreviousFocusRef.current = true;
+ previousFocusRef.current =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+ }
+ const focusClaim = claimCoverDrawerFocus();
+ drawerRef.current?.focus({ preventScroll: true });
+
+ return () => {
+ const previousFocus = previousFocusRef.current;
+ requestAnimationFrame(() => {
+ // Deferred by a frame so the exit animation can start, which is exactly
+ // long enough for a replacing drawer to mount and take focus. Restore
+ // only while this drawer still holds the slot; otherwise the successor
+ // owns focus and restoring would drop it into the inert channel.
+ if (!hasCoverDrawerFocusClaim(focusClaim)) return;
+ previousFocus?.focus({ preventScroll: true });
+ });
+ };
+ }, []);
+
+ return (
+
+
+
+
+
{children}
+
+
+ );
+}
diff --git a/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs b/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs
new file mode 100644
index 0000000000..e164317ca9
--- /dev/null
+++ b/desktop/src/features/channels/ui/CoverDrawerEscape.test.mjs
@@ -0,0 +1,381 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("", {
+ pretendToBeVisual: true,
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ Element: dom.window.Element,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ KeyboardEvent: dom.window.KeyboardEvent,
+ MutationObserver: dom.window.MutationObserver,
+ Node: dom.window.Node,
+ cancelAnimationFrame: dom.window.cancelAnimationFrame,
+ document: dom.window.document,
+ requestAnimationFrame: dom.window.requestAnimationFrame,
+ window: dom.window,
+ });
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: dom.window.navigator,
+ writable: true,
+ });
+ dom.window.matchMedia ??= () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+/**
+ * Renders a drawer holding one focusable child, which stands in for the thread
+ * composer that owns Escape while an edit is in progress.
+ */
+async function renderDrawer({ escapeYieldsToContent }) {
+ const React = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+
+ const closes = [];
+ const view = render(
+ React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ escapeYieldsToContent,
+ onClose: () => closes.push("close"),
+ scrimLabel: "Back to #general",
+ testId: "cover-drawer",
+ },
+ React.createElement("input", { "data-testid": "thread-composer" }),
+ ),
+ );
+
+ return { closes, view };
+}
+
+/**
+ * Renders the replacement window the way `ChannelPane` produces it: one
+ * `AnimatePresence` whose keyed child is swapped, so the outgoing drawer stays
+ * mounted in its exit phase while the successor mounts alongside it.
+ *
+ * Both are the real primitive, and the presence wrapper is real too, because the
+ * bug lives in the interaction between two instances under `AnimatePresence` —
+ * a harness that renders them as two independent trees reports both as present
+ * and cannot see it.
+ */
+async function renderReplacement({ successorOwnsEscape }) {
+ const React = await import("react");
+ const { act, render } = await import("@testing-library/react");
+ const { AnimatePresence } = await import("motion/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+
+ const events = [];
+
+ // Stands in for the agent session panel's own `useEscapeKey`: the successor
+ // drawer does not claim the key, its content handles it.
+ function SuccessorContent() {
+ React.useEffect(() => {
+ function onKeyDown(event) {
+ if (event.key === "Escape") events.push("successor-content-escape");
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+ return React.createElement("div", { "data-testid": "successor-content" });
+ }
+
+ const outgoing = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ key: "outgoing",
+ onClose: () => events.push("outgoing-close"),
+ scrimLabel: "Back to #general",
+ testId: "outgoing-drawer",
+ },
+ React.createElement("div", null, "thread"),
+ );
+ const successor = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Agent activity",
+ key: "successor",
+ onClose: () => events.push("successor-close"),
+ ownsEscape: successorOwnsEscape,
+ scrimLabel: "Back to #general",
+ testId: "successor-drawer",
+ },
+ React.createElement(SuccessorContent),
+ );
+
+ const view = render(React.createElement(AnimatePresence, null, outgoing));
+ await act(async () => {
+ view.rerender(React.createElement(AnimatePresence, null, successor));
+ });
+
+ // Both are mounted: the outgoing drawer is held through its exit animation.
+ // This is the ~210ms window an rAF probe measures in the browser, and it is
+ // the precondition for the assertions below — without it they prove nothing.
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="outgoing-drawer"]')
+ .length,
+ 1,
+ );
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="successor-drawer"]')
+ .length,
+ 1,
+ );
+
+ return { events, view };
+}
+
+function pressEscapeOn(element) {
+ element.dispatchEvent(
+ new dom.window.KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Escape",
+ }),
+ );
+}
+
+/**
+ * Renders the replacement window in the shape production actually has it: each
+ * drawer holds a panel using the real `useEscapeKey`, which is how both the
+ * thread and the agent session panels take the key.
+ *
+ * The synthetic harness above cannot see the second half of this bug. Its
+ * successor listener acts on every press, but `useEscapeKey` deliberately
+ * ignores an event that is already `defaultPrevented` — so an exiting *panel*
+ * that still calls `preventDefault` swallows the press from a real successor
+ * just as thoroughly as the exiting drawer's `stopImmediatePropagation` does,
+ * one layer further down and with no cover-drawer code in the path.
+ */
+async function renderPanelReplacement() {
+ const React = await import("react");
+ const { act, render } = await import("@testing-library/react");
+ const { AnimatePresence } = await import("motion/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+ const { useEscapeKey } = await import("@/shared/hooks/useEscapeKey.ts");
+
+ const events = [];
+
+ function Panel({ label, testId }) {
+ useEscapeKey(
+ React.useCallback(() => events.push(label), [label]),
+ true,
+ );
+ return React.createElement("div", { "data-testid": testId });
+ }
+
+ // The thread drawer claims Escape itself; activity leaves it to its panel.
+ const outgoing = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ key: "outgoing",
+ onClose: () => events.push("outgoing-drawer-close"),
+ scrimLabel: "Back to #general",
+ testId: "outgoing-drawer",
+ },
+ React.createElement(Panel, {
+ label: "outgoing-panel-escape",
+ testId: "outgoing-panel",
+ }),
+ );
+ const successor = React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Agent activity",
+ key: "successor",
+ onClose: () => events.push("successor-drawer-close"),
+ ownsEscape: false,
+ scrimLabel: "Back to #general",
+ testId: "successor-drawer",
+ },
+ React.createElement(Panel, {
+ label: "successor-panel-escape",
+ testId: "successor-panel",
+ }),
+ );
+
+ const view = render(React.createElement(AnimatePresence, null, outgoing));
+ await act(async () => {
+ view.rerender(React.createElement(AnimatePresence, null, successor));
+ });
+
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="outgoing-drawer"]')
+ .length,
+ 1,
+ );
+ assert.equal(
+ dom.window.document.querySelectorAll('[data-testid="successor-panel"]')
+ .length,
+ 1,
+ );
+
+ return { events, view };
+}
+
+function composer() {
+ return dom.window.document.querySelector('[data-testid="thread-composer"]');
+}
+
+test("Escape inside the drawer yields to content while it owns the key", async () => {
+ // The regression this guards (#6575): the drawer claims Escape in the capture
+ // phase, which runs before the composer's own handler. Without the yield, one
+ // press dismisses the entire drawer instead of cancelling the in-progress
+ // edit, and the unsaved draft goes with it.
+ const { closes } = await renderDrawer({ escapeYieldsToContent: true });
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, []);
+});
+
+test("Escape inside the drawer closes it when content does not own the key", async () => {
+ // The default: with no active edit the same press is a dismissal, so the yield
+ // above must be conditional rather than a blanket exemption for the subtree.
+ const { closes } = await renderDrawer({ escapeYieldsToContent: false });
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, ["close"]);
+});
+
+test("Escape from outside the drawer closes it even while content owns the key", async () => {
+ // The yield is scoped to the drawer's own subtree, so an active edit inside
+ // cannot wedge the drawer open against a press from the channel behind it.
+ const { closes } = await renderDrawer({ escapeYieldsToContent: true });
+
+ pressEscapeOn(dom.window.document.body);
+
+ assert.deepEqual(closes, ["close"]);
+});
+
+test("Escape during a replacement reaches the successor, not the exiting drawer", async () => {
+ // The bug this guards: `AnimatePresence` holds the outgoing drawer mounted
+ // through its exit animation (~210ms), and its capture-phase listener calls
+ // `stopImmediatePropagation()`. A successor that does not claim Escape — the
+ // agent activity drawer, which routes the key through its panel's own
+ // `useEscapeKey` — therefore never sees the press, so the user has to press
+ // Escape twice to leave a drawer that just replaced another.
+ const { events, view } = await renderReplacement({
+ successorOwnsEscape: false,
+ });
+
+ pressEscapeOn(
+ dom.window.document.querySelector('[data-testid="successor-content"]'),
+ );
+
+ // The press belongs to the drawer holding the covered slot. The exiting
+ // drawer is on its way out and must not act on it, let alone consume it.
+ assert.deepEqual(events, ["successor-content-escape"]);
+
+ view.unmount();
+});
+
+test("a claiming successor closes on a single Escape during a replacement", async () => {
+ // The same window, with a successor that does claim the key (thread over
+ // activity): exactly one drawer may act, and it must be the new one.
+ const { events, view } = await renderReplacement({
+ successorOwnsEscape: true,
+ });
+
+ pressEscapeOn(
+ dom.window.document.querySelector('[data-testid="successor-content"]'),
+ );
+
+ assert.deepEqual(events, ["successor-close"]);
+
+ view.unmount();
+});
+
+test("Escape during a panel replacement reaches the successor's panel", async () => {
+ // The other half of the same bug, one layer down and with no cover-drawer
+ // code in the path: `useEscapeKey` ignores an event that is already
+ // `defaultPrevented`, so an exiting panel that still calls `preventDefault`
+ // silently consumes the press from its successor's panel. This is the path the
+ // agent activity drawer actually takes — it sets `ownsEscape={false}` and lets
+ // its panel handle the key — so fixing only the drawer's claim leaves the
+ // two-press bug in place.
+ const { events, view } = await renderPanelReplacement();
+
+ pressEscapeOn(
+ dom.window.document.querySelector('[data-testid="successor-panel"]'),
+ );
+
+ // Exactly one handler acts, and it belongs to the arriving surface.
+ assert.deepEqual(events, ["successor-panel-escape"]);
+
+ view.unmount();
+});
+
+test("a lone panel still closes on Escape outside AnimatePresence", async () => {
+ // `useEscapeKey` is used by panels that never animate out (split pane,
+ // single-panel thread, profile). With no `AnimatePresence` above them there is
+ // no presence context at all, and the guard must read as present rather than
+ // as "not exiting yet" — otherwise it would disable Escape for every one of
+ // those surfaces.
+ const React = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { useEscapeKey } = await import("@/shared/hooks/useEscapeKey.ts");
+
+ const closes = [];
+ function Panel() {
+ useEscapeKey(() => closes.push("close"), true);
+ return React.createElement("input", { "data-testid": "thread-composer" });
+ }
+ render(React.createElement(Panel));
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, ["close"]);
+});
+
+test("a lone drawer still closes on Escape under StrictMode", async () => {
+ // The slot claim is taken in a layout effect, which `React.StrictMode` replays
+ // as setup → cleanup → setup. Each setup takes a *new* generation, so the
+ // guard above must be reading whatever the last setup stored rather than a
+ // stale claim from the discarded first pass — otherwise every drawer in
+ // development would ignore Escape entirely. The focus tests cannot see this:
+ // they assert on restore, which the coordinator handles separately.
+ const React = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+
+ const closes = [];
+ render(
+ React.createElement(
+ CoverDrawer,
+ {
+ ariaLabel: "Thread",
+ onClose: () => closes.push("close"),
+ scrimLabel: "Back to #general",
+ testId: "strict-drawer",
+ },
+ React.createElement("input", { "data-testid": "thread-composer" }),
+ ),
+ { reactStrictMode: true },
+ );
+
+ pressEscapeOn(composer());
+
+ assert.deepEqual(closes, ["close"]);
+});
diff --git a/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs b/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs
new file mode 100644
index 0000000000..ce07ddc7e0
--- /dev/null
+++ b/desktop/src/features/channels/ui/CoverDrawerFocusHandoff.test.mjs
@@ -0,0 +1,185 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+// `pretendToBeVisual` is what gives jsdom `requestAnimationFrame`. The drawer
+// defers its focus restore to one, so without it the restore silently never
+// runs and every assertion here would pass for the wrong reason.
+const dom = new JSDOM("", {
+ pretendToBeVisual: true,
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ Element: dom.window.Element,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ MutationObserver: dom.window.MutationObserver,
+ Node: dom.window.Node,
+ // The drawer calls the bare global, not `window.requestAnimationFrame`.
+ cancelAnimationFrame: dom.window.cancelAnimationFrame,
+ document: dom.window.document,
+ requestAnimationFrame: dom.window.requestAnimationFrame,
+ window: dom.window,
+ });
+ // `navigator` is getter-only on Node, so it needs defineProperty rather than
+ // assignment; motion/react reads it during render.
+ Object.defineProperty(globalThis, "navigator", {
+ configurable: true,
+ value: dom.window.navigator,
+ writable: true,
+ });
+ // `useReducedMotion` subscribes to a media query jsdom does not implement.
+ dom.window.matchMedia ??= () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+/**
+ * The drawer defers its focus restore to a `requestAnimationFrame`, so every
+ * assertion here has to run after that frame has actually fired. Two hops:
+ * React commits the unmount, then the rAF callback runs.
+ */
+async function flushDeferredFocusRestore(act) {
+ for (let hop = 0; hop < 2; hop += 1) {
+ await act(async () => {
+ await new Promise((resolve) =>
+ dom.window.requestAnimationFrame(() => resolve()),
+ );
+ });
+ }
+}
+
+async function loadHarness() {
+ const React = await import("react");
+ const { act, render } = await import("@testing-library/react");
+ const { CoverDrawer } = await import("./CoverDrawer.tsx");
+ const { releaseCoverDrawerFocus } = await import(
+ "@/features/channels/lib/coverDrawerFocusSlot"
+ );
+
+ const opener = dom.window.document.createElement("button");
+ opener.setAttribute("data-testid", "opener");
+ dom.window.document.body.append(opener);
+ opener.focus();
+ assert.equal(dom.window.document.activeElement, opener);
+
+ const drawer = (testId) =>
+ React.createElement(
+ CoverDrawer,
+ { ariaLabel: testId, onClose: () => {}, scrimLabel: testId, testId },
+ React.createElement("div", null, testId),
+ );
+
+ return { act, drawer, opener, releaseCoverDrawerFocus, render };
+}
+
+function activeTestId() {
+ return (
+ dom.window.document.activeElement?.getAttribute("data-testid") ??
+ dom.window.document.activeElement?.tagName ??
+ "none"
+ );
+}
+
+test("a drawer restores focus to whatever it covered when it simply closes", async () => {
+ const { act, drawer, opener, render } = await loadHarness();
+ const view = render(drawer("first-drawer"));
+ assert.equal(activeTestId(), "first-drawer");
+
+ await act(async () => {
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(dom.window.document.activeElement, opener);
+});
+
+test("a replaced drawer leaves focus with its successor, not the covered content", async () => {
+ // The bug this guards: restoration is deferred a frame, and in that frame the
+ // successor has already mounted and taken focus. An unconditional restore
+ // yanks focus back out of the new drawer and into content that is now inert —
+ // keyboard-dead, with no visible focus ring anywhere on screen.
+ const { act, drawer, render } = await loadHarness();
+ const view = render(drawer("outgoing-drawer"));
+
+ // Replacement, with no fully-closed intermediate state: the successor mounts
+ // and claims focus while the outgoing drawer is still animating out.
+ const successor = render(drawer("incoming-drawer"));
+ assert.equal(activeTestId(), "incoming-drawer");
+ await act(async () => {
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(activeTestId(), "incoming-drawer");
+ successor.unmount();
+});
+
+test("only the last-opened drawer keeps focus across a chain of replacements", async () => {
+ const { act, drawer, render } = await loadHarness();
+ const first = render(drawer("first-drawer"));
+ const second = render(drawer("second-drawer"));
+ const third = render(drawer("third-drawer"));
+
+ await act(async () => {
+ first.unmount();
+ second.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(activeTestId(), "third-drawer");
+ third.unmount();
+});
+
+test("a drawer restores focus to the opener even when StrictMode replays its capture", async () => {
+ // The app root is wrapped in `React.StrictMode`, which in development runs
+ // every effect setup → cleanup → setup. By that second setup the drawer has
+ // already focused itself, so a capture that reads `document.activeElement`
+ // unconditionally records the drawer as its own opener; closing then focuses a
+ // node React has detached and focus falls to ``. Capture has to survive
+ // the replay, which is what the other tests here cannot see because they
+ // render without StrictMode.
+ const { act, drawer, opener, render } = await loadHarness();
+ const view = render(drawer("strict-drawer"), { reactStrictMode: true });
+ assert.equal(activeTestId(), "strict-drawer");
+
+ await act(async () => {
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(dom.window.document.activeElement, opener);
+});
+
+test("a released slot leaves focus where the caller put it", async () => {
+ // The thread view-mode switch: nothing replaces the drawer, but the switch has
+ // already decided where focus belongs, so the drawer's own restore must not
+ // fire and drag focus back to whatever opened the thread.
+ const { act, drawer, releaseCoverDrawerFocus, render } = await loadHarness();
+ const view = render(drawer("thread-drawer"));
+
+ const splitPane = dom.window.document.createElement("button");
+ splitPane.setAttribute("data-testid", "split-pane");
+ dom.window.document.body.append(splitPane);
+
+ await act(async () => {
+ releaseCoverDrawerFocus();
+ splitPane.focus();
+ view.unmount();
+ });
+ await flushDeferredFocusRestore(act);
+
+ assert.equal(activeTestId(), "split-pane");
+});
diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
index 626dbbddc2..3012df8baf 100644
--- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
+++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
@@ -1,258 +1,48 @@
-import { motion, useReducedMotion } from "motion/react";
-import * as React from "react";
+import type * as React from "react";
-import {
- THREAD_FOCUS_DRAWER_TRAVEL_PX,
- THREAD_FOCUS_SLIVER_WIDTH_PX,
-} from "@/features/channels/lib/threadFocusLayout";
-import { getThreadViewMode } from "@/features/channels/lib/threadViewModePreference";
-import { cn } from "@/shared/lib/cn";
+import { CoverDrawer } from "@/features/channels/ui/CoverDrawer";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
+ /**
+ * Whether the thread has an edit in progress, which Escape must cancel before
+ * it can dismiss the drawer. See `CoverDrawer`'s `escapeYieldsToContent`.
+ */
+ hasActiveEdit?: boolean;
/** Accessible name for the drawer. Channel threads leave the default. */
label?: string;
- hasActiveEdit?: boolean;
onClose: () => void;
};
/**
- * Scrim over the channel content area behind the focus drawer.
- *
- * Veil, not shadow, and no blur: the channel fades toward the surface colour
- * rather than being darkened. A black wash is a multiply — it scales text and
- * background down together, so dark-on-light text keeps its contrast ratio and
- * stays readable at any opacity short of a solid bar. Fading toward
- * `background` instead compresses text against the surface in both themes,
- * which is what pushes the sliver back to colour and shape. Matches the shared
- * header backdrop's `bg-background/80` vocabulary, a touch heavier because this
- * one has to defeat body text rather than sit over a gap.
- */
-const FOCUS_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
-
-/**
- * Hover eases the veil one step in both themes.
- *
- * Feedback that the sliver is a target — deliberately not a peek: one step is
- * enough to register as interactive without making the channel readable.
- */
-const FOCUS_SCRIM_HOVER_CLASS =
- "hover:bg-background/65 dark:hover:bg-background/70";
-
-/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
-const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
-
-/**
- * Leave immediately. Shares the enter's fast-start shape rather than the
- * conventional accelerating ease-in for exits.
- *
- * The "exits accelerate away" rule assumes the whole travel is visible; an
- * ease-in spends its opening frames barely moving and pays that back at the end.
- * Here the tail is hidden under the opacity fade, so acceleration buys nothing
- * and those opening frames are the entire perception of responsiveness — a
- * dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
- * its total duration. Decisiveness comes from the duration below instead.
- */
-const EXIT_EASE = ENTER_EASE;
-
-const SCRIM_ENTER_SECONDS = 0.2;
-
-/**
- * Slightly ahead of the drawer's exit, and deliberately so.
- *
- * A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
- * of it, which reads as lag at the exact moment the user has committed to
- * leaving. Undimming first hands the channel back the instant it is asked for.
- */
-const SCRIM_EXIT_SECONDS = 0.12;
-
-/**
- * Enter: opacity front-loaded, transform long.
- *
- * The two channels animate over deliberately different windows, and that
- * asymmetry is the whole point. Short travel *requires* an opacity fade — an
- * opaque surface this large appearing 120px off its mark with no fade is a hard
- * cut, not a slide. But pairing both properties on one timing function (as a
- * single CSS keyframe must) welds them together for the full duration, and since
- * opacity covers 100% of its range while transform covers ~3% of the drawer's
- * width, the fade is what the eye reads. Resolving opacity in the first ~90ms
- * leaves the remaining ~190ms as pure travel: the fade is over before it
- * registers, and what's perceived is sliding.
- *
- * It also keeps the drawer's own entrance from exposing its contents' load
- * order. Anything arriving late (replies resolving, media decoding) lands on an
- * already-opaque surface and reads as "the thread is loading" rather than the UI
- * assembling itself.
- */
-const ENTER_TRANSITION = {
- opacity: { duration: 0.09, ease: "linear" },
- x: { duration: 0.28, ease: ENTER_EASE },
-} as const;
-
-/**
- * Exit: half the enter's duration, opacity barely back-loaded.
+ * The focus-mode thread presentation: a {@link CoverDrawer} holding the thread.
*
- * Opening and closing are not symmetric tasks. The enter has something to say —
- * it establishes where the thread came from and that the channel is still behind
- * it. The exit has nothing to say: attention has already left for the channel,
- * so its only job is to get out of the way without popping. That makes duration
- * the thing to spend, and 140ms is about the floor before the drawer reads as
- * vanishing rather than leaving.
+ * Everything about the surface itself — motion, scrim, Escape, focus
+ * capture/restore — lives in `CoverDrawer`. Switching this thread to the split
+ * pane is not a dismissal and must not restore focus to whatever opened the
+ * thread; that case is handled where the switch happens, by releasing the
+ * drawer's focus slot before this unmounts. See `useThreadViewModeSwitch`.
*
- * The opacity hold shrinks with it. Its purpose is to let the drawer commit to
- * moving before it dissolves, so it reads as sliding out — but at this duration a
- * hold proportional to the old one would eat half the animation. 20ms is enough
- * to register solidity in the first frame or two.
- */
-const EXIT_TRANSITION = {
- opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
- x: { duration: 0.14, ease: EXIT_EASE },
-} as const;
-
-/**
- * Reduced motion keeps a crossfade and drops the travel.
- *
- * Travel is the part that's motion; the fade is what makes appearing and
- * disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
- * timings would read as dead air on a stationary surface, so both collapse to
- * one short symmetric fade.
- */
-const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
-
-/**
- * Right-anchored thread drawer that overlays the channel content area.
- *
- * Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
- * `AnimatePresence` so the exit animation can run: everything here is absolutely
- * positioned against the channel content area, so the app sidebar is never
- * covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
- * of it remains visible for depth, and the whole scrim (sliver included) is one
- * tall click target back to the channel. Orientation lives in the drawer
- * header's breadcrumb, where the eye already is — the sliver carries no label of
- * its own.
- *
- * `z-41` places the drawer above the channel section (whose inner `isolate`
- * wrapper traps the timeline's z-50 pill, z-40 composer overlay, and z-50 drop
- * overlay) and the `z-30` shared header backdrop, while staying below the
- * global `z-45` top chrome. Setting z-index on the positioned container also
- * gives the drawer its own stacking context, so the panel chrome inside is
- * isolated.
+ * The idle auxiliary panel reuses this presentation with its own `label`, which
+ * is why the label is a prop rather than the constant the thread wants.
*/
export function FocusThreadDrawer({
channelName,
children,
- label = "Thread",
hasActiveEdit = false,
+ label = "Thread",
onClose,
}: FocusThreadDrawerProps) {
- const prefersReducedMotion = useReducedMotion();
- const travelPx = prefersReducedMotion ? 0 : THREAD_FOCUS_DRAWER_TRAVEL_PX;
- const drawerRef = React.useRef(null);
- const previousFocusRef = React.useRef(null);
-
- React.useEffect(() => {
- function handleEscape(event: KeyboardEvent) {
- if (event.key !== "Escape") return;
- const target = event.target;
- if (
- hasActiveEdit &&
- target instanceof Node &&
- drawerRef.current?.contains(target)
- ) {
- return;
- }
- event.preventDefault();
- event.stopImmediatePropagation();
- onClose();
- }
-
- window.addEventListener("keydown", handleEscape, { capture: true });
- return () => {
- window.removeEventListener("keydown", handleEscape, { capture: true });
- };
- }, [hasActiveEdit, onClose]);
-
- React.useLayoutEffect(() => {
- previousFocusRef.current =
- document.activeElement instanceof HTMLElement
- ? document.activeElement
- : null;
- drawerRef.current?.focus({ preventScroll: true });
-
- return () => {
- const previousFocus = previousFocusRef.current;
- requestAnimationFrame(() => {
- // A real dismissal keeps focus mode selected; a presentation switch
- // has already selected split mode and owns focus inside the new panel.
- if (getThreadViewMode() === "focus") {
- previousFocus?.focus({ preventScroll: true });
- }
- });
- };
- }, []);
-
return (
-
-
-
-
-
{children}
-
-
+ {children}
+
);
}
diff --git a/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs b/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs
new file mode 100644
index 0000000000..066e7be605
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelAgentSessionExclusivity.test.mjs
@@ -0,0 +1,197 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+const AGENT_PUBKEY = "a".repeat(64);
+const THREAD_HEAD_ID = "thread-head-1";
+const OTHER_THREAD_HEAD_ID = "thread-head-2";
+
+/**
+ * Renders the real hook over a recording state harness that re-renders on every
+ * write, the way the panel state hooks do — the open handlers read the current
+ * thread/profile state as props, so a plain mutable object would hand them stale
+ * values and the breadcrumb assertions would silently pass for the wrong reason.
+ *
+ * Last-opened-wins is a property of these two handlers clearing each other's
+ * state, so the assertions are on the state writes, not on a rendered surface.
+ */
+async function renderAgentSessionHandlers({
+ openThreadHeadId = null,
+ requireThreadEditResolution = () => true,
+} = {}) {
+ const React = await import("react");
+ const { act, renderHook } = await import("@testing-library/react");
+ const { useChannelAgentSessions } = await import(
+ "./useChannelAgentSessions.ts"
+ );
+
+ const state = {
+ channelManagementOpen: false,
+ openAgentSessionChannelId: null,
+ openAgentSessionPubkey: null,
+ openThreadHeadId,
+ profilePanelPubkey: null,
+ };
+ const openedThreads = [];
+ let commit = () => {};
+ const write = (key, value) => {
+ state[key] = value;
+ commit();
+ };
+
+ const rendered = renderHook(() => {
+ const [, force] = React.useState(0);
+ commit = () => force((version) => version + 1);
+
+ return useChannelAgentSessions({
+ activeChannel: { id: "channel-1", name: "general" },
+ activeChannelId: "channel-1",
+ agentsLoaded: true,
+ channelMembers: [{ pubkey: AGENT_PUBKEY, role: "bot" }],
+ handleOpenThread: (message) => {
+ openedThreads.push(message.id);
+ write("openThreadHeadId", message.id);
+ },
+ managedAgents: [
+ {
+ agentSource: "managed",
+ canInterruptTurn: true,
+ name: "ss-dev-00",
+ pubkey: AGENT_PUBKEY,
+ status: "deployed",
+ },
+ ],
+ openAgentSessionPubkey: state.openAgentSessionPubkey,
+ openThreadHeadId: state.openThreadHeadId,
+ profilePanelPubkey: state.profilePanelPubkey,
+ requireThreadEditResolution,
+ setChannelManagementOpen: (open) => write("channelManagementOpen", open),
+ setExpandedThreadReplyIds: () => {},
+ setOpenAgentSessionChannelId: (value) =>
+ write("openAgentSessionChannelId", value),
+ setOpenAgentSessionPubkey: (value) =>
+ write("openAgentSessionPubkey", value),
+ setOpenThreadHeadId: (value) => write("openThreadHeadId", value),
+ setProfilePanelPubkey: (value) => write("profilePanelPubkey", value),
+ setThreadReplyTargetId: () => {},
+ setThreadScrollTargetId: () => {},
+ });
+ });
+
+ const run = (body) => act(async () => body(rendered.result.current));
+
+ return { openedThreads, run, state };
+}
+
+test("opening activity over a thread clears the thread", async () => {
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ assert.equal(state.openAgentSessionPubkey, AGENT_PUBKEY);
+ assert.equal(state.openThreadHeadId, null);
+});
+
+test("opening a thread over activity clears the agent session", async () => {
+ const { openedThreads, run, state } = await renderAgentSessionHandlers();
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ await run((handlers) =>
+ handlers.openThreadAndCloseAgentSession({ id: THREAD_HEAD_ID }),
+ );
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.deepEqual(openedThreads, [THREAD_HEAD_ID]);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+});
+
+test("either ordering ends with exactly one surface's state live", async () => {
+ // Both directions back to back with no close in between: whichever handler ran
+ // last is the only one holding state, so the surface resolver never sees two
+ // candidates and its priority tie-break never has to decide.
+ const { run, state } = await renderAgentSessionHandlers();
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) =>
+ handlers.openThreadAndCloseAgentSession({ id: THREAD_HEAD_ID }),
+ );
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ assert.equal(state.openAgentSessionPubkey, AGENT_PUBKEY);
+ assert.equal(state.openThreadHeadId, null);
+});
+
+test("back from activity opened over a thread returns to that thread", async () => {
+ // The replacement clears the thread, so the breadcrumb is what keeps it
+ // recoverable; without it, last-opened-wins would be a one-way door.
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) => handlers.backFromAgentSession());
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+});
+
+test("back never resurrects a thread from an earlier replacement", async () => {
+ // Alternating both directions leaves one breadcrumb, not a stack:
+ // `openThreadAndCloseAgentSession` clears the recorded target, so the next
+ // activity open records the thread actually on screen.
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) =>
+ handlers.openThreadAndCloseAgentSession({ id: OTHER_THREAD_HEAD_ID }),
+ );
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+ await run((handlers) => handlers.backFromAgentSession());
+
+ assert.equal(state.openThreadHeadId, OTHER_THREAD_HEAD_ID);
+});
+
+test("an unresolved thread edit blocks the replacement entirely", async () => {
+ // Last-opened-wins runs *after* the thread edit guard `#6575` added, so a
+ // refused open must leave both surfaces exactly as they were — the thread
+ // still on screen with its draft, and no half-applied replacement that
+ // cleared the thread before the guard turned the activity open away.
+ const { run, state } = await renderAgentSessionHandlers({
+ openThreadHeadId: THREAD_HEAD_ID,
+ requireThreadEditResolution: () => false,
+ });
+
+ await run((handlers) => handlers.openAgentSession(AGENT_PUBKEY, "channel-1"));
+
+ assert.equal(state.openAgentSessionPubkey, null);
+ assert.equal(state.openThreadHeadId, THREAD_HEAD_ID);
+});
diff --git a/desktop/src/features/channels/ui/useFocusDrawerPresence.ts b/desktop/src/features/channels/ui/useCoverDrawerPresence.ts
similarity index 68%
rename from desktop/src/features/channels/ui/useFocusDrawerPresence.ts
rename to desktop/src/features/channels/ui/useCoverDrawerPresence.ts
index 271c867ae3..3d725cc979 100644
--- a/desktop/src/features/channels/ui/useFocusDrawerPresence.ts
+++ b/desktop/src/features/channels/ui/useCoverDrawerPresence.ts
@@ -1,9 +1,9 @@
import * as React from "react";
-import { subscribeToFocusedThreadCloseRequest } from "@/features/channels/focusedThreadCloseRequest";
+import { subscribeToCoverDrawerCloseRequest } from "@/features/channels/coverDrawerCloseRequest";
/** Keeps the covered channel inert and owns external dismissal while open. */
-export function useFocusDrawerPresence(open: boolean, onClose: () => void) {
+export function useCoverDrawerPresence(open: boolean, onClose: () => void) {
const [present, setPresent] = React.useState(false);
React.useEffect(() => {
@@ -12,7 +12,7 @@ export function useFocusDrawerPresence(open: boolean, onClose: () => void) {
React.useEffect(() => {
if (!open) return;
- return subscribeToFocusedThreadCloseRequest(onClose);
+ return subscribeToCoverDrawerCloseRequest(onClose);
}, [onClose, open]);
const markExitComplete = React.useCallback(() => setPresent(false), []);
diff --git a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
index 8dd41cfc51..5fdb9c62dd 100644
--- a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
+++ b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
@@ -1,5 +1,6 @@
import * as React from "react";
+import { releaseCoverDrawerFocus } from "@/features/channels/lib/coverDrawerFocusSlot";
import {
setThreadViewMode,
type ThreadViewMode,
@@ -89,6 +90,10 @@ export function useThreadViewModeSwitch({
);
onModeChange?.(mode);
setThreadViewMode(mode);
+ // Changing presentation is not a dismissal: this function decides where
+ // focus goes below, so release the cover drawer's focus slot to stop the
+ // outgoing drawer's own deferred restore from overriding that choice.
+ releaseCoverDrawerFocus();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document
diff --git a/desktop/src/shared/hooks/useEscapeKey.ts b/desktop/src/shared/hooks/useEscapeKey.ts
index 0422002746..fe51389fdb 100644
--- a/desktop/src/shared/hooks/useEscapeKey.ts
+++ b/desktop/src/shared/hooks/useEscapeKey.ts
@@ -1,3 +1,4 @@
+import { useIsPresent } from "motion/react";
import * as React from "react";
import { acquireEscapeSurface } from "@/shared/hooks/escapeSurfaces";
@@ -11,13 +12,29 @@ import { acquireEscapeSurface } from "@/shared/hooks/escapeSurfaces";
* app-level Escape shortcuts (mark channel read) know to yield instead
* of racing this listener on registration order.
*
+ * A surface animating out under `AnimatePresence` stands down: it stays
+ * registered (so background shortcuts keep yielding for the duration) but no
+ * longer acts on the key. `AnimatePresence` keeps a replaced surface mounted
+ * through its exit, so during a replacement two surfaces listen at once, and
+ * the outgoing one registered first — it would mark the press
+ * `defaultPrevented` and its successor, respecting exactly that flag, would
+ * ignore it. The user sees a swallowed keypress and has to press Escape twice.
+ * Outside `AnimatePresence` there is no presence context and this is always
+ * true, so surfaces that never animate out are unaffected.
+ *
* Pass `enabled: false` to skip registering the listener entirely.
*/
export function useEscapeKey(onEscape: () => void, enabled: boolean = true) {
+ // Read through a ref rather than an effect dependency so entering the exit
+ // phase does not release and re-acquire the surface registration.
+ const isPresentRef = React.useRef(true);
+ isPresentRef.current = useIsPresent();
+
React.useEffect(() => {
if (!enabled) return;
const releaseSurface = acquireEscapeSurface();
function handleKeyDown(event: KeyboardEvent) {
+ if (!isPresentRef.current) return;
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
onEscape();
diff --git a/desktop/src/shared/ui/markdown/CodeBlock.tsx b/desktop/src/shared/ui/markdown/CodeBlock.tsx
index 9954b03aa6..30748d236e 100644
--- a/desktop/src/shared/ui/markdown/CodeBlock.tsx
+++ b/desktop/src/shared/ui/markdown/CodeBlock.tsx
@@ -19,6 +19,31 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { getReactNodeText } from "./utils";
+/**
+ * Code-block presentation, supplied by the rendering surface.
+ *
+ * `focusProse` applies berd's recipe (`code-block.tsx` CodeBlockHeader:388-402,
+ * viewport:528-529): the language in a real header row above the frame with the
+ * copy action opposite it, a 10px radius, page-background fill behind a subtle
+ * border, and no shadow.
+ *
+ * It is opt-in because this renderer is shared with channel messages —
+ * restyling the default would change every code block in the app. The variant
+ * is a property of the *surface*, not of a role: `AgentSessionTranscriptList`
+ * provides it once at the transcript boundary, so a fence in a human prompt
+ * gets the same chrome as one in an agent reply.
+ *
+ * Delivered through context rather than a `MarkdownCodeBlock` prop for the same
+ * reason as `VideoReviewMarkdownContext`: the component map handed to
+ * react-markdown must stay module-stable. A prop would have to be threaded
+ * through `createMarkdownComponents`, which would also mean partitioning the
+ * parsed-node cache that identifies that map by variant string
+ * (`nodeCache.ts`). A context read at render time needs neither.
+ */
+export const CodeBlockVariantContext = React.createContext<
+ "default" | "focusProse"
+>("default");
+
let shikiHighlighter: HighlighterGeneric | null =
null;
let shikiInitPromise: Promise | null = null;
@@ -75,6 +100,8 @@ export function MarkdownCodeBlock({
const [isCopying, setIsCopying] = React.useState(false);
const codeBlockRef = React.useRef(null);
const code = React.useMemo(() => getCodeBlockText(children), [children]);
+ const isFocusProse =
+ React.useContext(CodeBlockVariantContext) === "focusProse";
useSmoothCorners(codeBlockRef);
const handleCopy = React.useCallback(
@@ -96,6 +123,52 @@ export function MarkdownCodeBlock({
[code],
);
+ const focusProseCopyButton = (
+
+
+
+
+ Copy code block
+
+
+ Copy code
+
+ );
+
+ if (isFocusProse) {
+ return (
+
+
+ {language}
+ {focusProseCopyButton}
+
+
+ {children}
+
+
+ );
+ }
+
+ // Unchanged default path: this renderer is shared with channel messages, so
+ // its markup stays byte-identical (including class order) to keep every other
+ // code block in the app exactly as it was.
return (
+ new Date(TURN_START_MS + offsetSeconds * 1_000).toISOString();
+
+type ObserverEventSeed = {
+ seq: number;
+ timestamp: string;
+ kind: string;
+ agentIndex: number | null;
+ channelId: string | null;
+ sessionId: string | null;
+ turnId: string | null;
+ payload: unknown;
+};
+
+let seq = 0;
+
+function sessionUpdate(
+ offsetSeconds: number,
+ update: unknown,
+): ObserverEventSeed {
+ seq += 1;
+ return {
+ seq,
+ timestamp: at(offsetSeconds),
+ kind: "acp_read",
+ agentIndex: 0,
+ channelId: CHANNEL_ID,
+ sessionId: SESSION_ID,
+ turnId: TURN_ID,
+ payload: {
+ jsonrpc: "2.0",
+ method: "session/update",
+ params: { sessionId: SESSION_ID, update },
+ },
+ };
+}
+
+/**
+ * A tool call as the harness actually reports it: an `in_progress` announcement
+ * followed by a terminal update carrying the output. Seeding only the terminal
+ * update would skip the correlation path the transcript uses to pair them.
+ */
+function toolCall(
+ offsetSeconds: number,
+ input: {
+ args: Record;
+ failed?: boolean;
+ id: string;
+ output: string;
+ title: string;
+ toolName: string;
+ },
+): ObserverEventSeed[] {
+ return [
+ sessionUpdate(offsetSeconds, {
+ sessionUpdate: "tool_call",
+ rawInput: input.args,
+ status: "in_progress",
+ title: input.title,
+ toolCallId: input.id,
+ toolName: input.toolName,
+ }),
+ sessionUpdate(offsetSeconds + 1, {
+ content: [
+ { type: "content", content: { type: "text", text: input.output } },
+ ],
+ rawInput: input.args,
+ sessionUpdate: "tool_call_update",
+ status: input.failed ? "failed" : "completed",
+ title: input.title,
+ toolCallId: input.id,
+ toolName: input.toolName,
+ }),
+ ];
+}
+
+/**
+ * One finished turn with the shape a real investigation has: a mention that
+ * starts it, thinking, file reads, a shell command, a relay post, a step that
+ * failed, a plan, and an answer containing code.
+ *
+ * The prompt is framed the way the harness frames it — a `[Buzz event: ...]`
+ * section with `From:`/`Content:` lines — because `parsePromptText` reads the
+ * author pubkey and the user-visible text out of exactly that shape. A plain
+ * text prompt would render as an unattributed bubble and would not exercise the
+ * header the drawer is meant to make readable.
+ */
+function buildTurnEvents(): ObserverEventSeed[] {
+ seq = 0;
+ const events: ObserverEventSeed[] = [];
+
+ seq += 1;
+ events.push({
+ seq,
+ timestamp: at(0),
+ kind: "acp_write",
+ agentIndex: 0,
+ channelId: CHANNEL_ID,
+ sessionId: SESSION_ID,
+ turnId: TURN_ID,
+ payload: {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "session/prompt",
+ params: {
+ prompt: [
+ {
+ type: "text",
+ text: [
+ "[Buzz event: @mention]",
+ "Event ID: 4f1c8e6d2b7a90c3e5148af6b0d29c73518ea4d6c09b7f2318ad45e6019cb372",
+ "Channel: agents (#94a444a4-c0a3-5966-ab05-530c6ddc2301)",
+ "Kind: 9",
+ `From: bob (npub: npub1hv32jnyjyr9dwmlagvvsejul4j4ushx2vph2ghde5ktxxu6hlxcqzt5qsn, hex: ${HUMAN_PUBKEY})`,
+ "Time: 2026-08-24T18:04:11+00:00",
+ "Content: @Observer Agent the mention badge lands on the wrong channel row after a reconnect. Trace where the feed category is set and confirm whether the singular/plural mismatch is the cause. Post what you find here.",
+ ].join("\n"),
+ },
+ {
+ type: "text",
+ text: "[Thread context]\nThis is the thread history with 3 prior messages.",
+ },
+ ],
+ },
+ },
+ });
+
+ events.push(
+ sessionUpdate(4, {
+ sessionUpdate: "agent_thought_chunk",
+ messageId: "thought-1",
+ content: {
+ type: "text",
+ text: "The badge is driven by the feed category on the alert event, so a mismatch would show up where that string is built. Start at the emit site, then follow the value into the sidebar row selector.",
+ },
+ }),
+ );
+
+ events.push(
+ ...toolCall(12, {
+ args: { path: "desktop/src/features/feed/lib/feedCategory.ts" },
+ id: "call-read-1",
+ output: 'export type FeedCategory = "mention" | "reply" | "reaction";',
+ title: "read_file",
+ toolName: "buzz_dev_mcp__read_file",
+ }),
+ ...toolCall(15, {
+ args: { path: "desktop/src/features/feed/lib/alertRouting.ts" },
+ id: "call-read-2",
+ output: 'if (category === "mentions") { routeToChannel(channelId); }',
+ title: "read_file",
+ toolName: "buzz_dev_mcp__read_file",
+ }),
+ ...toolCall(18, {
+ args: { path: "desktop/src/features/channels/ui/ChannelRowBadge.tsx" },
+ id: "call-read-3",
+ output: 'const hasMention = categories.includes("mention");',
+ title: "read_file",
+ toolName: "buzz_dev_mcp__read_file",
+ }),
+ );
+
+ events.push(
+ ...toolCall(24, {
+ args: {
+ command: "rg -n 'mentions\"' desktop/src --glob '*.ts' --glob '*.tsx'",
+ },
+ id: "call-shell-1",
+ output: [
+ 'desktop/src/features/feed/lib/alertRouting.ts:41: if (category === "mentions") {',
+ 'desktop/src/features/feed/emitFeedAlert.ts:88: category: "mentions",',
+ 'desktop/src/features/feed/emitFeedAlert.test.mjs:12: category: "mentions",',
+ "",
+ "3 matches across 3 files",
+ ].join("\n"),
+ title: "shell",
+ toolName: "buzz_dev_mcp__shell",
+ }),
+ );
+
+ events.push(
+ ...toolCall(31, {
+ args: {
+ command:
+ "buzz messages send --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301 --content 'Confirmed the plural/singular mismatch at the emit site.'",
+ },
+ id: "call-relay-1",
+ output: '{\n "accepted": true,\n "event_id": "a41c9e2f…"\n}',
+ title: "shell",
+ toolName: "buzz_dev_mcp__shell",
+ }),
+ );
+
+ events.push(
+ ...toolCall(37, {
+ args: { command: "pnpm vitest run desktop/src/features/feed" },
+ failed: true,
+ id: "call-fail-1",
+ output:
+ "Error: ENOENT: no such file or directory, scandir 'desktop/src/features/feed/__tests__'\n at readdirSync (node:fs:1478:26)\nexit code 1",
+ title: "shell",
+ toolName: "buzz_dev_mcp__shell",
+ }),
+ );
+
+ events.push(
+ sessionUpdate(44, {
+ sessionUpdate: "plan",
+ entries: [
+ { content: "Trace the feed category emit site", status: "completed" },
+ {
+ content: "Confirm the sidebar row reads the singular form",
+ status: "completed",
+ },
+ {
+ content: "Add a regression test for the routing branch",
+ status: "pending",
+ },
+ { content: "Open the fix PR", status: "pending" },
+ ],
+ }),
+ );
+
+ events.push(
+ sessionUpdate(52, {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "reply-1",
+ content: {
+ type: "text",
+ text: [
+ 'Confirmed — it is the singular/plural mismatch, and it is on the emit side. `emitFeedAlert` writes `category: "mentions"` while every reader compares against the singular `"mention"`, so the alert never matches the mention branch and falls through to the generic channel-activity path. That is why the badge lands on whichever row was last touched rather than the mentioning channel.',
+ "",
+ "```ts",
+ "// desktop/src/features/feed/emitFeedAlert.ts",
+ '- category: "mentions",',
+ '+ category: "mention",',
+ "```",
+ "",
+ "The reader side needs no change. No fix pushed yet — the feed test directory the suite expects does not exist, so the regression test needs a home first.",
+ ].join("\n"),
+ },
+ }),
+ );
+
+ return events;
+}
+
+async function seedTurn(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
+ ),
+ )
+ .toBe(true);
+ await page.evaluate(
+ ({ evts, pubkey }) => {
+ window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
+ agentPubkey: pubkey,
+ events: evts,
+ });
+ },
+ { evts: buildTurnEvents(), pubkey: AGENT_PUBKEY },
+ );
+}
+
+/**
+ * Composer activity bar → the agent's row.
+ *
+ * This ingress has no prior pane, so the drawer opens with its own close
+ * affordance and no back arrow — the presentation this reference shot is of.
+ */
+async function openActivityFromComposer(page: Page) {
+ await page.getByTestId("channel-agents").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("agents");
+
+ await page.evaluate((pubkey) => {
+ window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
+ channelName: "agents",
+ pubkey,
+ });
+ }, AGENT_PUBKEY);
+
+ const trigger = page.getByTestId("bot-activity-composer-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const item = page.getByTestId(`bot-activity-composer-item-${AGENT_PUBKEY}`);
+ await expect(item).toBeVisible();
+ await item.click({ force: true });
+}
+
+/**
+ * Scroll the transcript to the head of the turn and confirm it stayed there.
+ *
+ * Finds the scrolling ancestor by computed overflow rather than by class name:
+ * the transcript's styling belongs to the transcript variants, which are being
+ * restyled in parallel, and this spec must not break when they change.
+ *
+ * The panel is tail-anchored, and growing its content (by expanding folds) pins
+ * it back to the bottom — so a scroll issued while that is still settling gets
+ * undone a frame later and the shot silently becomes a second tail frame.
+ * Re-issues the scroll and then re-reads it on a later task, so the assertion
+ * only passes once the position actually survives a frame.
+ */
+async function scrollTranscriptToTop(page: Page) {
+ const panel = page.getByTestId("agent-session-thread-panel");
+ const scrollToTopAndSettle = () =>
+ panel.evaluate((element) => {
+ let node = element.querySelector('[role="log"]')?.parentElement ?? null;
+ while (node) {
+ const overflowY = window.getComputedStyle(node).overflowY;
+ if (
+ (overflowY === "auto" || overflowY === "scroll") &&
+ node.scrollHeight > node.clientHeight
+ ) {
+ const scroller = node;
+ scroller.scrollTop = 0;
+ // Read back after two frames: a re-pin from the tail anchor lands in
+ // an effect or rAF, so an immediate read would report the write rather
+ // than the outcome.
+ return new Promise((resolve) => {
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => resolve(scroller.scrollTop)),
+ );
+ });
+ }
+ node = node.parentElement;
+ }
+ return Promise.resolve(-1);
+ });
+
+ await expect.poll(scrollToTopAndSettle, { timeout: 10_000 }).toBe(0);
+}
+
+/**
+ * Scroll the transcript to the tail of the turn.
+ *
+ * Same scroller discovery as {@link scrollTranscriptToTop}; no re-pin race to
+ * fight here because the tail is where the anchor wants to be anyway.
+ */
+async function scrollTranscriptToBottom(page: Page) {
+ await page.getByTestId("agent-session-thread-panel").evaluate((element) => {
+ let node = element.querySelector('[role="log"]')?.parentElement ?? null;
+ while (node) {
+ const overflowY = window.getComputedStyle(node).overflowY;
+ if (
+ (overflowY === "auto" || overflowY === "scroll") &&
+ node.scrollHeight > node.clientHeight
+ ) {
+ node.scrollTop = node.scrollHeight;
+ return;
+ }
+ node = node.parentElement;
+ }
+ });
+}
+
+/**
+ * Open every folded work block in the transcript.
+ *
+ * The transcript opens with finished work folded, so the default frame is a
+ * stack of one-line summaries—true to the product, but it shows none of the
+ * turn's actual shape. Expanding gives the second shot the content the drawer's
+ * width exists for: command output, a failed step, plan items.
+ *
+ * Drive the product's summary buttons rather than reaching through the old
+ * `` implementation. This keeps the reference flow exercising the same
+ * disclosure state a reader uses.
+ */
+async function expandTranscriptRows(page: Page) {
+ const summaries = page.getByTestId("transcript-work-block-summary");
+ const count = await summaries.count();
+ expect(count).toBeGreaterThan(0);
+ for (let index = 0; index < count; index += 1) {
+ const summary = summaries.nth(index);
+ if ((await summary.getAttribute("aria-expanded")) !== "true") {
+ await summary.click();
+ }
+ }
+}
+
+/**
+ * Reference screenshots of a realistic agent turn in the cover drawer.
+ *
+ * The PNGs are the deliverable — they are what design and review look at, and
+ * regenerating them is the point of keeping this spec. So the assertions are
+ * deliberately limited to what Slice A owns: the drawer covers, the panel is
+ * mounted inside it, and there is no split resize handle. Transcript structure,
+ * grouping, and styling belong to the transcript variants and are asserted by
+ * their own specs; asserting them here would make the reference shots fail for
+ * reasons that have nothing to do with the drawer.
+ */
+test.describe("agent activity cover drawer screenshots", () => {
+ test.use({ viewport: DRAWER_VIEWPORT });
+
+ test("realistic turn in the cover drawer", async ({ page }) => {
+ await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+
+ // Seed before opening so the panel has content on its first paint, and
+ // again after: the panel subscribes on mount, and re-seeding is how the
+ // observer store notifies an already-mounted subscriber.
+ await seedTurn(page);
+ await openActivityFromComposer(page);
+ await seedTurn(page);
+
+ const drawer = page.getByTestId("agent-activity-drawer");
+ const panel = page.getByTestId("agent-session-thread-panel");
+ await expect(drawer).toBeVisible();
+ await expect(
+ drawer.getByTestId("agent-session-thread-panel"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("right-auxiliary-pane-resize-handle"),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("channel-drop-zone")).toHaveAttribute(
+ "inert",
+ "",
+ );
+
+ // The turn actually rendered — without this the shots could be of an empty
+ // drawer and still pass every structural assertion above.
+ await expect(
+ page.getByTestId("transcript-user-message").first(),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // The reading view is pinned by presentation rather than inferred from panel
+ // width: the drawer is the reading surface, so its transcript renders the
+ // conversation variant. Asserted here rather than in
+ // `agent-activity-cover.spec.ts` because the marker only exists once the
+ // transcript has content, and this is the spec that seeds a turn. Asserted
+ // on the DOM marker rather than the prop so it proves the value survives the
+ // whole path from the presentation resolver through the panel.
+ await expect(panel.locator("[data-transcript-variant]")).toHaveAttribute(
+ "data-transcript-variant",
+ "conversation",
+ );
+
+ await waitForAnimations(page);
+ // Full window: the drawer against the sliver and the scrimmed channel,
+ // which is the part of this presentation a panel-only shot cannot show.
+ // Folds are left as the product leaves them — collapsed on open.
+ await page.screenshot({ path: `${SHOTS}/01-turn-in-drawer.png` });
+
+ // Second shot expanded, at the head: this is the frame that shows what the
+ // drawer's width buys — the prompt, thinking, and the reads and shell output
+ // that are invisible while the runs are folded. Scrolled back to the head
+ // because expanding overflows the panel and it is anchored to the tail.
+ await expandTranscriptRows(page);
+ await scrollTranscriptToTop(page);
+ await waitForAnimations(page);
+ await panel.screenshot({ path: `${SHOTS}/02-turn-expanded-head.png` });
+
+ // Third shot, the tail of the same expanded turn: the failed step with its
+ // error, the plan, and the answer with code. Expanded, the turn is taller
+ // than the drawer, so no single frame holds both ends of it.
+ await scrollTranscriptToBottom(page);
+ await waitForAnimations(page);
+ await panel.screenshot({ path: `${SHOTS}/03-turn-expanded-tail.png` });
+ });
+});
diff --git a/desktop/tests/e2e/agent-activity-cover.spec.ts b/desktop/tests/e2e/agent-activity-cover.spec.ts
new file mode 100644
index 0000000000..bdab356528
--- /dev/null
+++ b/desktop/tests/e2e/agent-activity-cover.spec.ts
@@ -0,0 +1,426 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds";
+import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+
+const AGENT_PUBKEY = TEST_IDENTITIES.alice.pubkey;
+
+/** Two panes fit, so the agent panel covers. */
+const WIDE_VIEWPORT = { width: 1280, height: 800 };
+
+/** Below the two-pane breakpoint, so today's presentation is unchanged. */
+const NARROW_VIEWPORT = { width: 860, height: 800 };
+
+async function waitForMockLiveSubscription(
+ page: Page,
+ channelName: string,
+ kind?: number,
+) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ ({ currentChannelName, currentKind }) =>
+ (
+ window as Window & {
+ __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
+ channelName: string;
+ kind?: number;
+ }) => boolean;
+ }
+ ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
+ channelName: currentChannelName,
+ kind: currentKind,
+ }) ?? false,
+ { currentChannelName: channelName, currentKind: kind },
+ ),
+ )
+ .toBe(true);
+}
+
+async function seedThreadRoot(page: Page) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
+ ),
+ )
+ .toBe(true);
+ return page.evaluate(() => {
+ const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName: "agents",
+ content: "Cover drawer exclusivity thread",
+ createdAt: 1_700_800_000,
+ });
+ if (!root) throw new Error("Failed to seed thread root");
+ window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelName: "agents",
+ content: "A reply so the thread summary renders.",
+ parentEventId: root.id,
+ createdAt: 1_700_800_001,
+ });
+ return root.id;
+ });
+}
+
+/**
+ * Opens the agent activity panel from the composer activity bar — the ingress
+ * that has no prior pane, so the header shows close and no back arrow.
+ */
+async function openActivityFromComposer(page: Page) {
+ await page.getByTestId("channel-agents").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("agents");
+ await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR);
+
+ await page.evaluate((pubkey) => {
+ window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
+ channelName: "agents",
+ pubkey,
+ });
+ }, AGENT_PUBKEY);
+
+ const trigger = page.getByTestId("bot-activity-composer-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const item = page.getByTestId(`bot-activity-composer-item-${AGENT_PUBKEY}`);
+ await expect(item).toBeVisible();
+ await item.click({ force: true });
+ await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
+}
+
+/**
+ * There is one covered slot, so at most one drawer overlay may be in the DOM.
+ * Asserts on overlays rather than the drawer surfaces because the overlay is
+ * what makes the channel unreachable — two of them stacked is the failure the
+ * user would actually feel.
+ */
+async function expectExactlyOneCoverDrawer(
+ page: Page,
+ expected: "agent-activity-drawer" | "focus-thread-drawer",
+) {
+ const others = ["agent-activity-drawer", "focus-thread-drawer"].filter(
+ (testId) => testId !== expected,
+ );
+ await expect(page.getByTestId(`${expected}-overlay`)).toHaveCount(1);
+ for (const testId of others) {
+ await expect(page.getByTestId(`${testId}-overlay`)).toHaveCount(0);
+ }
+ await expect(page.getByTestId("channel-drop-zone")).toHaveAttribute(
+ "inert",
+ "",
+ );
+}
+
+/**
+ * Focus must end inside the drawer that won the slot.
+ *
+ * This is a positive check, not the regression guard: the covered channel is
+ * `inert`, so a wrongly-restored focus into it is silently refused by the
+ * browser and lands on `` instead of visibly stealing focus. The
+ * discriminating assertions for the handoff live in
+ * `CoverDrawerFocusHandoff.test.mjs`, which drives the primitive directly.
+ * Polls because both the successor's capture and the loser's deferred restore
+ * land asynchronously.
+ */
+async function expectFocusInside(page: Page, testId: string) {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (currentTestId) =>
+ document
+ .querySelector(`[data-testid="${currentTestId}"]`)
+ ?.contains(document.activeElement) ?? false,
+ testId,
+ ),
+ )
+ .toBe(true);
+}
+
+/**
+ * Opens a thread while activity covers the channel.
+ *
+ * The covered channel is inert, so its thread summaries cannot be clicked. A
+ * `messageId` deep link reaches the same place: `useChannelRouteTarget` closes
+ * the agent session and opens the thread in one navigation, which is exactly the
+ * open-over-open transition under test — no closed intermediate state.
+ *
+ * The router uses hash history, so the param has to be written into the hash
+ * fragment (see the same technique in `scroll-history.spec.ts`); rewriting
+ * `location.search` would leave the router none the wiser.
+ */
+async function openThreadByMessageLink(page: Page, threadHeadId: string) {
+ await page.evaluate((targetId) => {
+ const hash = window.location.hash.replace(/^#/, "") || "/";
+ const [path, query = ""] = hash.split("?");
+ const params = new URLSearchParams(query);
+ params.set("messageId", targetId);
+ window.history.pushState(
+ {},
+ "",
+ `${window.location.pathname}#${path}?${params.toString()}`,
+ );
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ }, threadHeadId);
+}
+
+/**
+ * Opens activity while a thread covers the channel, from the thread composer's
+ * own activity bar — the one ingress that is reachable while the channel behind
+ * is inert, so the thread never closes first.
+ */
+async function openActivityFromThreadComposer(
+ page: Page,
+ threadHeadId: string,
+) {
+ await page.evaluate(
+ ({ currentThreadHeadId, pubkey }) => {
+ window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
+ channelName: "agents",
+ pubkey,
+ threadHeadId: currentThreadHeadId,
+ });
+ },
+ { currentThreadHeadId: threadHeadId, pubkey: AGENT_PUBKEY },
+ );
+
+ const drawer = page.getByTestId("focus-thread-drawer");
+ const trigger = drawer.getByTestId("bot-activity-composer-trigger");
+ await expect(trigger).toBeVisible();
+ await trigger.click();
+ const item = page.getByTestId(`bot-activity-composer-item-${AGENT_PUBKEY}`);
+ await expect(item).toBeVisible();
+ await item.click({ force: true });
+}
+
+/**
+ * Opens activity over a covering thread by URL param.
+ *
+ * Reaches the same open handler as the thread-composer ingress, but through a
+ * navigation rather than a Radix popover — so the page holds no dismissable
+ * layer of its own and a press during the overlap can be attributed to the
+ * cover surfaces alone.
+ */
+async function openActivityByParam(page: Page, channelId: string) {
+ await page.evaluate(
+ ({ currentChannelId, pubkey }) => {
+ const hash = window.location.hash.replace(/^#/, "") || "/";
+ const [path, query = ""] = hash.split("?");
+ const params = new URLSearchParams(query);
+ params.delete("messageId");
+ params.delete("thread");
+ params.set("agentSession", pubkey);
+ params.set("agentSessionChannel", currentChannelId);
+ window.history.pushState(
+ {},
+ "",
+ `${window.location.pathname}#${path}?${params.toString()}`,
+ );
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ },
+ { currentChannelId: channelId, pubkey: AGENT_PUBKEY },
+ );
+}
+
+/**
+ * The default mock bridge already seeds alice as an agent in `#agents`, which
+ * is what makes her eligible for the composer activity bar once she types.
+ * Re-seeding her through `managedAgents` instead *replaces* that relay-agent
+ * row with a managed one that is not in the channel's working-agent set, so the
+ * trigger never renders — use the default seed.
+ */
+test.beforeEach(async ({ page }) => {
+ await installMockBridge(page);
+});
+
+test("agent activity covers the channel at wide viewports", async ({
+ page,
+}) => {
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await page.goto("/");
+ await openActivityFromComposer(page);
+
+ const channel = page.getByTestId("channel-drop-zone");
+ const drawer = page.getByTestId("agent-activity-drawer");
+ const panel = page.getByTestId("agent-session-thread-panel");
+
+ // Covering, not splitting: the panel lives inside the drawer, the channel is
+ // inert behind it, and there is no split pane to resize.
+ await expect(drawer).toBeVisible();
+ await expect(drawer.getByTestId("agent-session-thread-panel")).toBeVisible();
+ await expect(channel).toHaveAttribute("inert", "");
+ await expect(
+ page.getByTestId("right-auxiliary-pane-resize-handle"),
+ ).toHaveCount(0);
+
+ // Activity never offers the thread's focus/split switch.
+ await expect(page.getByTestId("thread-view-mode-toggle")).toHaveCount(0);
+
+ // The drawer owns the entrance, so the panel must not slide too — a second
+ // animation inside a moving container compounds into a double slide.
+ await expect(panel).not.toHaveClass(/buzz-side-panel-enter/);
+
+ // Wide enough to read a transcript: the drawer takes the channel content
+ // area less the sliver, so it is far wider than the split pane it replaces.
+ const drawerWidth = (await drawer.boundingBox())?.width ?? 0;
+ expect(drawerWidth).toBeGreaterThan(700);
+
+ // The drawer captures focus, and the panel keeps its close affordance.
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ Boolean(
+ document
+ .querySelector('[data-testid="agent-activity-drawer"]')
+ ?.contains(document.activeElement),
+ ),
+ ),
+ )
+ .toBe(true);
+ await expect(page.getByTestId("agent-session-back")).toHaveCount(0);
+ await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible();
+
+ // Escape leaves — and the settings menu still gets its own press first.
+ await page.getByTestId("agent-session-settings-menu-trigger").click();
+ await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("agent-session-stop-turn")).toHaveCount(0);
+ await expect(drawer).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(panel).toHaveCount(0);
+ await expect(channel).not.toHaveAttribute("inert", "");
+
+ // The scrim is the click target back to the channel.
+ await openActivityFromComposer(page);
+ await expect(drawer).toBeVisible();
+ await page
+ .getByTestId("agent-activity-drawer-scrim")
+ .click({ position: { x: 24, y: 300 } });
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(channel).not.toHaveAttribute("inert", "");
+});
+
+test("cover drawers replace each other in both directions without stacking", async ({
+ page,
+}) => {
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await page.addInitScript(() => {
+ localStorage.setItem("buzz.channels.threadViewMode", "focus");
+ });
+ await page.goto("/");
+ const rootId = await seedThreadRoot(page);
+ await openActivityFromComposer(page);
+
+ const agentDrawer = page.getByTestId("agent-activity-drawer");
+ const threadDrawer = page.getByTestId("focus-thread-drawer");
+ await expect(agentDrawer).toBeVisible();
+ await expectExactlyOneCoverDrawer(page, "agent-activity-drawer");
+
+ // Direction 1: thread opens over activity, with no closed intermediate. The
+ // covered channel is inert so its thread summaries can't be clicked, but a
+ // message link resolves through the same route-target handler, which clears
+ // the agent session and opens the thread in one navigation.
+ await openThreadByMessageLink(page, rootId);
+ await expect(threadDrawer).toBeVisible();
+ await expect(agentDrawer).toHaveCount(0);
+ await expectExactlyOneCoverDrawer(page, "focus-thread-drawer");
+ // The replaced surface's param is gone, not merely outranked.
+ await expect(page).not.toHaveURL(/agentSession=/);
+ await expect(page).toHaveURL(new RegExp(`thread=${rootId}`));
+ await expectFocusInside(page, "focus-thread-drawer");
+
+ // Direction 2: activity opens over the thread, again with no closed
+ // intermediate — the thread drawer's own composer activity bar is live while
+ // it covers, and its trigger calls the same open handler.
+ await openActivityFromThreadComposer(page, rootId);
+ await expect(agentDrawer).toBeVisible();
+ await expect(threadDrawer).toHaveCount(0);
+ await expectExactlyOneCoverDrawer(page, "agent-activity-drawer");
+ await expect(page).not.toHaveURL(new RegExp(`thread=${rootId}`));
+ await expect(page).toHaveURL(/agentSession=/);
+ await expectFocusInside(page, "agent-activity-drawer");
+});
+
+test("a single Escape closes the drawer that replaced another", async ({
+ page,
+}) => {
+ // `AnimatePresence` holds the replaced drawer mounted through its exit
+ // animation, so for a short window (~210ms measured) two surfaces are
+ // listening for Escape at once. The exiting one must stand down at both
+ // layers: the drawer's own capture-phase claim would consume the press with
+ // `stopImmediatePropagation`, and — on activity's path, where the drawer sets
+ // `ownsEscape={false}` and the panel handles the key — the exiting panel's
+ // `preventDefault` would swallow it just as completely, since `useEscapeKey`
+ // ignores an already-`defaultPrevented` event. Either one alone forces a
+ // second press. Deliberately does not wait for the overlap to settle; that
+ // wait is what makes the other tests here blind to this.
+ await page.setViewportSize(WIDE_VIEWPORT);
+ await page.addInitScript(() => {
+ localStorage.setItem("buzz.channels.threadViewMode", "focus");
+ });
+ await page.goto("/");
+ const rootId = await seedThreadRoot(page);
+ // Both ingresses here are navigations, so the page never holds a Radix layer
+ // of its own — the composer ingress opens a popover that would legitimately
+ // own the next Escape, and it clears at the same time as the outgoing drawer
+ // (both ~233ms measured), leaving no moment where the overlap is live and the
+ // popover is gone. Keeping the page free of dismissable layers is what lets
+ // the press be attributed to the cover surfaces alone.
+ await page.getByTestId("channel-agents").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("agents");
+ const channelId = await page.evaluate(() => {
+ const hash = window.location.hash.replace(/^#/, "");
+ return hash.split("?")[0].split("/").pop() ?? "";
+ });
+ await openThreadByMessageLink(page, rootId);
+ await expect(page.getByTestId("focus-thread-drawer")).toBeVisible();
+
+ // Activity replaces the thread. The successor is up, and the outgoing thread
+ // drawer is still mounted mid-exit.
+ await openActivityByParam(page, channelId);
+ await expect(page.getByTestId("agent-activity-drawer")).toBeVisible();
+ expect(
+ await page.getByTestId("focus-thread-drawer-overlay").count(),
+ ).toBeGreaterThan(0);
+ // Nothing but a cover drawer can absorb the press below.
+ await expect(page.locator("[data-radix-popper-content-wrapper]")).toHaveCount(
+ 0,
+ );
+
+ // One press, inside that window, and activity is gone.
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(page.getByTestId("agent-session-thread-panel")).toHaveCount(0);
+ await expect(page.getByTestId("channel-drop-zone")).not.toHaveAttribute(
+ "inert",
+ "",
+ );
+ await expect(page).not.toHaveURL(/agentSession=/);
+});
+
+test("narrow viewports keep the existing activity presentation", async ({
+ page,
+}) => {
+ await page.setViewportSize(NARROW_VIEWPORT);
+ await page.goto("/");
+ await openActivityFromComposer(page);
+
+ await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
+ await expect(page.getByTestId("agent-activity-drawer")).toHaveCount(0);
+ await expect(page.getByTestId("agent-activity-drawer-overlay")).toHaveCount(
+ 0,
+ );
+ await expect(page.getByTestId("agent-activity-drawer-scrim")).toHaveCount(0);
+ // Below the breakpoint the channel is replaced rather than covered, so the
+ // pane it would be made inert behind is not rendered at all — and nothing
+ // else on the page is inert either.
+ await expect(page.getByTestId("channel-drop-zone")).toHaveCount(0);
+ expect(await page.locator("[inert]").count()).toBe(0);
+});
diff --git a/desktop/tests/e2e/observer-feed-screenshots.spec.ts b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
index 44ff609c9e..e7fd359bc4 100644
--- a/desktop/tests/e2e/observer-feed-screenshots.spec.ts
+++ b/desktop/tests/e2e/observer-feed-screenshots.spec.ts
@@ -606,9 +606,25 @@ test.describe("observer feed screenshots", () => {
},
]);
- await expect(feedPanel.getByText("Commands", { exact: true })).toBeVisible({
- timeout: 5_000,
- });
+ // This panel is the agent activity cover drawer, so it renders the reading
+ // (`conversation`) variant, where a plain lifecycle status recedes to a
+ // centered divider whose label joins the title and detail. The row is not
+ // lost — the previous `getByText("Commands", { exact: true })` encoded the
+ // dense variant's separate title span, so it stopped describing this
+ // surface the moment the variant was pinned to the drawer.
+ //
+ // Asserting the row, its recede presentation and its full joined label is
+ // strictly stronger than the bare text node it replaces: a variant flip or
+ // a dropped detail half both fail here.
+ const commandsRow = feedPanel
+ .getByTestId("transcript-lifecycle-item")
+ .filter({ hasText: "Commands available: 3" });
+ await expect(commandsRow).toBeVisible({ timeout: 5_000 });
+ await expect(commandsRow).toHaveAttribute(
+ "data-variant",
+ "conversation-divider",
+ );
+ await expect(commandsRow).toHaveText("Commands · Commands available: 3");
await settleAnimations(feedPanel);
await feedPanel.screenshot({
path: `${SHOTS}/09-available-commands-update.png`,