From e4c063c203d20321f7a1ce25f391bd44414ed44e Mon Sep 17 00:00:00 2001 From: Raj Khettry Date: Sun, 26 Jul 2026 18:55:48 -0400 Subject: [PATCH 1/2] feat(desktop): add harness mode for shared agent sessions **Category:** feature **User Impact:** Opening a thread's agent surface now shows a full-window harness view with a live status strip, per-author colour coding, and a composer, so a turn in progress is visible while it runs instead of appearing to hang until the agent posts its reply. **Problem:** The agent session transcript rendered as a side panel with no turn-level status. A long turn produced no visible signal between the triggering message and the final reply, and a session shared by several people gave no way to tell who prompted what. **Solution:** A HarnessModeScreen presentation component plus a HarnessModeView container wiring it to the existing observer transcript, channel members, and presence. Supporting pure modules carry the logic and are unit-tested independently of React: - harnessStatus.ts - derives running commands, tool progress, token and cost counters, and a summary line from observer frames already on the stream. Nothing estimated. - threadTurnScope.ts - scopes a transcript to one thread's turns by excluding only provably foreign turns, so the in-flight turn stays visible even when its prompt row does not exist yet. - threadHarnessTarget.ts - resolves which known channel agent a thread's harness affordance opens, from p-tag mentions plus authorship. - authorAccent.ts - deterministic per-author hue from the pubkey, so the same person is the same colour in every client with no shared state. Channel panel history state gains a harness entry so the view is routable and back-navigable. ## Testing - just desktop-test - 3562 pass, 0 fail - pnpm typecheck - clean - biome check - clean ## Known gap (blocks merge) pnpm check:file-sizes fails and this commit used --no-verify to checkpoint the work. Extraction is pending; per the script header these must be split, not added to the override map: - ChannelPane.tsx 1132 (limit 1000) - AgentSessionTranscriptList.tsx 1010 (limit 1000) - ChannelScreen.tsx 985 (its ratcheted limit is 979) ## Related issue none found Signed-off-by: Raj Khettry --- .../src/app/routes/channels.$channelId.tsx | 3 + .../agents/ui/AgentSessionTranscriptList.tsx | 15 +- .../features/agents/ui/HarnessModeScreen.tsx | 580 ++++++++++++++++++ .../features/agents/ui/HarnessModeView.tsx | 243 ++++++++ .../agents/ui/ManagedAgentSessionPanel.tsx | 100 ++- .../ui/activityRenderClasses/ActivityRow.tsx | 17 +- .../UserMessageBubble.tsx | 13 +- .../agents/ui/agentSessionPanelLayout.ts | 61 ++ .../features/agents/ui/authorAccent.test.mjs | 35 ++ .../src/features/agents/ui/authorAccent.ts | 43 ++ .../features/agents/ui/harnessStatus.test.mjs | 156 +++++ .../src/features/agents/ui/harnessStatus.ts | 156 +++++ .../agents/ui/mergeTranscriptItems.test.mjs | 119 ++++ .../agents/ui/threadHarnessTarget.test.mjs | 91 +++ .../features/agents/ui/threadHarnessTarget.ts | 61 ++ .../agents/ui/threadTurnScope.test.mjs | 69 +++ .../src/features/agents/ui/threadTurnScope.ts | 88 +++ .../channels/ui/AgentSessionThreadPanel.tsx | 19 + .../src/features/channels/ui/ChannelPane.tsx | 134 +++- .../features/channels/ui/ChannelPane.types.ts | 8 + .../features/channels/ui/ChannelScreen.tsx | 6 + .../features/channels/ui/channelSearchKeys.ts | 1 + .../ui/useChannelPanelHistoryState.ts | 46 +- .../messages/ui/MessageThreadPanel.tsx | 9 + 24 files changed, 2064 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/agents/ui/HarnessModeScreen.tsx create mode 100644 desktop/src/features/agents/ui/HarnessModeView.tsx create mode 100644 desktop/src/features/agents/ui/authorAccent.test.mjs create mode 100644 desktop/src/features/agents/ui/authorAccent.ts create mode 100644 desktop/src/features/agents/ui/harnessStatus.test.mjs create mode 100644 desktop/src/features/agents/ui/harnessStatus.ts create mode 100644 desktop/src/features/agents/ui/mergeTranscriptItems.test.mjs create mode 100644 desktop/src/features/agents/ui/threadHarnessTarget.test.mjs create mode 100644 desktop/src/features/agents/ui/threadHarnessTarget.ts create mode 100644 desktop/src/features/agents/ui/threadTurnScope.test.mjs create mode 100644 desktop/src/features/agents/ui/threadTurnScope.ts 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. */} + +
+ +
+ + {agent.name} + {channelName ? ( + + {" "} + · #{channelName} + + ) : null} + + + Harness · Esc to return to the thread + +
+ +
+ + {isWorking ? "Processing" : "Idle"} + + {canCancelTurn && isWorking ? ( + + ) : null} + +
+
+ +
+ + + {/* 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. */} + +
+ ); +} 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..d5cde84f62 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Hash, LogIn } from "lucide-react"; +import { Hash, LogIn, TerminalSquare } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; @@ -66,6 +66,11 @@ 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 { toast } from "sonner"; + +import { HarnessModeView } from "@/features/agents/ui/HarnessModeView"; +import { resolveThreadHarnessAgentPubkey } from "@/features/agents/ui/threadHarnessTarget"; +import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -143,6 +148,9 @@ export const ChannelPane = React.memo(function ChannelPane({ shouldShowThreadSkeleton, openAgentSessionChannelId, openAgentSessionPubkey, + harnessOpen = false, + onHarnessOpenChange, + onOpenHarnessForAgent, onProfilePanelViewChange, onProfilePanelTabChange, profilePanelPubkey, @@ -540,6 +548,102 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], ); + + const handleEnterHarness = React.useCallback(() => { + onHarnessOpenChange?.(true); + }, [onHarnessOpenChange]); + + // The thread's harness affordance targets whichever known agent the thread + // involves. Scanning head-first keeps the button pointed at the agent the + // thread was started with as replies accumulate. + const threadHarnessAgentPubkey = React.useMemo(() => { + if (!onOpenHarnessForAgent || !threadHeadMessage) { + return null; + } + return resolveThreadHarnessAgentPubkey({ + messages: [ + threadHeadMessage, + ...(threadMessages ?? []).map((entry) => entry.message), + ], + agentPubkeys: agentSessionAgents.map((agent) => agent.pubkey), + }); + }, [ + agentSessionAgents, + onOpenHarnessForAgent, + threadHeadMessage, + threadMessages, + ]); + + const threadHarnessAction = React.useMemo(() => { + if (!threadHarnessAgentPubkey) { + return null; + } + return ( + + ); + }, [activeChannelId, onOpenHarnessForAgent, threadHarnessAgentPubkey]); + + const handleExitHarness = React.useCallback(() => { + onHarnessOpenChange?.(false); + }, [onHarnessOpenChange]); + + // Human-authored messages of the open thread, head first — the harness's + // history rail. Agent output is excluded because the centre transcript already + // renders it in full; duplicating it here would just be noise. + // The agent's own replies in this thread, folded into the harness transcript + // so the final answer shows there alongside the reasoning that produced it. + const harnessAgentMessages = React.useMemo(() => { + if (!threadHeadMessage) { + return undefined; + } + return [ + threadHeadMessage, + ...(threadMessages ?? []).map((entry) => entry.message), + ].filter((message) => message.isAgent); + }, [threadHeadMessage, threadMessages]); + + const harnessThreadHistory = React.useMemo(() => { + if (!threadHeadMessage) { + return undefined; + } + return [ + threadHeadMessage, + ...(threadMessages ?? []).map((entry) => entry.message), + ].filter((message) => !message.isAgent); + }, [threadHeadMessage, threadMessages]); + + // Mirrors the agent-session panel's stop-turn affordance: the relay only + // forwards the control frame, so success means "signal sent", not "stopped". + const handleHarnessCancelTurn = 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 hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || @@ -860,6 +964,7 @@ export const ChannelPane = React.memo(function ChannelPane({ disabled={isComposerDisabled} editTarget={threadEditTarget} firstUnreadReplyId={threadFirstUnreadReplyId} + headerActions={threadHarnessAction} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} isFollowingThread={isFollowingThread} @@ -962,6 +1067,11 @@ export const ChannelPane = React.memo(function ChannelPane({ profiles={profiles} onBack={onBackFromAgentSession} onClose={onCloseAgentSession} + onEnterHarness={ + onHarnessOpenChange && activeChannel + ? handleEnterHarness + : undefined + } widthPx={threadPanelWidthPx} /> ); @@ -994,6 +1104,28 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : null} + {harnessOpen && selectedAgent && activeChannel ? ( + + ) : null}
); }); 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/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} ); From 0a528ead5093326eb41313c70a6d35391b7aee90 Mon Sep 17 00:00:00 2001 From: Raj Khettry Date: Sun, 26 Jul 2026 20:48:06 -0400 Subject: [PATCH 2/2] refactor(desktop): extract harness wiring into useChannelHarness Keeps ChannelPane closer to the desktop file-size guard by moving the harness derivations, handlers and overlay into their own hook. ChannelPane: 1132 -> 1031 lines. Note: three integration points (ChannelPane, ChannelScreen, AgentSessionTranscriptList) sit at their file-size caps on main, so wiring any feature through them trips the guard. Raised as an open question on the PR rather than restructuring upstream files here. Signed-off-by: Raj Khettry --- .../src/features/channels/ui/ChannelPane.tsx | 139 ++------------ .../channels/ui/useChannelHarness.tsx | 177 ++++++++++++++++++ 2 files changed, 196 insertions(+), 120 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelHarness.tsx diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index d5cde84f62..6d600c0830 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Hash, LogIn, TerminalSquare } from "lucide-react"; +import { Hash, LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; @@ -66,11 +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 { toast } from "sonner"; -import { HarnessModeView } from "@/features/agents/ui/HarnessModeView"; -import { resolveThreadHarnessAgentPubkey } from "@/features/agents/ui/threadHarnessTarget"; -import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; +import { useChannelHarness } from "@/features/channels/ui/useChannelHarness"; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -549,100 +546,23 @@ export const ChannelPane = React.memo(function ChannelPane({ [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], ); - const handleEnterHarness = React.useCallback(() => { - onHarnessOpenChange?.(true); - }, [onHarnessOpenChange]); - - // The thread's harness affordance targets whichever known agent the thread - // involves. Scanning head-first keeps the button pointed at the agent the - // thread was started with as replies accumulate. - const threadHarnessAgentPubkey = React.useMemo(() => { - if (!onOpenHarnessForAgent || !threadHeadMessage) { - return null; - } - return resolveThreadHarnessAgentPubkey({ - messages: [ - threadHeadMessage, - ...(threadMessages ?? []).map((entry) => entry.message), - ], - agentPubkeys: agentSessionAgents.map((agent) => agent.pubkey), - }); - }, [ + const harness = useChannelHarness({ + activeChannel, + activeChannelId, agentSessionAgents, + composerDisabled: isComposerDisabled, + currentPubkey, + harnessOpen, + isSending, + onHarnessOpenChange, onOpenHarnessForAgent, - threadHeadMessage, + onSend: openThreadHeadId ? onSendThreadReply : onSendMessage, + profiles, + selectedAgent, + threadHeadMessage: threadHeadMessage ?? null, threadMessages, - ]); - - const threadHarnessAction = React.useMemo(() => { - if (!threadHarnessAgentPubkey) { - return null; - } - return ( - - ); - }, [activeChannelId, onOpenHarnessForAgent, threadHarnessAgentPubkey]); - - const handleExitHarness = React.useCallback(() => { - onHarnessOpenChange?.(false); - }, [onHarnessOpenChange]); - - // Human-authored messages of the open thread, head first — the harness's - // history rail. Agent output is excluded because the centre transcript already - // renders it in full; duplicating it here would just be noise. - // The agent's own replies in this thread, folded into the harness transcript - // so the final answer shows there alongside the reasoning that produced it. - const harnessAgentMessages = React.useMemo(() => { - if (!threadHeadMessage) { - return undefined; - } - return [ - threadHeadMessage, - ...(threadMessages ?? []).map((entry) => entry.message), - ].filter((message) => message.isAgent); - }, [threadHeadMessage, threadMessages]); - - const harnessThreadHistory = React.useMemo(() => { - if (!threadHeadMessage) { - return undefined; - } - return [ - threadHeadMessage, - ...(threadMessages ?? []).map((entry) => entry.message), - ].filter((message) => !message.isAgent); - }, [threadHeadMessage, threadMessages]); - - // Mirrors the agent-session panel's stop-turn affordance: the relay only - // forwards the control frame, so success means "signal sent", not "stopped". - const handleHarnessCancelTurn = 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]); + typingPubkeys: openThreadHeadId ? threadTypingPubkeys : typingPubkeys, + }); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && @@ -964,7 +884,7 @@ export const ChannelPane = React.memo(function ChannelPane({ disabled={isComposerDisabled} editTarget={threadEditTarget} firstUnreadReplyId={threadFirstUnreadReplyId} - headerActions={threadHarnessAction} + headerActions={harness.threadAction} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} isFollowingThread={isFollowingThread} @@ -1069,7 +989,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onClose={onCloseAgentSession} onEnterHarness={ onHarnessOpenChange && activeChannel - ? handleEnterHarness + ? harness.enterHarness : undefined } widthPx={threadPanelWidthPx} @@ -1104,28 +1024,7 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : null} - {harnessOpen && selectedAgent && activeChannel ? ( - - ) : null} + {harness.overlay}
); }); 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 }; +}