diff --git a/apps/desktop/src/components/ChatTranscript.tsx b/apps/desktop/src/components/ChatTranscript.tsx index c083513458..c66e73bbf5 100644 --- a/apps/desktop/src/components/ChatTranscript.tsx +++ b/apps/desktop/src/components/ChatTranscript.tsx @@ -1,2 +1 @@ -export { ChatTranscript } from "../features/chat/transcript/ChatTranscript"; -export { SubagentDetail } from "../features/chat/transcript/SubagentDetail"; +export { ChatTranscript } from "../features/chat/transcript/ChatTranscript"; diff --git a/apps/desktop/src/components/workpanel/SubagentPanel.tsx b/apps/desktop/src/components/workpanel/SubagentPanel.tsx deleted file mode 100644 index bf259702f0..0000000000 --- a/apps/desktop/src/components/workpanel/SubagentPanel.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useLayoutEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import type { UiMessage } from "@pi-desktop/shared"; -import { - buildTranscriptEntries, - type AssistantActivityItem, -} from "../../lib/assistant-turns"; -import { - collectDelegationFailures, - collectDelegationStatuses, - collectDelegationTimings, - isDelegationActivityItem, - type DelegationActivityItem, - type DelegationFailure, - type SubagentOutcome, - type SubagentTiming, -} from "../../lib/subagent-topology"; -import { delegationIdForMessage, type SubagentPanelSelection } from "../../lib/subagent-panel"; -import { useAppStore } from "../../stores/app-store"; -import { useFollowScroll } from "../../hooks/use-follow-scroll"; -import { useTranscriptView } from "../../hooks/use-transcript-view"; -import { useTranscriptSearchFocus } from "../../hooks/use-transcript-search-focus"; -import { IconArrowDown } from "../icons"; -import { DisclosureAnchorContext } from "../../lib/disclosure-anchor-context"; -import { TranscriptDisclosureProvider } from "../../features/chat/transcript/disclosure"; -import { TooltipButton } from "../ui"; -import { SubagentDetail } from "../ChatTranscript"; - -type SelectedSubagent = { - item: DelegationActivityItem; - turnActivityItems: AssistantActivityItem[]; -}; - -function findSelectedSubagent( - messages: UiMessage[], - delegationId: string, -): SelectedSubagent | null { - const { entries } = buildTranscriptEntries(messages); - for (const entry of entries) { - if (entry.kind !== "assistant-turn") continue; - const turnActivityItems = entry.parts.flatMap((part) => - part.kind === "activity" ? part.items : [], - ); - const item = turnActivityItems.find( - (candidate): candidate is DelegationActivityItem => - isDelegationActivityItem(candidate) && - delegationIdForMessage(candidate.message) === delegationId, - ); - if (item) return { item, turnActivityItems }; - } - return null; -} - -export function SubagentPanel({ selection }: { selection: SubagentPanelSelection }) { - return ( - - - - ); -} - -function SubagentPanelSurface({ selection }: { selection: SubagentPanelSelection }) { - const { t } = useTranslation(); - const activeSessionId = useAppStore((state) => state.activeSessionId); - const transcript = useTranscriptView(selection.sessionId); - const { messages } = transcript; - const searchTarget = selection.searchRequestId === transcript.focus?.requestId ? transcript.focus : null; - const isRunning = useAppStore( - (state) => state.runningSessions[selection.sessionId] ?? false, - ); - const selected = useMemo( - () => findSelectedSubagent(messages, selection.delegationId), - [messages, selection.delegationId], - ); - const delegationStatuses = useMemo>( - () => - selected - ? collectDelegationStatuses(selected.turnActivityItems, { - turnLive: isRunning, - }) - : new Map(), - [isRunning, selected], - ); - const delegationFailures = useMemo>( - () => - selected - ? collectDelegationFailures(selected.turnActivityItems) - : new Map(), - [selected], - ); - const delegationTimings = useMemo>( - () => - selected - ? collectDelegationTimings(selected.turnActivityItems) - : new Map(), - [selected], - ); - const { - scrollRef, - contentRef, - showJump, - handleScroll, - jumpToLatest, - scheduleFollowScroll, - releaseFollow, - disclosureAnchorNotifier, - } = useFollowScroll(); - - useLayoutEffect(() => { - if (!searchTarget) jumpToLatest(); - }, [jumpToLatest, selection.delegationId, searchTarget]); - - useLayoutEffect(() => { - scheduleFollowScroll(); - }, [messages, scheduleFollowScroll]); - - useTranscriptSearchFocus({ - target: searchTarget, - source: messages.find((message) => message.id === searchTarget?.messageId)?.content ?? "", - scrollRef, - contentRef, - contentVersion: messages, - onNavigate: releaseFollow, - }); - - return ( - - - - ); -} diff --git a/apps/desktop/src/components/workpanel/SubagentTranscriptTab.tsx b/apps/desktop/src/components/workpanel/SubagentTranscriptTab.tsx new file mode 100644 index 0000000000..8df9a8e06c --- /dev/null +++ b/apps/desktop/src/components/workpanel/SubagentTranscriptTab.tsx @@ -0,0 +1,189 @@ +import { Fragment, useLayoutEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppStore } from "../../stores/app-store"; +import { useFollowScroll } from "../../hooks/use-follow-scroll"; +import { useTranscriptView } from "../../hooks/use-transcript-view"; +import { useTranscriptSearchFocus } from "../../hooks/use-transcript-search-focus"; +import { IconArrowDown } from "../icons"; +import { Textarea, TooltipButton } from "../ui"; +import { DisclosureAnchorContext } from "../../lib/disclosure-anchor-context"; +import { TranscriptDisclosureProvider } from "../../features/chat/transcript/disclosure"; +import { buildSubagentTranscript } from "../../lib/subagent-transcript"; +import { + AssistantErrorMessage, + ThinkingRow, +} from "../../features/chat/transcript/shared"; +import { + ReviewChangeCard, +} from "../ReviewChangeCard"; +import { ToolRow } from "../../features/chat/transcript/ToolRow"; +import { Markdown } from "../Markdown"; + +/** + * The work-panel tab showing one delegation as a conversation (issue #917). + * + * Every `Task` call of the delegation chain opens a turn: the prompt the + * parent sent renders as a user row, and the rows the delegate produced under + * that call render with the main transcript's own message-list language — + * tool rows, thinking rows, and answer bubbles alike. Follow-up `Task` calls + * on the same delegation append further turns, so a resumed delegate reads as + * one continuing user/assistant exchange. + * + * The tab is display-only: the composer at the foot is a disabled textarea + * whose placeholder says the delegate is driven by the main agent. There is + * deliberately no send path. + */ +export function SubagentTranscriptTab({ delegationId }: { delegationId: string }) { + return ( + + + + ); +} + +function SubagentTranscriptSurface({ delegationId }: { delegationId: string }) { + const { t } = useTranslation(); + const activeSessionId = useAppStore((state) => state.activeSessionId); + // A tab only renders inside a presented panel, which always has a session; + // the empty key keeps the hook's contract while nothing is active. + const transcript = useTranscriptView(activeSessionId ?? ""); + const { messages } = transcript; + const isRunning = useAppStore( + (state) => (activeSessionId ? state.runningSessions[activeSessionId] ?? false : false), + ); + const transcriptResult = useMemo( + () => buildSubagentTranscript(messages, delegationId), + [messages, delegationId], + ); + const { + scrollRef, + contentRef, + showJump, + handleScroll, + jumpToLatest, + scheduleFollowScroll, + releaseFollow, + disclosureAnchorNotifier, + } = useFollowScroll(); + + // Several delegation tabs can be open at once, so an explicit search hit + // is claimed only by the tab whose own conversation contains the message; + // the other tabs stay where the reader left them. + const searchTarget = useMemo(() => { + const focus = transcript.focus; + if (!focus) return null; + const claimed = transcriptResult?.turns.some((turn) => + turn.rows.some((row) => row.message.id === focus.messageId), + ); + return claimed ? focus : null; + }, [transcript.focus, transcriptResult]); + + useTranscriptSearchFocus({ + target: searchTarget, + source: + messages.find((message) => message.id === searchTarget?.messageId)?.content ?? "", + scrollRef, + contentRef, + contentVersion: messages, + onNavigate: releaseFollow, + }); + + useLayoutEffect(() => { + if (!searchTarget) jumpToLatest(); + }, [jumpToLatest, searchTarget]); + + useLayoutEffect(() => { + scheduleFollowScroll(); + }, [messages, scheduleFollowScroll]); + + return ( + +
+
+
+ {transcriptResult ? ( + transcriptResult.turns.map((turn, turnIndex) => ( + +
+
+
+
+ {turn.task || t("panel.subagentTaskEmpty")} +
+
+
+
+ {turn.rows.map((row) => + row.kind === "tool" ? ( + + + + + ) : row.kind === "thinking" ? ( + + ) : ( +
+
+
+ {row.message.content ? ( +
+ +
+ ) : null} + {row.message.error ? ( + + ) : null} +
+
+
+ ), + )} +
+ )) + ) : ( +
+ {t("panel.subagentEmpty")} +
+ )} +
+
+ {showJump ? ( + + + + ) : null} +