diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx
index f032eff9d0..b3ebcc50c0 100644
--- a/desktop/src/app/routes/channels.$channelId.tsx
+++ b/desktop/src/app/routes/channels.$channelId.tsx
@@ -17,6 +17,8 @@ type ChannelRouteSearch = {
* composer can verify it has the right draft before firing.
*/
autoSend?: string;
+ /** Sentinel `"1"` promoting the agent session panel to full-screen harness mode. */
+ harness?: string;
messageId?: string;
profile?: string;
profileTab?: ProfilePanelTab;
@@ -35,6 +37,7 @@ function validateChannelSearch(
return {
agentSession: nonEmptyString(search.agentSession),
autoSend: nonEmptyString(search.autoSend),
+ harness: nonEmptyString(search.harness),
messageId: nonEmptyString(search.messageId),
profile: nonEmptyString(search.profile),
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index 29bfd7dbab..19d2f03f7f 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -116,6 +116,7 @@ export function AgentSessionTranscriptList({
agentName,
agentPubkey,
autoTail = false,
+ liveStatusSlot,
channelId = null,
emptyDescription,
emptyState = "idle",
@@ -126,6 +127,12 @@ export function AgentSessionTranscriptList({
variant = "default",
}: AgentTranscriptIdentityProps & {
autoTail?: boolean;
+ /**
+ * Rendered inline beside the turn-liveness indicator. Harness mode puts its
+ * live status there (buzzword, elapsed, tokens) so the two read as one line
+ * instead of stacking a second strip above the composer.
+ */
+ liveStatusSlot?: React.ReactNode;
channelId?: string | null;
emptyDescription: string;
emptyState?: AgentSessionTranscriptEmptyState;
@@ -278,7 +285,12 @@ export function AgentSessionTranscriptList({
);
})}
- {isTurnLive && !isCompactPreview ? : null}
+ {isTurnLive && !isCompactPreview ? (
+
+
+ {liveStatusSlot}
+
+ ) : null}
@@ -518,6 +530,7 @@ function SameKindSummaryItem({
<>
["agent"];
+ channelId: string | null;
+ channelName: string | null;
+ participants: readonly HarnessParticipant[];
+ isWorking: boolean;
+ /** Gate destructive turn cancellation to admins / the turn's initiator. */
+ canCancelTurn: boolean;
+ onCancelTurn?: () => void;
+ onExit: () => void;
+ composerDisabled?: boolean;
+ isSending?: boolean;
+ /** Human messages in the originating thread, oldest first. */
+ threadMessages?: readonly HarnessThreadMessage[];
+ /** Participants currently composing, from the channel's typing stream. */
+ typingParticipants?: readonly HarnessParticipant[];
+ /**
+ * Event ids of this thread's messages. Scopes the transcript to the turns
+ * they started, keeping threads in the same channel independent.
+ */
+ threadMessageIds?: ReadonlySet;
+ /** Extra transcript rows to interleave — the agent's published replies. */
+ extraTranscriptItems?: React.ComponentProps<
+ typeof ManagedAgentSessionPanel
+ >["extraTranscriptItems"];
+ /**
+ * Sends a message into this harness's channel. The agent's pubkey is always
+ * appended to `mentionPubkeys` before this fires — see `handleSend`.
+ */
+ onSend?: (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ channelId?: string | null,
+ ) => Promise;
+ pendingApproval?: HarnessPendingApproval | null;
+ onApprove?: (id: string) => void;
+ onDeny?: (id: string) => void;
+ profiles?: UserProfileLookup;
+};
+
+// Matches the members sidebar's section heading treatment so the rail reads as
+// part of the same design system rather than a bespoke panel.
+const BUZZWORD_INTERVAL_SECONDS = 5;
+
+const RAIL_DEFAULT_WIDTH = 320;
+const RAIL_MIN_WIDTH = 220;
+const RAIL_MAX_WIDTH = 520;
+
+const RAIL_SECTION_LABEL =
+ "text-sm font-semibold tracking-tight text-muted-foreground";
+
+export function HarnessModeScreen({
+ agent,
+ channelId,
+ channelName,
+ participants,
+ isWorking,
+ canCancelTurn,
+ onCancelTurn,
+ onExit,
+ composerDisabled = false,
+ isSending = false,
+ onSend,
+ threadMessages,
+ typingParticipants,
+ threadMessageIds,
+ extraTranscriptItems,
+ pendingApproval = null,
+ onApprove,
+ onDeny,
+ profiles,
+}: HarnessModeScreenProps) {
+ // Escape exits the harness rather than closing the whole window.
+ React.useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ onExit();
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [onExit]);
+
+ // ── Live status strip ──────────────────────────────────────────────────────
+ const [statusItems, setStatusItems] = React.useState([]);
+ const onTranscriptChange = React.useCallback((items: HarnessStatusItem[]) => {
+ setStatusItems(items);
+ }, []);
+ const status = React.useMemo(
+ () => deriveHarnessStatus(statusItems),
+ [statusItems],
+ );
+
+ // One interval drives both the elapsed clock and the buzzword cycle, so the
+ // word changes on a visible beat rather than every render.
+ const [tick, setTick] = React.useState(0);
+ const turnStartRef = React.useRef(null);
+ const [elapsedMs, setElapsedMs] = React.useState(0);
+
+ React.useEffect(() => {
+ if (!isWorking) {
+ turnStartRef.current = null;
+ return;
+ }
+ if (turnStartRef.current === null) {
+ turnStartRef.current = performance.now();
+ }
+ let seconds = 0;
+ const id = window.setInterval(() => {
+ seconds += 1;
+ // Elapsed updates every second; the buzzword changes every 5 so it reads
+ // as a status rather than a flicker.
+ if (seconds % BUZZWORD_INTERVAL_SECONDS === 0) {
+ setTick((value) => value + 1);
+ }
+ if (turnStartRef.current !== null) {
+ setElapsedMs(performance.now() - turnStartRef.current);
+ }
+ }, 1000);
+ return () => window.clearInterval(id);
+ }, [isWorking]);
+
+ // Rendered beside the liveness bees rather than as its own strip, so the
+ // "what it's doing" reads as one line with the animation.
+ const liveStatus = (
+
+ {buzzwordAt(tick)}…
+
+ {formatElapsed(elapsedMs)}
+ {status.tokensUsed !== null
+ ? ` · ↓ ${formatTokens(status.tokensUsed)} tokens`
+ : ""}
+ {status.toolsTotal > 0
+ ? ` · ${status.toolsDone}/${status.toolsTotal} tools`
+ : ""}
+
+ {status.summary ? (
+
+ {status.summary}
+
+ ) : null}
+
+ );
+
+ // Rail width is drag-resizable within bounds. Clamped rather than free so the
+ // transcript always keeps a readable column.
+ const [railWidth, setRailWidth] = React.useState(RAIL_DEFAULT_WIDTH);
+ const dragStateRef = React.useRef<{
+ startX: number;
+ startWidth: number;
+ } | null>(null);
+ const mainRef = React.useRef(null);
+
+ const onDragStart = React.useCallback(
+ (event: React.PointerEvent) => {
+ event.preventDefault();
+ dragStateRef.current = { startX: event.clientX, startWidth: railWidth };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ },
+ [railWidth],
+ );
+
+ const onDragMove = React.useCallback(
+ (event: React.PointerEvent) => {
+ const drag = dragStateRef.current;
+ if (!drag) {
+ return;
+ }
+ const next = drag.startWidth + (event.clientX - drag.startX);
+ setRailWidth(Math.min(RAIL_MAX_WIDTH, Math.max(RAIL_MIN_WIDTH, next)));
+ },
+ [],
+ );
+
+ const onDragEnd = React.useCallback(
+ (event: React.PointerEvent) => {
+ dragStateRef.current = null;
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ },
+ [],
+ );
+
+ // Newest first: the rail is a "what just happened" log, so the latest query
+ // should be reachable without scrolling to the bottom of a long thread.
+ const railMessages = React.useMemo(
+ () =>
+ threadMessages
+ ? [...threadMessages].sort((a, b) => b.createdAt - a.createdAt)
+ : undefined,
+ [threadMessages],
+ );
+
+ // Scroll the transcript to a rail row's anchor. Queried live rather than held
+ // in a ref map because transcript rows mount and unmount as the window scrolls.
+ const presentCount = participants.filter(
+ (participant) => participant.status !== "offline",
+ ).length;
+
+ // In harness mode you are, by definition, addressing this agent — so the
+ // agent is always p-tagged even when the text carries no literal @mention.
+ // Without it the relay never routes the message to the harness's
+ // `#p`-filtered subscription and the turn silently never starts.
+ const handleSend = React.useCallback(
+ async (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ sendChannelId?: string | null,
+ ) => {
+ if (!onSend) {
+ return;
+ }
+ const withAgent = mentionPubkeys.some(
+ (pubkey) => pubkey.toLowerCase() === agent.pubkey.toLowerCase(),
+ )
+ ? mentionPubkeys
+ : [...mentionPubkeys, agent.pubkey];
+
+ await onSend(content, withAgent, mediaTags, sendChannelId ?? channelId);
+ },
+ [agent.pubkey, channelId, onSend],
+ );
+
+ return (
+ // Sits below the global top chrome rather than over it: that strip hosts the
+ // macOS traffic lights and drag region, so covering it (inset-0) puts the
+ // header under the window controls. Offsetting by the same CSS var the rest
+ // of the app uses keeps the harness aligned with every other surface.
+
+ {/* Same two-layer surface the app shell uses: the brand gradient behind,
+ content floating on a rounded card. Without these the harness reads as
+ a flat modal bolted onto Buzz rather than one of its screens. */}
+
+
+
+
+
+
+ {/* Pointer-capture drag rather than a library: the handle is a thin
+ seam that reveals a rule on hover, matching the app's pane resizers. */}
+
+
+
+
+
+ {/* min-h-0 is load-bearing: without it this column flex child cannot
+ shrink below its content height, so the transcript's own scroll
+ container never gets a bounded height and ContentSurface's
+ overflow-hidden clips the tail instead of letting it scroll. */}
+
+ {/* showRaw={false}: the raw JSON-RPC rail is a debugging surface, not
+ part of the shared session view. It belongs behind the existing
+ per-panel toggle, not pinned open in a full-screen room. */}
+
+
+
+ {onSend ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/HarnessModeView.tsx b/desktop/src/features/agents/ui/HarnessModeView.tsx
new file mode 100644
index 0000000000..9ee09864cc
--- /dev/null
+++ b/desktop/src/features/agents/ui/HarnessModeView.tsx
@@ -0,0 +1,243 @@
+import * as React from "react";
+
+import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
+import {
+ HarnessModeScreen,
+ type HarnessParticipant,
+ type HarnessThreadMessage,
+} from "@/features/agents/ui/HarnessModeScreen";
+import { useChannelMembersQuery } from "@/features/channels/hooks";
+import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes";
+import type { TimelineMessage } from "@/features/messages/types";
+import { usePresenceQuery } from "@/features/presence/hooks";
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import type { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
+import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
+
+type HarnessModeViewProps = {
+ agent: React.ComponentProps["agent"];
+ canCancelTurn: boolean;
+ channelId: string | null;
+ channelName: string | null;
+ currentUserPubkey: string | null;
+ onCancelTurn?: () => void;
+ onExit: () => void;
+ composerDisabled?: boolean;
+ isSending?: boolean;
+ onSend?: (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ channelId?: string | null,
+ ) => Promise;
+ /** Messages of the originating thread (head first), for the history rail. */
+ threadMessages?: readonly TimelineMessage[];
+ /**
+ * The agent's own published replies in this thread. Folded into the centre
+ * transcript so the final answer appears there, not just the tool call that
+ * produced it.
+ */
+ agentMessages?: readonly TimelineMessage[];
+ /** Pubkeys currently typing in this channel/thread. */
+ typingPubkeys?: readonly string[];
+ profiles?: UserProfileLookup;
+};
+
+/**
+ * Data container for the full-screen harness view.
+ *
+ * Keeps every query and derivation out of [`HarnessModeScreen`] so the screen
+ * stays presentational (and screenshot-testable with fixture props).
+ */
+export function HarnessModeView({
+ agent,
+ canCancelTurn,
+ channelId,
+ channelName,
+ currentUserPubkey,
+ onCancelTurn,
+ onExit,
+ composerDisabled,
+ isSending,
+ onSend,
+ threadMessages,
+ agentMessages,
+ typingPubkeys,
+ profiles,
+}: HarnessModeViewProps) {
+ const membersQuery = useChannelMembersQuery(channelId);
+ const members = membersQuery.data;
+
+ // Agents are members too, but the roster answers "who is watching this run",
+ // so the agent doing the work is not one of its own spectators.
+ const humanMembers = React.useMemo(
+ () => (members ?? []).filter((member) => !member.isAgent),
+ [members],
+ );
+
+ const memberPubkeys = React.useMemo(
+ () => humanMembers.map((member) => member.pubkey),
+ [humanMembers],
+ );
+
+ const presenceQuery = usePresenceQuery(memberPubkeys, {
+ enabled: memberPubkeys.length > 0,
+ });
+ const presence = presenceQuery.data;
+
+ const participants = React.useMemo(() => {
+ const selfPubkey = currentUserPubkey
+ ? normalizePubkey(currentUserPubkey)
+ : null;
+
+ return humanMembers
+ .map((member) => {
+ const normalized = normalizePubkey(member.pubkey);
+ const profile = profiles?.[normalized];
+ return {
+ pubkey: member.pubkey,
+ displayName:
+ profile?.displayName ||
+ member.displayName ||
+ truncatePubkey(normalized ?? member.pubkey),
+ avatarUrl: profile?.avatarUrl ?? null,
+ // Carry the real tri-state through — "away" is a distinct presence the
+ // shared PresenceDot already renders, so collapsing it to a boolean
+ // would show idle teammates as gone.
+ status: presence?.[normalized] ?? "offline",
+ isSelf: selfPubkey !== null && normalized === selfPubkey,
+ };
+ })
+ .sort((a, b) => {
+ const aPresent = a.status !== "offline";
+ const bPresent = b.status !== "offline";
+ if (aPresent !== bPresent) {
+ return aPresent ? -1 : 1;
+ }
+ return a.displayName.localeCompare(b.displayName);
+ });
+ }, [currentUserPubkey, humanMembers, presence, profiles]);
+
+ const working = useAgentWorking(agent.pubkey, channelId);
+
+ // The agent's published replies, shaped as assistant transcript rows. The
+ // observer stream carries reasoning and tool calls but never the answer text
+ // (replies go out through the `buzz` CLI), so without this the transcript
+ // shows the work and omits the conclusion.
+ const agentReplyItems = React.useMemo(() => {
+ if (!agentMessages || agentMessages.length === 0) {
+ return [];
+ }
+ return agentMessages.map((message) => ({
+ id: `reply:${message.id}`,
+ type: "message" as const,
+ renderClass: "message" as const,
+ role: "assistant" as const,
+ title: message.author,
+ text: message.body,
+ timestamp: new Date(message.createdAt * 1000).toISOString(),
+ messageId: message.id,
+ authorPubkey: message.pubkey ?? null,
+ channelId,
+ }));
+ }, [agentMessages, channelId]);
+
+ // The agent's own replies plus every human prompt in this thread, as
+ // transcript rows.
+ //
+ // Prompts have to be injected because the ACP stream cannot be relied on to
+ // emit one. A goose-native steer writes a `steer:` row, but Claude Code's
+ // adapter has no such frame — the harness falls back to cancel+merge, so a
+ // mid-turn message is folded into a merged re-prompt and never gets a row of
+ // its own. Injecting here (deduped by `messageId`, so the stream's own row
+ // wins when it exists) puts the message in the chat in timestamp order, with
+ // the answer below it.
+ const injectedItems = React.useMemo(() => {
+ const prompts: TranscriptItem[] = (threadMessages ?? []).map((message) => ({
+ id: `prompt:${message.id}`,
+ type: "message" as const,
+ renderClass: "message" as const,
+ role: "user" as const,
+ title: message.author,
+ text: message.body,
+ timestamp: new Date(message.createdAt * 1000).toISOString(),
+ messageId: message.id,
+ authorPubkey: message.pubkey ?? null,
+ channelId,
+ }));
+ return [...agentReplyItems, ...prompts];
+ }, [agentReplyItems, channelId, threadMessages]);
+
+ // Thread rail rows. `isSelf` drives the unread badge, which must ignore your
+ // own sends.
+ const threadHistory = React.useMemo<
+ HarnessThreadMessage[] | undefined
+ >(() => {
+ if (!threadMessages) {
+ return undefined;
+ }
+ const selfPubkey = currentUserPubkey
+ ? normalizePubkey(currentUserPubkey)
+ : null;
+ return threadMessages.map((message) => ({
+ id: message.id,
+ author: message.author,
+ avatarUrl: message.avatarUrl ?? null,
+ body: message.body,
+ time: message.time,
+ createdAt: message.createdAt,
+ authorPubkey: message.pubkey ?? null,
+ isSelf:
+ selfPubkey !== null &&
+ normalizePubkey(message.pubkey ?? "") === selfPubkey,
+ }));
+ }, [currentUserPubkey, threadMessages]);
+
+ const typingParticipants = React.useMemo(() => {
+ if (!typingPubkeys || typingPubkeys.length === 0) {
+ return [];
+ }
+ const typing = new Set(
+ typingPubkeys.map((pubkey) => normalizePubkey(pubkey)),
+ );
+ return participants.filter((participant) =>
+ typing.has(normalizePubkey(participant.pubkey)),
+ );
+ }, [participants, typingPubkeys]);
+
+ // Real thread scope: the ids of this thread's messages. The panel resolves
+ // them to turn ids, so two threads in the same channel stay independent —
+ // which a timestamp window could never do (the older thread's window always
+ // swallows the newer thread's frames).
+ const threadMessageIds = React.useMemo(() => {
+ const ids = new Set();
+ for (const message of threadMessages ?? []) {
+ ids.add(message.id);
+ }
+ for (const message of agentMessages ?? []) {
+ ids.add(message.id);
+ }
+ return ids;
+ }, [agentMessages, threadMessages]);
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx
index 594fa1cbaa..d694ecaeee 100644
--- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx
@@ -30,8 +30,11 @@ import {
mergeObserverEventWindows,
resolveDisplayEvents,
resolveRawRailLayout,
+ isInjectedTranscriptId,
+ mergeTranscriptItems,
scopeByChannel,
} from "./agentSessionPanelLayout";
+import { scopeItemsToThread } from "./threadTurnScope";
import { shorten } from "./agentSessionUtils";
import {
useObserverEvents,
@@ -57,6 +60,47 @@ type ManagedAgentSessionPanelProps = {
profiles?: UserProfileLookup;
rawEventsOverride?: ObserverEvent[];
transcriptOverride?: TranscriptItem[];
+ /**
+ * Transcript rows to interleave with the observer-derived ones, by timestamp.
+ * Harness mode uses this to fold in the agent's published replies, which the
+ * ACP stream never carries (a reply is sent via the `buzz` CLI, so only the
+ * tool call appears — not the answer text).
+ */
+ extraTranscriptItems?: TranscriptItem[];
+ /**
+ * Hide only the *trailing* ACP narration row, plus usage rows.
+ *
+ * Two different assistant texts reach this panel. Intermediate narration —
+ * "now let me check X" between tool calls — is exactly the live progress a
+ * human wants to steer against, so it stays. The final narration is a
+ * third-person self-report ("Answered both questions…") that duplicates the
+ * published reply, so it goes. Hiding *all* narration (the earlier behaviour)
+ * threw away the useful half.
+ *
+ * Usage rows are dropped too: the harness surfaces live token counts in its
+ * status strip instead of as transcript entries.
+ */
+ hideTrailingNarration?: boolean;
+ /** Inline content beside the turn-liveness indicator. */
+ liveStatusSlot?: React.ReactNode;
+ /**
+ * Event ids of the messages belonging to one thread. When provided, the
+ * transcript is scoped to the turns those messages started.
+ *
+ * This is real scoping, not a time window: every item carries `turnId`, and a
+ * user row carries the `messageId` that triggered its turn, so a thread's
+ * turns can be identified exactly. A time-based boundary cannot separate two
+ * threads in the same channel — the older thread's window necessarily
+ * contains the newer thread's frames, which is precisely the leak this
+ * replaces.
+ */
+ threadMessageIds?: ReadonlySet;
+ /**
+ * Observe the transcript actually rendered. Harness mode derives its live
+ * status strip (running commands, tokens, elapsed) from this rather than
+ * re-deriving the event pipeline itself.
+ */
+ onTranscriptChange?: (items: TranscriptItem[]) => void;
};
export function ManagedAgentSessionPanel({
@@ -75,6 +119,11 @@ export function ManagedAgentSessionPanel({
profiles,
rawEventsOverride,
transcriptOverride,
+ extraTranscriptItems,
+ hideTrailingNarration = false,
+ liveStatusSlot,
+ threadMessageIds,
+ onTranscriptChange,
}: ManagedAgentSessionPanelProps) {
const hasObserver = isManagedAgentActive(agent);
// Always read from the store — archived frames are ingested regardless of
@@ -116,7 +165,52 @@ export function ManagedAgentSessionPanel({
() => buildTranscriptState(combinedEvents).items,
[combinedEvents],
);
- const displayTranscript = transcriptOverride ?? derivedTranscript;
+ const displayTranscript = React.useMemo(() => {
+ const merged = mergeTranscriptItems(
+ transcriptOverride ?? derivedTranscript,
+ extraTranscriptItems ?? [],
+ );
+ // Thread scoping: drop only turns provably belonging to another thread, so
+ // the in-flight turn (whose prompt row may not exist yet) stays visible.
+ const scoped = scopeItemsToThread(
+ merged,
+ threadMessageIds,
+ isInjectedTranscriptId,
+ );
+
+ if (!hideTrailingNarration) {
+ return scoped;
+ }
+ // Usage rows always go — the status strip owns token reporting.
+ const withoutUsage = scoped.filter((item) => !item.id.startsWith("usage:"));
+ // Drop only the last `assistant:` row, and only once a published reply
+ // exists to supersede it. Everything earlier is intermediate progress.
+ const hasPublishedReply = withoutUsage.some((item) =>
+ item.id.startsWith("reply:"),
+ );
+ if (!hasPublishedReply) {
+ return withoutUsage;
+ }
+ let lastNarrationIndex = -1;
+ withoutUsage.forEach((item, index) => {
+ if (item.id.startsWith("assistant:")) {
+ lastNarrationIndex = index;
+ }
+ });
+ return lastNarrationIndex === -1
+ ? withoutUsage
+ : withoutUsage.filter((_, index) => index !== lastNarrationIndex);
+ }, [
+ derivedTranscript,
+ extraTranscriptItems,
+ hideTrailingNarration,
+ threadMessageIds,
+ transcriptOverride,
+ ]);
+
+ React.useEffect(() => {
+ onTranscriptChange?.(displayTranscript);
+ }, [displayTranscript, onTranscriptChange]);
const displayEvents = React.useMemo(
() => resolveDisplayEvents(combinedEvents, rawEventsOverride),
@@ -162,6 +256,7 @@ export function ManagedAgentSessionPanel({
profiles={profiles}
rawLayout={rawLayout}
showRaw={showRaw}
+ liveStatusSlot={liveStatusSlot}
transcript={displayTranscript}
transcriptContentClassName={transcriptContentClassName}
transcriptVariant={transcriptVariant}
@@ -206,6 +301,7 @@ function SessionHeader({
}
function SessionBody({
+ liveStatusSlot,
agentAvatarUrl,
agentName,
agentPubkey,
@@ -240,6 +336,7 @@ function SessionBody({
profiles?: UserProfileLookup;
rawLayout: "responsive" | "exclusive";
showRaw: boolean;
+ liveStatusSlot?: React.ReactNode;
transcript: TranscriptItem[];
transcriptContentClassName?: string;
transcriptVariant: AgentSessionTranscriptVariant;
@@ -282,6 +379,7 @@ function SessionBody({
)}
>
`), so a reader can
+ * still collapse it — the point is that grouped tool runs are visible by
+ * default instead of hiding what the agent just did behind a click.
+ */
+ defaultOpen?: boolean;
children: React.ReactNode;
className?: string;
openToneScope?: Exclude;
@@ -38,6 +44,7 @@ type ActivityRowContentComponent = React.FC & {
export function ActivityRow({
children,
className,
+ defaultOpen = false,
openToneScope = "tool",
testId,
title,
@@ -65,8 +72,12 @@ export function ActivityRow({
className={cn(
openToneScope === "summary" ? "group/summary" : "group",
"not-prose w-full",
+ // Grouped runs read as a side note, not body copy: cap the measure so
+ // the summary and its children stop spanning the full transcript width.
+ openToneScope === "summary" && "max-w-[55%] min-w-fit",
className,
)}
+ open={defaultOpen}
data-testid={testId}
title={title}
>
@@ -90,7 +101,11 @@ export function ActivityRow({
{contentChildren.map((child, index) => (
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
index 181e4febf5..95782f0c37 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx
@@ -6,6 +6,7 @@ import {
} from "@/features/profile/lib/identity";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { cn } from "@/shared/lib/cn";
+import { authorAccent } from "../authorAccent";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -89,6 +90,8 @@ export function UserMessageBubble({
}
: {};
+ const accent = authorAccent(item.authorPubkey);
+
return (
(
return items.filter((item) => item.channelId === channelId);
}
+/**
+ * Id prefixes used by caller-injected transcript rows.
+ *
+ * Injected rows are thread-scoped by construction — the caller only builds them
+ * from one thread's messages — and they carry no `turnId`, so any turn-based
+ * scoping must exempt them explicitly or they vanish. Keeping the prefixes here
+ * stops the injector and the filter from drifting apart.
+ */
+export const INJECTED_TRANSCRIPT_PREFIXES = ["reply:", "prompt:"] as const;
+
+export function isInjectedTranscriptId(id: string): boolean {
+ return INJECTED_TRANSCRIPT_PREFIXES.some((prefix) => id.startsWith(prefix));
+}
+
+/**
+ * Merge two timestamp-ordered transcript windows into one ascending stream.
+ *
+ * Used to fold the agent's *published* replies into the observer transcript.
+ * The observer stream only sees the agent's reasoning and tool calls, because a
+ * reply is sent by shelling out to the `buzz` CLI — so the answer itself lands
+ * in the channel as a kind-9 event the ACP frames never contain. Interleaving
+ * them by time reconstructs the full prompt → work → answer shape.
+ *
+ * Items with equal timestamps keep base-before-extra order so a reply always
+ * renders after the tool call that produced it. Unparseable timestamps sort
+ * last rather than throwing.
+ */
+export function mergeTranscriptItems<
+ T extends { id: string; timestamp: string; messageId?: string | null },
+>(base: readonly T[], extra: readonly T[]): T[] {
+ if (extra.length === 0) return base as T[];
+ const seenIds = new Set(base.map((item) => item.id));
+ // Also dedupe on source event id: an injected row and the ACP stream's own
+ // row for the same message have different ids but are the same message, and
+ // showing both would double every prompt.
+ const seenMessageIds = new Set(
+ base
+ .map((item) => item.messageId)
+ .filter((id): id is string => typeof id === "string" && id.length > 0),
+ );
+ const combined = [
+ ...base,
+ ...extra.filter(
+ (item) =>
+ !seenIds.has(item.id) &&
+ !(item.messageId && seenMessageIds.has(item.messageId)),
+ ),
+ ];
+ const at = (value: string) => {
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
+ };
+ return combined
+ .map((item, index) => ({ item, index }))
+ .sort(
+ (a, b) =>
+ at(a.item.timestamp) - at(b.item.timestamp) || a.index - b.index,
+ )
+ .map(({ item }) => item);
+}
+
/**
* Merge live and archived raw `ObserverEvent[]` arrays into a single
* deduplicated, chronologically-sorted array.
diff --git a/desktop/src/features/agents/ui/authorAccent.test.mjs b/desktop/src/features/agents/ui/authorAccent.test.mjs
new file mode 100644
index 0000000000..b447e69107
--- /dev/null
+++ b/desktop/src/features/agents/ui/authorAccent.test.mjs
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { authorAccent, authorHue } from "./authorAccent.ts";
+
+test("same pubkey always yields the same hue", () => {
+ const key = "aa".repeat(32);
+ assert.equal(authorHue(key), authorHue(key));
+});
+
+test("different pubkeys generally differ", () => {
+ const a = authorHue("aa".repeat(32));
+ const b = authorHue("bb".repeat(32));
+ const c = authorHue("cc".repeat(32));
+ assert.equal(new Set([a, b, c]).size >= 2, true);
+});
+
+test("hue stays within range", () => {
+ for (const key of ["", "a", "ff".repeat(32), "zzz"]) {
+ const hue = authorHue(key);
+ assert.equal(hue >= 0 && hue < 360, true, `out of range for ${key}`);
+ assert.equal(Number.isInteger(hue), true);
+ }
+});
+
+test("missing pubkey is handled without throwing", () => {
+ assert.equal(authorHue(null), 0);
+ assert.equal(authorHue(undefined), 0);
+});
+
+test("accent returns usable css colour strings", () => {
+ const accent = authorAccent("aa".repeat(32));
+ assert.match(accent.border, /^hsl\(\d+ 70% 55%\)$/);
+ assert.match(accent.background, /^hsl\(\d+ 70% 55% \/ 0\.10\)$/);
+});
diff --git a/desktop/src/features/agents/ui/authorAccent.ts b/desktop/src/features/agents/ui/authorAccent.ts
new file mode 100644
index 0000000000..b755d2b3c7
--- /dev/null
+++ b/desktop/src/features/agents/ui/authorAccent.ts
@@ -0,0 +1,43 @@
+/**
+ * Stable per-author accent colour for transcript user messages.
+ *
+ * A shared harness session can have several people prompting the same agent, so
+ * "who said this" has to be readable while scrolling past. Colour is derived
+ * from the author's pubkey rather than assigned by join order, so the same
+ * person is the same colour in every client and across restarts — no shared
+ * state to keep in sync.
+ *
+ * Saturation and lightness are fixed and mid-range so the hue reads on both the
+ * light and dark themes; only the hue varies.
+ */
+
+const HUE_STEPS = 360;
+
+/** Deterministic 0–359 hue from a pubkey (FNV-1a, so no crypto dependency). */
+export function authorHue(pubkey: string | null | undefined): number {
+ if (!pubkey) {
+ return 0;
+ }
+ let hash = 0x811c9dc5;
+ for (let index = 0; index < pubkey.length; index += 1) {
+ hash ^= pubkey.charCodeAt(index);
+ // FNV prime, kept in 32-bit range via Math.imul.
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return hash % HUE_STEPS;
+}
+
+export type AuthorAccent = {
+ /** Left rule and author-name colour. */
+ border: string;
+ /** Faint fill so the message block is distinguishable at a glance. */
+ background: string;
+};
+
+export function authorAccent(pubkey: string | null | undefined): AuthorAccent {
+ const hue = authorHue(pubkey);
+ return {
+ border: `hsl(${hue} 70% 55%)`,
+ background: `hsl(${hue} 70% 55% / 0.10)`,
+ };
+}
diff --git a/desktop/src/features/agents/ui/harnessStatus.test.mjs b/desktop/src/features/agents/ui/harnessStatus.test.mjs
new file mode 100644
index 0000000000..b23aa8bc88
--- /dev/null
+++ b/desktop/src/features/agents/ui/harnessStatus.test.mjs
@@ -0,0 +1,156 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buzzwordAt,
+ deriveHarnessStatus,
+ formatElapsed,
+ formatTokens,
+ HARNESS_BUZZWORDS,
+ parseUsageText,
+ shellCommandOf,
+} from "./harnessStatus.ts";
+
+test("buzzwords cycle and never index out of range", () => {
+ assert.equal(buzzwordAt(0), HARNESS_BUZZWORDS[0]);
+ assert.equal(buzzwordAt(HARNESS_BUZZWORDS.length), HARNESS_BUZZWORDS[0]);
+ assert.equal(buzzwordAt(-1), HARNESS_BUZZWORDS[HARNESS_BUZZWORDS.length - 1]);
+});
+
+test("parses tokens and cost out of a usage row", () => {
+ assert.deepEqual(parseUsageText("Tokens: 32048/1000000 ($0.1754 USD)"), {
+ used: 32048,
+ size: 1000000,
+ cost: "$0.1754",
+ });
+});
+
+test("parses tokens when the provider reports no cost", () => {
+ assert.deepEqual(parseUsageText("Tokens: 500/1000"), {
+ used: 500,
+ size: 1000,
+ cost: null,
+ });
+});
+
+test("usage parsing tolerates missing or junk text", () => {
+ assert.deepEqual(parseUsageText(null), {
+ used: null,
+ size: null,
+ cost: null,
+ });
+ assert.deepEqual(parseUsageText("no numbers here"), {
+ used: null,
+ size: null,
+ cost: null,
+ });
+});
+
+test("extracts a shell command from any known arg key", () => {
+ assert.equal(
+ shellCommandOf({
+ id: "t",
+ type: "tool",
+ args: { command: " ls -la " },
+ timestamp: "",
+ }),
+ "ls -la",
+ );
+ assert.equal(
+ shellCommandOf({
+ id: "t",
+ type: "tool",
+ args: { cmd: "pwd" },
+ timestamp: "",
+ }),
+ "pwd",
+ );
+ assert.equal(
+ shellCommandOf({ id: "t", type: "tool", args: {}, timestamp: "" }),
+ null,
+ );
+ assert.equal(
+ shellCommandOf({
+ id: "t",
+ type: "tool",
+ args: { command: " " },
+ timestamp: "",
+ }),
+ null,
+ );
+});
+
+test("derives running commands and tool progress", () => {
+ const status = deriveHarnessStatus([
+ {
+ id: "tool:1",
+ type: "tool",
+ status: "completed",
+ args: { command: "ls" },
+ timestamp: "1",
+ },
+ {
+ id: "tool:2",
+ type: "tool",
+ status: "executing",
+ args: { command: "cd /tmp" },
+ timestamp: "2",
+ },
+ {
+ id: "usage:c:t",
+ type: "lifecycle",
+ text: "Tokens: 3200/100000 ($0.02 USD)",
+ timestamp: "3",
+ },
+ {
+ id: "thinking:c:t",
+ type: "thought",
+ text: "Checking the call sites\nmore detail",
+ timestamp: "4",
+ },
+ ]);
+ assert.deepEqual(status.runningCommands, ["cd /tmp"]);
+ assert.equal(status.toolsTotal, 2);
+ assert.equal(status.toolsDone, 1);
+ assert.equal(status.tokensUsed, 3200);
+ assert.equal(status.cost, "$0.02");
+ assert.equal(status.summary, "Checking the call sites");
+});
+
+test("uses the newest usage row when several exist", () => {
+ const status = deriveHarnessStatus([
+ {
+ id: "usage:c:t",
+ type: "lifecycle",
+ text: "Tokens: 100/1000",
+ timestamp: "1",
+ },
+ {
+ id: "usage:c:t2",
+ type: "lifecycle",
+ text: "Tokens: 900/1000",
+ timestamp: "2",
+ },
+ ]);
+ assert.equal(status.tokensUsed, 900);
+});
+
+test("returns an empty status for an empty transcript", () => {
+ const status = deriveHarnessStatus([]);
+ assert.deepEqual(status.runningCommands, []);
+ assert.equal(status.toolsTotal, 0);
+ assert.equal(status.tokensUsed, null);
+ assert.equal(status.summary, null);
+});
+
+test("formats elapsed time like Claude Code", () => {
+ assert.equal(formatElapsed(3200), "3s");
+ assert.equal(formatElapsed(77000), "1m 17s");
+ assert.equal(formatElapsed(0), "0s");
+ assert.equal(formatElapsed(-50), "0s");
+});
+
+test("formats token counts compactly", () => {
+ assert.equal(formatTokens(980), "980");
+ assert.equal(formatTokens(3200), "3.2k");
+});
diff --git a/desktop/src/features/agents/ui/harnessStatus.ts b/desktop/src/features/agents/ui/harnessStatus.ts
new file mode 100644
index 0000000000..0a48d27484
--- /dev/null
+++ b/desktop/src/features/agents/ui/harnessStatus.ts
@@ -0,0 +1,156 @@
+/**
+ * Live status line for harness mode — the "what is it doing right now" strip.
+ *
+ * Everything here is derived from data the observer stream already carries, so
+ * nothing is estimated or faked:
+ * - tool items expose `toolName`, `args`, `status`, `startedAt`, `completedAt`
+ * - `usage_update` frames become a lifecycle row whose text holds the token
+ * counters and (when the provider reports it) real spend
+ * - `turn_started` gives the turn's clock origin
+ */
+
+export type HarnessStatusItem = {
+ id: string;
+ type: string;
+ role?: string;
+ /** Source event id for a user row, when the transcript resolved one. */
+ messageId?: string | null;
+ renderClass?: string;
+ title?: string;
+ text?: string;
+ toolName?: string;
+ status?: string;
+ args?: Record;
+ timestamp: string;
+ startedAt?: string | null;
+ completedAt?: string | null;
+};
+
+export type HarnessStatus = {
+ /** Shell commands currently executing, in start order. */
+ runningCommands: string[];
+ /** Total tool calls in this turn, and how many have finished. */
+ toolsTotal: number;
+ toolsDone: number;
+ /** Tokens used / context size, when a usage frame has arrived. */
+ tokensUsed: number | null;
+ tokensSize: number | null;
+ /** Provider-reported spend for the turn, already formatted. */
+ cost: string | null;
+ /** Most recent thought or plan text — the short "what it's up to" line. */
+ summary: string | null;
+};
+
+/**
+ * Bee-flavoured progress words. Cycled by index so the caller controls cadence
+ * (and so tests stay deterministic — no clock or randomness in here).
+ */
+export const HARNESS_BUZZWORDS = [
+ "Buzzing",
+ "Zipping",
+ "Pollinating",
+ "Foraging",
+ "Nectaring",
+ "Swarming",
+ "Waggling",
+ "Combing",
+ "Fermenting",
+ "Humming",
+ "Beelining",
+ "Hiving",
+] as const;
+
+export function buzzwordAt(tick: number): string {
+ const index =
+ ((tick % HARNESS_BUZZWORDS.length) + HARNESS_BUZZWORDS.length) %
+ HARNESS_BUZZWORDS.length;
+ return HARNESS_BUZZWORDS[index];
+}
+
+/** `Tokens: 32048/1000000 ($0.1754 USD)` → counts + formatted cost. */
+export function parseUsageText(text: string | undefined | null): {
+ used: number | null;
+ size: number | null;
+ cost: string | null;
+} {
+ if (!text) {
+ return { used: null, size: null, cost: null };
+ }
+ const counts = text.match(/(\d+)\s*\/\s*(\d+)/);
+ const cost = text.match(/\(\$([0-9.]+)\s*([A-Za-z]{3})\)/);
+ return {
+ used: counts ? Number(counts[1]) : null,
+ size: counts ? Number(counts[2]) : null,
+ cost: cost ? `$${cost[1]}` : null,
+ };
+}
+
+/** Best-effort shell command text from a tool call's arguments. */
+export function shellCommandOf(item: HarnessStatusItem): string | null {
+ const args = item.args ?? {};
+ for (const key of ["command", "cmd", "script"]) {
+ const value = args[key];
+ if (typeof value === "string" && value.trim().length > 0) {
+ return value.trim();
+ }
+ }
+ return null;
+}
+
+export function deriveHarnessStatus(
+ items: readonly HarnessStatusItem[],
+): HarnessStatus {
+ const tools = items.filter((item) => item.type === "tool");
+ const running = tools.filter((item) => item.status === "executing");
+
+ // Latest usage frame wins: `usage::` is replaced in place as
+ // the turn progresses, so the last one holds current totals.
+ const usage = [...items]
+ .reverse()
+ .find((item) => item.id.startsWith("usage:"));
+ const parsed = parseUsageText(usage?.text);
+
+ // Prefer the newest thought, then plan — the closest thing to a one-line
+ // "what it's doing" that the agent itself produced.
+ const summarySource = [...items]
+ .reverse()
+ .find((item) => item.type === "thought" || item.type === "plan");
+
+ return {
+ runningCommands: running
+ .map(shellCommandOf)
+ .filter((command): command is string => command !== null),
+ toolsTotal: tools.length,
+ toolsDone: tools.filter((item) => item.status !== "executing").length,
+ tokensUsed: parsed.used,
+ tokensSize: parsed.size,
+ cost: parsed.cost,
+ summary: firstLine(summarySource?.text) ?? null,
+ };
+}
+
+function firstLine(text: string | undefined | null): string | null {
+ if (!text) {
+ return null;
+ }
+ const line = text
+ .split("\n")
+ .find((candidate) => candidate.trim().length > 0);
+ return line ? line.trim() : null;
+}
+
+/** `77000` → `1m 17s`; `3200` → `3s`. Mirrors Claude Code's compact form. */
+export function formatElapsed(ms: number): string {
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
+}
+
+/** `3200` → `3.2k`; `980` → `980`. */
+export function formatTokens(count: number): string {
+ if (count < 1000) {
+ return String(count);
+ }
+ return `${(count / 1000).toFixed(1)}k`;
+}
diff --git a/desktop/src/features/agents/ui/mergeTranscriptItems.test.mjs b/desktop/src/features/agents/ui/mergeTranscriptItems.test.mjs
new file mode 100644
index 0000000000..8e854de77a
--- /dev/null
+++ b/desktop/src/features/agents/ui/mergeTranscriptItems.test.mjs
@@ -0,0 +1,119 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ isInjectedTranscriptId,
+ mergeTranscriptItems,
+} from "./agentSessionPanelLayout.ts";
+
+const base = [
+ { id: "a", timestamp: "2026-07-26T10:00:00.000Z" },
+ { id: "b", timestamp: "2026-07-26T12:00:00.000Z" },
+];
+
+test("returns base untouched when there is nothing to merge", () => {
+ assert.deepEqual(mergeTranscriptItems(base, []), base);
+});
+
+test("interleaves extra rows by timestamp", () => {
+ const merged = mergeTranscriptItems(base, [
+ { id: "mid", timestamp: "2026-07-26T11:00:00.000Z" },
+ ]);
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["a", "mid", "b"],
+ );
+});
+
+test("appends extra rows that are newer than everything", () => {
+ const merged = mergeTranscriptItems(base, [
+ { id: "late", timestamp: "2026-07-26T23:00:00.000Z" },
+ ]);
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["a", "b", "late"],
+ );
+});
+
+test("keeps base before extra on identical timestamps", () => {
+ const merged = mergeTranscriptItems(base, [
+ { id: "tie", timestamp: "2026-07-26T12:00:00.000Z" },
+ ]);
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["a", "b", "tie"],
+ );
+});
+
+test("drops extra rows whose id already exists in base", () => {
+ const merged = mergeTranscriptItems(base, [
+ { id: "a", timestamp: "2026-07-26T09:00:00.000Z" },
+ ]);
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["a", "b"],
+ );
+});
+
+test("sorts unparseable timestamps last instead of throwing", () => {
+ const merged = mergeTranscriptItems(base, [
+ { id: "bad", timestamp: "nonsense" },
+ ]);
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["a", "b", "bad"],
+ );
+});
+
+test("drops an extra row whose source messageId is already rendered", () => {
+ const merged = mergeTranscriptItems(
+ [
+ {
+ id: "user:c:evt1",
+ timestamp: "2026-07-26T11:00:00.000Z",
+ messageId: "evt1",
+ },
+ ],
+ [
+ {
+ id: "prompt:evt1",
+ timestamp: "2026-07-26T11:00:00.000Z",
+ messageId: "evt1",
+ },
+ ],
+ );
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["user:c:evt1"],
+ );
+});
+
+test("keeps an extra row when its messageId is not yet rendered", () => {
+ const merged = mergeTranscriptItems(
+ [
+ {
+ id: "user:c:evt1",
+ timestamp: "2026-07-26T11:00:00.000Z",
+ messageId: "evt1",
+ },
+ ],
+ [
+ {
+ id: "prompt:evt2",
+ timestamp: "2026-07-26T11:05:00.000Z",
+ messageId: "evt2",
+ },
+ ],
+ );
+ assert.deepEqual(
+ merged.map((i) => i.id),
+ ["user:c:evt1", "prompt:evt2"],
+ );
+});
+
+test("recognises both injected row prefixes", () => {
+ assert.equal(isInjectedTranscriptId("reply:abc"), true);
+ assert.equal(isInjectedTranscriptId("prompt:abc"), true);
+ assert.equal(isInjectedTranscriptId("user:chan:abc"), false);
+ assert.equal(isInjectedTranscriptId("assistant:chan:abc"), false);
+});
diff --git a/desktop/src/features/agents/ui/threadHarnessTarget.test.mjs b/desktop/src/features/agents/ui/threadHarnessTarget.test.mjs
new file mode 100644
index 0000000000..019d7af755
--- /dev/null
+++ b/desktop/src/features/agents/ui/threadHarnessTarget.test.mjs
@@ -0,0 +1,91 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { resolveThreadHarnessAgentPubkey } from "./threadHarnessTarget.ts";
+
+const FIZZ = "aa".repeat(32);
+const BUZZ = "bb".repeat(32);
+const HUMAN = "cc".repeat(32);
+
+test("returns null when the channel has no agents", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [{ pubkey: HUMAN, tags: [["p", FIZZ]] }],
+ agentPubkeys: [],
+ }),
+ null,
+ );
+});
+
+test("returns null when no known agent appears in the thread", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [{ pubkey: HUMAN, tags: [["p", HUMAN]] }],
+ agentPubkeys: [FIZZ],
+ }),
+ null,
+ );
+});
+
+test("resolves an agent mentioned by p tag", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [{ pubkey: HUMAN, tags: [["p", FIZZ]] }],
+ agentPubkeys: [FIZZ],
+ }),
+ FIZZ,
+ );
+});
+
+test("resolves an agent that authored a message", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [{ pubkey: FIZZ, tags: [] }],
+ agentPubkeys: [FIZZ],
+ }),
+ FIZZ,
+ );
+});
+
+test("ignores p tags naming pubkeys that are not known agents", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [{ pubkey: HUMAN, tags: [["p", BUZZ]] }],
+ agentPubkeys: [FIZZ],
+ }),
+ null,
+ );
+});
+
+test("matches case-insensitively but returns the canonical pubkey", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [{ pubkey: HUMAN, tags: [["p", FIZZ.toUpperCase()]] }],
+ agentPubkeys: [FIZZ],
+ }),
+ FIZZ,
+ );
+});
+
+test("prefers the earliest-appearing agent so the target stays stable", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [
+ { pubkey: HUMAN, tags: [["p", BUZZ]] },
+ { pubkey: HUMAN, tags: [["p", FIZZ]] },
+ ],
+ agentPubkeys: [FIZZ, BUZZ],
+ }),
+ BUZZ,
+ );
+});
+
+test("tolerates null messages, missing tags, and malformed tags", () => {
+ assert.equal(
+ resolveThreadHarnessAgentPubkey({
+ messages: [null, undefined, {}, { tags: null }, { tags: [["p"]] }],
+ agentPubkeys: [FIZZ],
+ }),
+ null,
+ );
+});
diff --git a/desktop/src/features/agents/ui/threadHarnessTarget.ts b/desktop/src/features/agents/ui/threadHarnessTarget.ts
new file mode 100644
index 0000000000..e9fcaefbf2
--- /dev/null
+++ b/desktop/src/features/agents/ui/threadHarnessTarget.ts
@@ -0,0 +1,61 @@
+/**
+ * Resolve which agent a thread's harness affordance should open.
+ *
+ * A thread becomes "an agent's thread" as soon as that agent is mentioned in it
+ * or has posted into it, so the affordance keys off the same `p`-tag signal the
+ * relay routes on plus message authorship. Only agents known to the channel are
+ * considered, so a stray `p` tag naming a non-member cannot produce a target.
+ *
+ * Returns the earliest-appearing candidate, keeping the button stable as a
+ * thread grows. Returns null when the thread involves no known agent — the
+ * caller hides the affordance rather than guessing.
+ */
+
+/** Minimum shape needed from a thread message. */
+export type ThreadHarnessMessage = {
+ pubkey?: string;
+ /** Raw event tags; `p` entries are treated as mentions. */
+ tags?: string[][] | null;
+};
+
+export function resolveThreadHarnessAgentPubkey({
+ messages,
+ agentPubkeys,
+}: {
+ messages: readonly (ThreadHarnessMessage | null | undefined)[];
+ agentPubkeys: readonly string[];
+}): string | null {
+ if (agentPubkeys.length === 0) {
+ return null;
+ }
+
+ const known = new Map(
+ agentPubkeys.map((pubkey) => [pubkey.toLowerCase(), pubkey]),
+ );
+
+ for (const message of messages) {
+ if (!message) {
+ continue;
+ }
+
+ const author = message.pubkey?.toLowerCase();
+ if (author) {
+ const match = known.get(author);
+ if (match) {
+ return match;
+ }
+ }
+
+ for (const tag of message.tags ?? []) {
+ if (tag[0] !== "p" || typeof tag[1] !== "string") {
+ continue;
+ }
+ const match = known.get(tag[1].toLowerCase());
+ if (match) {
+ return match;
+ }
+ }
+ }
+
+ return null;
+}
diff --git a/desktop/src/features/agents/ui/threadTurnScope.test.mjs b/desktop/src/features/agents/ui/threadTurnScope.test.mjs
new file mode 100644
index 0000000000..9d46c760f7
--- /dev/null
+++ b/desktop/src/features/agents/ui/threadTurnScope.test.mjs
@@ -0,0 +1,69 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { classifyTurns, scopeItemsToThread } from "./threadTurnScope.ts";
+
+const isInjected = (id) => id.startsWith("reply:") || id.startsWith("prompt:");
+const mine = new Set(["m1", "m2"]);
+
+const userRow = (turnId, messageId) => ({
+ id: `user:c:${messageId}`,
+ type: "message",
+ role: "user",
+ messageId,
+ turnId,
+});
+const toolRow = (turnId, n) => ({ id: `tool:${n}`, type: "tool", turnId });
+
+test("classifies own, foreign and unattributed turns", () => {
+ const items = [
+ userRow("t1", "m1"),
+ toolRow("t1", 1),
+ userRow("t2", "other"),
+ toolRow("t2", 2),
+ toolRow("t3", 3), // live turn: no user row yet
+ ];
+ const map = classifyTurns(items, mine);
+ assert.equal(map.get("t1"), "own");
+ assert.equal(map.get("t2"), "foreign");
+ assert.equal(map.get("t3"), "unattributed");
+});
+
+test("keeps the live unattributed turn — the darkness regression", () => {
+ const items = [userRow("t1", "m1"), toolRow("t1", 1), toolRow("live", 9)];
+ const kept = scopeItemsToThread(items, mine, isInjected).map((i) => i.id);
+ assert.equal(kept.includes("tool:9"), true);
+});
+
+test("drops another thread's turn", () => {
+ const items = [userRow("t1", "m1"), userRow("t2", "other"), toolRow("t2", 2)];
+ const kept = scopeItemsToThread(items, mine, isInjected).map((i) => i.id);
+ assert.deepEqual(kept, ["user:c:m1"]);
+});
+
+test("keeps injected rows regardless of turn", () => {
+ const items = [
+ { id: "prompt:m1", type: "message", role: "user", messageId: "m1" },
+ { id: "reply:agent1", type: "message", role: "assistant" },
+ userRow("t2", "other"),
+ ];
+ const kept = scopeItemsToThread(items, mine, isInjected).map((i) => i.id);
+ assert.deepEqual(kept, ["prompt:m1", "reply:agent1"]);
+});
+
+test("keeps session-level items that have no turnId", () => {
+ const items = [{ id: "session:new", type: "lifecycle", turnId: null }];
+ assert.equal(scopeItemsToThread(items, mine, isInjected).length, 1);
+});
+
+test("no scoping when the thread id set is empty or absent", () => {
+ const items = [userRow("t2", "other")];
+ assert.equal(scopeItemsToThread(items, new Set(), isInjected).length, 1);
+ assert.equal(scopeItemsToThread(items, undefined, isInjected).length, 1);
+});
+
+test("a turn with both own and foreign user rows counts as own", () => {
+ const items = [userRow("t1", "m1"), userRow("t1", "other"), toolRow("t1", 1)];
+ const kept = scopeItemsToThread(items, mine, isInjected).map((i) => i.id);
+ assert.equal(kept.includes("tool:1"), true);
+});
diff --git a/desktop/src/features/agents/ui/threadTurnScope.ts b/desktop/src/features/agents/ui/threadTurnScope.ts
new file mode 100644
index 0000000000..f6cff92d6e
--- /dev/null
+++ b/desktop/src/features/agents/ui/threadTurnScope.ts
@@ -0,0 +1,88 @@
+/**
+ * Scope a transcript to one thread's turns.
+ *
+ * Observer frames carry `turnId` but no thread reference, so a thread's turns
+ * have to be identified indirectly: a user row carries the `messageId` that
+ * triggered its turn, and we know which message ids belong to this thread.
+ *
+ * The rule is **exclude only what is provably foreign**, not "include only what
+ * is provably ours". That distinction matters a lot in practice: a whitelist
+ * drops every turn it cannot attribute — including the turn that is running
+ * right now, whose prompt row may not exist yet (and on Claude Code's
+ * cancel+merge path may never exist, because no `steer:` frame is written). The
+ * result is an agent that appears to do nothing while it works. Excluding only
+ * attributable-elsewhere turns keeps threads separate *and* keeps live activity
+ * visible.
+ *
+ * Items with no `turnId` are session-level (connection, mode, command lists)
+ * rather than thread content, so they are kept.
+ */
+
+export type ThreadScopeItem = {
+ id: string;
+ type?: string;
+ role?: string;
+ messageId?: string | null;
+ turnId?: string | null;
+};
+
+export type TurnAttribution = "own" | "foreign" | "unattributed";
+
+/**
+ * Classify each turn by the user messages seen inside it.
+ *
+ * - `own` — carries at least one of this thread's messages
+ * - `foreign` — carries user messages, none of them this thread's
+ * - `unattributed` — no user message id seen yet (e.g. the in-flight turn)
+ */
+export function classifyTurns(
+ items: readonly ThreadScopeItem[],
+ threadMessageIds: ReadonlySet,
+): Map {
+ const seenByTurn = new Map();
+
+ for (const item of items) {
+ if (!item.turnId) {
+ continue;
+ }
+ const entry = seenByTurn.get(item.turnId) ?? { own: false, any: false };
+ if (item.type === "message" && item.role === "user" && item.messageId) {
+ entry.any = true;
+ if (threadMessageIds.has(item.messageId)) {
+ entry.own = true;
+ }
+ }
+ seenByTurn.set(item.turnId, entry);
+ }
+
+ const result = new Map();
+ for (const [turnId, entry] of seenByTurn) {
+ result.set(
+ turnId,
+ entry.own ? "own" : entry.any ? "foreign" : "unattributed",
+ );
+ }
+ return result;
+}
+
+export function scopeItemsToThread(
+ items: readonly T[],
+ threadMessageIds: ReadonlySet | undefined,
+ isInjectedId: (id: string) => boolean,
+): T[] {
+ if (!threadMessageIds || threadMessageIds.size === 0) {
+ return items as T[];
+ }
+ const attribution = classifyTurns(items, threadMessageIds);
+
+ return items.filter((item) => {
+ // Injected rows are built from one thread's messages and carry no turnId.
+ if (isInjectedId(item.id)) {
+ return true;
+ }
+ if (!item.turnId) {
+ return true;
+ }
+ return attribution.get(item.turnId) !== "foreign";
+ });
+}
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
index a907f87245..4a0ee45e1d 100644
--- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
+++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
@@ -77,6 +77,11 @@ type AgentSessionThreadPanelProps = {
*/
onBack?: () => void;
onClose: () => void;
+ /**
+ * Promotes this session to the full-screen harness view. Omitted when the
+ * host cannot render it (no channel scope), which hides the affordance.
+ */
+ onEnterHarness?: () => void;
widthPx: number;
transparentChrome?: boolean;
};
@@ -91,6 +96,7 @@ export function AgentSessionThreadPanel({
profiles,
onBack,
onClose,
+ onEnterHarness,
widthPx,
transparentChrome = false,
}: AgentSessionThreadPanelProps) {
@@ -260,6 +266,19 @@ export function AgentSessionThreadPanel({
const agentHeaderActions = (
+ {onEnterHarness ? (
+
+ ) : null}
{isLive ? (
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index dbce0b6f5e..6d600c0830 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -66,6 +66,8 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
+
+import { useChannelHarness } from "@/features/channels/ui/useChannelHarness";
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentPubkeys,
@@ -143,6 +145,9 @@ export const ChannelPane = React.memo(function ChannelPane({
shouldShowThreadSkeleton,
openAgentSessionChannelId,
openAgentSessionPubkey,
+ harnessOpen = false,
+ onHarnessOpenChange,
+ onOpenHarnessForAgent,
onProfilePanelViewChange,
onProfilePanelTabChange,
profilePanelPubkey,
@@ -540,6 +545,25 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
);
+
+ const harness = useChannelHarness({
+ activeChannel,
+ activeChannelId,
+ agentSessionAgents,
+ composerDisabled: isComposerDisabled,
+ currentPubkey,
+ harnessOpen,
+ isSending,
+ onHarnessOpenChange,
+ onOpenHarnessForAgent,
+ onSend: openThreadHeadId ? onSendThreadReply : onSendMessage,
+ profiles,
+ selectedAgent,
+ threadHeadMessage: threadHeadMessage ?? null,
+ threadMessages,
+ typingPubkeys: openThreadHeadId ? threadTypingPubkeys : typingPubkeys,
+ });
+
const hasSplitAuxiliaryPane =
useSplitAuxiliaryPane &&
(channelManagementOpen ||
@@ -860,6 +884,7 @@ export const ChannelPane = React.memo(function ChannelPane({
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
+ headerActions={harness.threadAction}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
isFollowingThread={isFollowingThread}
@@ -962,6 +987,11 @@ export const ChannelPane = React.memo(function ChannelPane({
profiles={profiles}
onBack={onBackFromAgentSession}
onClose={onCloseAgentSession}
+ onEnterHarness={
+ onHarnessOpenChange && activeChannel
+ ? harness.enterHarness
+ : undefined
+ }
widthPx={threadPanelWidthPx}
/>
);
@@ -994,6 +1024,7 @@ export const ChannelPane = React.memo(function ChannelPane({
})()
) : null}
+ {harness.overlay}
);
});
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 5a27d85c4d..4eb6c10386 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -139,6 +139,14 @@ export type ChannelPaneProps = {
shouldShowThreadSkeleton: boolean;
openAgentSessionChannelId: string | null;
openAgentSessionPubkey: string | null;
+ /** True when the open agent session is promoted to the full-screen harness. */
+ harnessOpen?: boolean;
+ onHarnessOpenChange?: (open: boolean) => void;
+ /** Selects an agent and enters harness mode in a single history patch. */
+ onOpenHarnessForAgent?: (
+ agentPubkey: string,
+ channelId?: string | null,
+ ) => void;
onProfilePanelViewChange: (
view: ProfilePanelView,
options?: { replace?: boolean },
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 52b3ae7fb7..ead29b7270 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -118,6 +118,8 @@ export function ChannelScreen({
channelManagementOpen,
clearAutoSend,
clearMessageRouteTarget,
+ harnessOpen,
+ openHarnessForAgent,
openAgentSessionChannelId,
openAgentSessionPubkey,
openThreadHeadId,
@@ -125,6 +127,7 @@ export function ChannelScreen({
profilePanelTab,
profilePanelView,
setChannelManagementOpen,
+ setHarnessOpen,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
@@ -929,6 +932,9 @@ export function ChannelScreen({
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
+ harnessOpen={harnessOpen}
+ onHarnessOpenChange={setHarnessOpen}
+ onOpenHarnessForAgent={openHarnessForAgent}
openAgentSessionChannelId={openAgentSessionChannelId}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={effectiveOpenThreadHeadId}
diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts
index 0828d9fec6..97710811d9 100644
--- a/desktop/src/features/channels/ui/channelSearchKeys.ts
+++ b/desktop/src/features/channels/ui/channelSearchKeys.ts
@@ -10,6 +10,7 @@ export const CHANNEL_SEARCH_KEYS = [
"agentSessionChannel",
"autoSend",
"channelManagement",
+ "harness",
"messageId",
"profile",
"profileTab",
diff --git a/desktop/src/features/channels/ui/useChannelHarness.tsx b/desktop/src/features/channels/ui/useChannelHarness.tsx
new file mode 100644
index 0000000000..fe36c1e517
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelHarness.tsx
@@ -0,0 +1,177 @@
+import * as React from "react";
+import { TerminalSquare } from "lucide-react";
+import { toast } from "sonner";
+
+import { HarnessModeView } from "@/features/agents/ui/HarnessModeView";
+import { resolveThreadHarnessAgentPubkey } from "@/features/agents/ui/threadHarnessTarget";
+import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
+import type { TimelineMessage } from "@/features/messages/types";
+import { cancelManagedAgentTurn } from "@/shared/api/agentControl";
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import type { Channel } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+
+type HarnessSend = (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ channelId?: string | null,
+) => Promise;
+
+type UseChannelHarnessOptions = {
+ activeChannel: Channel | null;
+ composerDisabled: boolean;
+ currentPubkey?: string;
+ harnessOpen: boolean;
+ isSending: boolean;
+ onSend: HarnessSend;
+ profiles?: UserProfileLookup;
+ typingPubkeys: readonly string[];
+ activeChannelId: string | null;
+ agentSessionAgents: readonly { pubkey: string }[];
+ onHarnessOpenChange?: (open: boolean) => void;
+ onOpenHarnessForAgent?: (
+ agentPubkey: string,
+ channelId?: string | null,
+ ) => void;
+ selectedAgent:
+ | (React.ComponentProps["agent"] & {
+ canInterruptTurn: boolean;
+ })
+ | null;
+ threadHeadMessage: TimelineMessage | null;
+ threadMessages?: readonly MainTimelineEntry[];
+};
+
+/**
+ * Harness-mode wiring for the channel pane.
+ *
+ * Extracted from `ChannelPane` to keep that file under the desktop file-size
+ * guard, and because none of this is channel-pane concern beyond being where
+ * the thread and the agent list happen to meet.
+ */
+export function useChannelHarness({
+ activeChannel,
+ composerDisabled,
+ currentPubkey,
+ harnessOpen,
+ isSending,
+ onSend,
+ profiles,
+ typingPubkeys,
+ activeChannelId,
+ agentSessionAgents,
+ onHarnessOpenChange,
+ onOpenHarnessForAgent,
+ selectedAgent,
+ threadHeadMessage,
+ threadMessages,
+}: UseChannelHarnessOptions) {
+ const enterHarness = React.useCallback(() => {
+ onHarnessOpenChange?.(true);
+ }, [onHarnessOpenChange]);
+
+ const exitHarness = React.useCallback(() => {
+ onHarnessOpenChange?.(false);
+ }, [onHarnessOpenChange]);
+
+ // Every message in the open thread, head first — the shared input for the
+ // history rail and the injected transcript rows.
+ const threadTimeline = React.useMemo(() => {
+ if (!threadHeadMessage) {
+ return null;
+ }
+ return [
+ threadHeadMessage,
+ ...(threadMessages ?? []).map((entry) => entry.message),
+ ];
+ }, [threadHeadMessage, threadMessages]);
+
+ // The thread's affordance targets whichever known agent the thread involves.
+ // Scanning head-first keeps it pointed at the agent the thread started with
+ // as replies accumulate.
+ const threadAgentPubkey = React.useMemo(() => {
+ if (!onOpenHarnessForAgent || !threadTimeline) {
+ return null;
+ }
+ return resolveThreadHarnessAgentPubkey({
+ messages: threadTimeline,
+ agentPubkeys: agentSessionAgents.map((agent) => agent.pubkey),
+ });
+ }, [agentSessionAgents, onOpenHarnessForAgent, threadTimeline]);
+
+ const threadAction = React.useMemo(() => {
+ if (!threadAgentPubkey) {
+ return null;
+ }
+ return (
+
+ );
+ }, [activeChannelId, onOpenHarnessForAgent, threadAgentPubkey]);
+
+ // The agent's own replies feed the transcript; human messages feed the
+ // history rail. Splitting here avoids showing the agent's output twice.
+ const agentMessages = React.useMemo(
+ () => threadTimeline?.filter((message) => message.isAgent),
+ [threadTimeline],
+ );
+
+ const humanMessages = React.useMemo(
+ () => threadTimeline?.filter((message) => !message.isAgent),
+ [threadTimeline],
+ );
+
+ // Mirrors the agent-session panel's stop-turn affordance: the relay only
+ // forwards the control frame, so success means "signal sent", not "stopped".
+ const cancelTurn = React.useCallback(async () => {
+ if (!selectedAgent || !activeChannel) {
+ return;
+ }
+ try {
+ await cancelManagedAgentTurn(selectedAgent.pubkey, activeChannel.id);
+ toast.success(
+ `Stop signal sent to ${selectedAgent.name}. It may take a moment to respond.`,
+ );
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : `Failed to stop ${selectedAgent.name}'s current turn.`,
+ );
+ }
+ }, [activeChannel, selectedAgent]);
+
+ const overlay =
+ harnessOpen && selectedAgent && activeChannel ? (
+
+ ) : null;
+
+ return { enterHarness, overlay, threadAction };
+}
diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
index 24ccf1cce9..b9ecb16b4b 100644
--- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
+++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
@@ -26,9 +26,11 @@ export type { ChannelSearchKey } from "./channelSearchKeys";
* tab), `agentSession` (agent session panel pubkey), `agentSessionChannel`
* (optional channel scope for the agent session panel), `channelManagement`
* (presence flag for the channel-management panel — open/closed only, so it
- * carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit
- * trigger — cleared surgically after the auto-submit fires so `thread` and
- * all other panel state are preserved).
+ * carries a sentinel `"1"` rather than an id), `harness` (presence flag
+ * promoting the agent session to the full-screen harness view — also a
+ * sentinel `"1"`, and only meaningful alongside `agentSession`), `autoSend`
+ * (draft auto-submit trigger — cleared surgically after the auto-submit fires
+ * so `thread` and all other panel state are preserved).
*/
export type PanelSetterOptions = HistorySearchSetterOptions;
@@ -39,6 +41,7 @@ export type PanelValueSetter = (
) => void;
const CHANNEL_MANAGEMENT_OPEN_VALUE = "1";
+const HARNESS_OPEN_VALUE = "1";
export function useChannelPanelHistoryState() {
const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS);
@@ -71,10 +74,42 @@ export function useChannelPanelHistoryState() {
[applyPatch],
);
+ // Closing the agent session also leaves harness mode — a `harness` flag with
+ // no `agentSession` has nothing to render, so it must never outlive it.
const setOpenAgentSessionPubkey = React.useCallback(
(value, options) =>
applyPatch(
- { agentSession: value, agentSessionChannel: value ? undefined : null },
+ {
+ agentSession: value,
+ agentSessionChannel: value ? undefined : null,
+ harness: value ? undefined : null,
+ },
+ options,
+ ),
+ [applyPatch],
+ );
+
+ const setHarnessOpen = React.useCallback(
+ (open: boolean, options?: PanelSetterOptions) =>
+ applyPatch({ harness: open ? HARNESS_OPEN_VALUE : null }, options),
+ [applyPatch],
+ );
+
+ // Selecting the agent and raising the harness flag must land in ONE patch:
+ // two sequential applyPatch calls in the same tick both derive from the
+ // pre-patch search state, so the second would drop the first's key.
+ const openHarnessForAgent = React.useCallback(
+ (
+ agentPubkey: string,
+ channelId?: string | null,
+ options?: PanelSetterOptions,
+ ) =>
+ applyPatch(
+ {
+ agentSession: agentPubkey,
+ agentSessionChannel: channelId ?? undefined,
+ harness: HARNESS_OPEN_VALUE,
+ },
options,
),
[applyPatch],
@@ -114,6 +149,8 @@ export function useChannelPanelHistoryState() {
channelManagementOpen: values.channelManagement != null,
clearAutoSend,
clearMessageRouteTarget,
+ harnessOpen: values.harness != null,
+ openHarnessForAgent,
openAgentSessionChannelId: values.agentSessionChannel,
openAgentSessionPubkey: values.agentSession,
openThreadHeadId: values.thread,
@@ -121,6 +158,7 @@ export function useChannelPanelHistoryState() {
profilePanelTab: profilePanelTabFromSearch(values.profileTab),
profilePanelView: profilePanelViewFromSearch(values.profileView),
setChannelManagementOpen,
+ setHarnessOpen,
setOpenAgentSessionChannelId,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index 0cd4d67238..6447cba480 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -27,6 +27,7 @@ import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
import {
AuxiliaryPanelHeader,
+ AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
AuxiliaryPanelTitle,
} from "@/shared/layout/AuxiliaryPanel";
@@ -107,6 +108,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
threadTypingPubkeys: string[];
threadHeadVideoReviewContext?: VideoReviewContext;
toolbarExtraActions?: React.ReactNode;
+ /** Trailing controls rendered in the thread panel header. */
+ headerActions?: React.ReactNode;
widthPx: number;
isFollowingThread?: boolean;
isMessageUnreadById?: (messageId: string) => boolean;
@@ -189,6 +192,7 @@ export function MessageThreadPanel({
huddleMemberPubkeysPending = false,
layout = "standalone",
editTarget,
+ headerActions,
headerLeading,
isSending,
isFocusMode,
@@ -895,6 +899,11 @@ export function MessageThreadPanel({
>
Thread
+ {headerActions ? (
+
+ {headerActions}
+
+ ) : null}
>
);