From 47c18a921371d75356f8a0f99e9c21cf9f2b36d4 Mon Sep 17 00:00:00 2001 From: Kevin Aspesi Date: Sat, 1 Aug 2026 10:41:30 -0400 Subject: [PATCH] Fork an agent session mid-run from the in-flight turn footer (#2638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): fork an agent session mid-run from the tab menu Forking already existed, but only on the footer of a *completed* assistant turn. While an agent is running, the in-flight turn renders RunningTurnFooter with no fork menu, so exploring another path meant scrolling back to hunt for an older turn's footer — or waiting for the run to finish. Add two entries to the agent tab context menu ("Fork chat in a new tab", "Fork chat in a new workspace"), reachable regardless of run state. They omit the fork boundary, and selectForkContextRows projects the whole timeline when no boundary is given, so a mid-run fork carries everything up to now — including the partially streamed in-flight turn. Fork logic moves out of agent-stream/view.tsx into a shared useForkAgent hook so the turn footer and the tab menu share one code path; the per-turn behavior is unchanged (it still pins a boundary to its turn). No server, protocol, or capability changes — this reuses the existing agent.fork_context RPC and agentForkContext host feature. The daemon handler is already a pure in-memory timeline read, so it was safe mid-turn before this change. * fix(app): resolve missing agent records before failing a fork handleForkAgent read the agent record from the session store and gave up if it was absent from both agents and agentDetails. A persisted agent tab can outlive its record — restored on startup before hydration — so both fork entries failed with a generic toast even though the agent exists on the daemon and is perfectly forkable. Fall back to client.fetchAgent and store the result before failing, mirroring the resolution path agent-panel.tsx already uses. Only report forkFailed when the agent genuinely cannot be resolved; a disconnected host now says so instead. The resolution logic lands in resolveForkAgentSource with injected dependencies, since workspace-screen.tsx has no handler-level test harness and a sibling testable module is the pattern in this directory. storeFetchedAgentDetail moves out of panels/agent-panel.tsx into utils/agent-detail-store.ts so the workspace screen doesn't import a panel module — and into its own file rather than agent-directory-sync.ts, which would close an import cycle through legacy-daemon-workspaces. * feat(app): fork from the in-flight turn instead of the tab menu Move the mid-run fork affordance to where the fork button already lives: the assistant turn footer, beside the progress loader. RunningTurnFooter previously showed only a spinner and elapsed timer, so the fork button vanished for the whole duration of a run. It now renders AssistantForkMenu alongside them, reachable the moment a run starts. The in-flight fork passes no boundary, so selectForkContextRows projects the whole timeline and the fork captures the response being streamed. A fork button sitting next to a live response that silently dropped that response would be worse than no button. Completed turns still pin their own boundary, enforced by a separate handler type that keeps boundary required. layout.ts is untouched — the affordance is added to the running footer component rather than by lifting the footer suppression, so no copy or duration affordance leaks onto an incomplete turn. Reverts the workspace tab context menu approach, along with the agent resolution helper and store-writer extraction that existed only to serve it. The footer path already holds the agent record. * test(app): cover the in-flight turn fork with a Playwright spec Extends the existing assistant fork menu spec rather than adding a sibling file, so the completed-turn and in-flight cases sit side by side. The new test pins the behavior that defines the feature: the in-flight fork omits the boundary fields entirely, while the completed-turn fork sends one. Both assertions read the actual agent.fork_context frames off the WebSocket, using the read-only page.on("websocket") idiom from helpers/timeline-delivery.ts so the observer cannot perturb the flow it measures. Note the request omits the fields rather than nulling them — daemon-client spreads them conditionally — so absence is what's asserted there, and null only on the response. Also asserts the fork trigger is scoped inside turn-working-indicator, that no copy or duration affordance leaks onto the incomplete turn, that the attachment carries the streamed text, and that the source agent keeps streaming afterwards. * test(app): cover the in-flight fork call site, gate, and teardown Three behaviors this PR introduced had no coverage below the Playwright spec. A copy-paste of resolveAssistantTurnForkBoundary into handleForkInFlightTurn would have left every unit test green. view-fork-in-flight.test.tsx mounts the real AgentStreamView with the render strategy stubbed to the live-auxiliary slot, so handleForkInFlightTurn, useStableEvent and the readOnly expression all stay real. It asserts the fork request has no boundary KEY — matching the wire contract, where the field is absent rather than null — for both targets, and that the read-only gate withholds the trigger, with positive controls either side so an empty tree cannot pass for a working gate. turn-footer.test.tsx covers the race this PR created: the fork menu now mounts only while a run is in flight, so a fork can be pending when the subtree is torn down. The behavior is benign — React 18 makes the orphaned setState a no-op — so the test asserts the observable outcome and leaves a tripwire for unhandled rejections, since AssistantForkMenu's finally has no catch. Each test was verified by mutation: adding boundary: undefined, dropping the readOnly gate, and keeping the running subtree alive each fail the matching test. Scoped to what this PR changed. The relocated hook's error paths, draft setup construction, and attachment metadata predate it and stay untested here. * test(app): replace mocked fork coverage with Playwright --------- Co-authored-by: Mohamed Boudra --- docs/glossary.md | 1 + packages/app/e2e/assistant-fork-menu.spec.ts | 93 ++++---- packages/app/e2e/helpers/assistant-fork.ts | 114 ++++++++++ packages/app/src/agent-stream/turn-footer.tsx | 37 +++- packages/app/src/agent-stream/view.tsx | 155 +++----------- packages/app/src/hooks/use-fork-agent.ts | 198 ++++++++++++++++++ 6 files changed, 423 insertions(+), 175 deletions(-) create mode 100644 packages/app/e2e/helpers/assistant-fork.ts create mode 100644 packages/app/src/hooks/use-fork-agent.ts diff --git a/docs/glossary.md b/docs/glossary.md index c21d16cf0..4bee02256 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -40,6 +40,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Composer track** — A contextual lane above the composer input. Specific tracks use the ` track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`). - **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term. - **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar". +- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying). - **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string). - **Product discussion** — Where non-bug product talk goes: [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) and `#product` in Discord. Covers workflows, features, and product direction. The container term; the ask inside it is always a **workflow** — what you're doing, how you do it today, where Paseo gets in the way. Issues are bugs only. Forbidden: "Feature request" as a Paseo surface, label, or invitation; the phrase is only used when answering someone who arrived with it. Don't invent "feature discussion", "idea", or "workflow thread" as synonyms. See [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts index 965c87b94..9d26c00a2 100644 --- a/packages/app/e2e/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -1,6 +1,14 @@ -import { expect, test as base, type Page } from "./fixtures"; -import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor"; +import { expect, test as base } from "./fixtures"; import { awaitAssistantMessage } from "./helpers/agent-stream"; +import { + expectChatHistoryAttachment, + expectInFlightForkAvailable, + expectLiveAssistantText, + forkInFlightTurnToNewTab, + forkMostRecentAssistantTurnToNewTab, + forkMostRecentAssistantTurnToNewWorkspace, + observeForkAttachment, +} from "./helpers/assistant-fork"; import { expectComposerVisible, submitMessage } from "./helpers/composer"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { @@ -27,30 +35,6 @@ const test = base.extend<{ }, }); -async function openAssistantForkMenu(page: Page): Promise { - await expect - .poll( - async () => { - await scrollAgentChatToBottom(page); - return page.getByTestId("assistant-fork-menu-trigger").count(); - }, - { timeout: 30_000 }, - ) - .toBeGreaterThan(0); - const trigger = page.getByTestId("assistant-fork-menu-trigger").last(); - await expect(trigger).toBeVisible({ timeout: 30_000 }); - await trigger.click(); - await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({ - timeout: 10_000, - }); -} - -async function expectChatHistoryPill(page: Page): Promise { - const pill = page.getByTestId("composer-chat-history-attachment-pill").first(); - await expect(pill).toBeVisible({ timeout: 30_000 }); - await expect(pill).toContainText("Chat history"); -} - test.describe("Assistant fork menu", () => { test.describe.configure({ timeout: 180_000 }); @@ -71,9 +55,38 @@ test.describe("Assistant fork menu", () => { timeout: 30_000, }); - await openAssistantForkMenu(page); - await page.getByTestId("assistant-fork-menu-new-tab").click(); - await expectChatHistoryPill(page); + await forkMostRecentAssistantTurnToNewTab(page); + await expectChatHistoryAttachment(page); + }); + + test("forks a streaming assistant turn without interrupting it", async ({ + page, + seedForkWorkspace, + }) => { + const visibleBeforeFork = "where the auto-scroll logic actually lives"; + const visibleAfterFork = "the first useful step is to read the relevant files"; + const sourceAgentTitle = "Assistant fork in flight"; + const forkAttachment = observeForkAttachment(page); + + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-in-flight-", + title: sourceAgentTitle, + model: "thirty-minute-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await submitMessage(page, "Walk me through the scroll anchor behavior."); + + await expectInFlightForkAvailable(page); + await expectLiveAssistantText(page, visibleBeforeFork); + + await forkInFlightTurnToNewTab(page); + await expectChatHistoryAttachment(page); + expect(await forkAttachment.waitForText()).toContain(visibleBeforeFork); + + await page.getByRole("button", { name: sourceAgentTitle }).click(); + await expectLiveAssistantText(page, visibleAfterFork); }); test("focuses a forked assistant turn in a new workspace draft tab", async ({ @@ -99,8 +112,7 @@ test.describe("Assistant fork menu", () => { const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`); await expect(agentTab).toHaveAttribute("aria-selected", "true"); - await openAssistantForkMenu(page); - await page.getByTestId("assistant-fork-menu-new-tab").click(); + await forkMostRecentAssistantTurnToNewTab(page); const selectedTab = page .getByTestId("workspace-tabs-row") @@ -110,7 +122,7 @@ test.describe("Assistant fork menu", () => { timeout: 30_000, }); await expect(agentTab).toHaveAttribute("aria-selected", "false"); - await expectChatHistoryPill(page); + await expectChatHistoryAttachment(page); }); test("keeps the fork attachment after submitting an existing-workspace draft tab", async ({ @@ -129,9 +141,8 @@ test.describe("Assistant fork menu", () => { await awaitAssistantMessage(page); await session.client.waitForFinish(session.agentId, 45_000); - await openAssistantForkMenu(page); - await page.getByTestId("assistant-fork-menu-new-tab").click(); - await expectChatHistoryPill(page); + await forkMostRecentAssistantTurnToNewTab(page); + await expectChatHistoryAttachment(page); await submitMessage(page, ""); @@ -171,17 +182,16 @@ test.describe("Assistant fork menu", () => { await awaitAssistantMessage(page); await session.client.waitForFinish(session.agentId, 45_000); - await openAssistantForkMenu(page); - await page.getByTestId("assistant-fork-menu-new-workspace").click(); + await forkMostRecentAssistantTurnToNewWorkspace(page); await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 }); - await expectChatHistoryPill(page); + await expectChatHistoryAttachment(page); await page.getByTestId("host-picker-trigger").click(); await page .getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host") .click(); - await expectChatHistoryPill(page); + await expectChatHistoryAttachment(page); }); test("keeps the fork attachment after the new agent receives its user message", async ({ @@ -200,9 +210,8 @@ test.describe("Assistant fork menu", () => { await awaitAssistantMessage(page); await session.client.waitForFinish(session.agentId, 45_000); - await openAssistantForkMenu(page); - await page.getByTestId("assistant-fork-menu-new-workspace").click(); - await expectChatHistoryPill(page); + await forkMostRecentAssistantTurnToNewWorkspace(page); + await expectChatHistoryAttachment(page); await submitNewWorkspaceEmpty(page); diff --git a/packages/app/e2e/helpers/assistant-fork.ts b/packages/app/e2e/helpers/assistant-fork.ts new file mode 100644 index 000000000..ad377fd1c --- /dev/null +++ b/packages/app/e2e/helpers/assistant-fork.ts @@ -0,0 +1,114 @@ +import { expect, type Page } from "@playwright/test"; +import { scrollAgentChatToBottom } from "./agent-bottom-anchor"; +import { getE2EDaemonPort } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface SessionMessage { + type?: unknown; + payload?: unknown; +} + +interface ForkContextResponsePayload { + attachment?: { text?: unknown } | null; +} + +function readSessionMessage(message: WebSocketMessage): SessionMessage | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { type?: unknown; message?: SessionMessage }; + return envelope.type === "session" ? (envelope.message ?? null) : envelope; + } catch { + return null; + } +} + +export function observeForkAttachment(page: Page): { + waitForText: () => Promise; +} { + const attachmentTexts: string[] = []; + const daemonPortMarker = `:${getE2EDaemonPort()}`; + + page.on("websocket", (socket) => { + if (!socket.url().includes(daemonPortMarker)) return; + socket.on("framereceived", ({ payload }) => { + const message = readSessionMessage(payload); + if (message?.type === "agent.fork_context.response") { + const response = (message.payload ?? {}) as ForkContextResponsePayload; + attachmentTexts.push(String(response.attachment?.text ?? "")); + } + }); + }); + + return { + async waitForText() { + await expect.poll(() => attachmentTexts.length, { timeout: 30_000 }).toBeGreaterThan(0); + const text = attachmentTexts.at(-1); + if (text === undefined) { + throw new Error("Expected a chat history attachment from the fork"); + } + return text; + }, + }; +} + +function inFlightTurn(page: Page) { + return page.getByTestId("turn-working-indicator"); +} + +async function openForkMenu(page: Page, trigger: ReturnType): Promise { + await trigger.click(); + await expect(page.getByRole("button", { name: "Fork in a new tab" })).toBeVisible({ + timeout: 10_000, + }); +} + +export async function openMostRecentAssistantForkMenu(page: Page): Promise { + await expect + .poll( + async () => { + await scrollAgentChatToBottom(page); + return page.getByRole("button", { name: "Fork chat" }).count(); + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); + const trigger = page.getByRole("button", { name: "Fork chat" }).last(); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await openForkMenu(page, trigger); +} + +export async function forkMostRecentAssistantTurnToNewTab(page: Page): Promise { + await openMostRecentAssistantForkMenu(page); + await page.getByRole("button", { name: "Fork in a new tab" }).click(); +} + +export async function expectInFlightForkAvailable(page: Page): Promise { + const trigger = inFlightTurn(page).getByRole("button", { name: "Fork chat" }); + await expect(trigger).toHaveCount(1, { timeout: 30_000 }); + await expect(trigger).toBeVisible(); +} + +export async function forkInFlightTurnToNewTab(page: Page): Promise { + const trigger = inFlightTurn(page).getByRole("button", { name: "Fork chat" }); + await openForkMenu(page, trigger); + await page.getByRole("button", { name: "Fork in a new tab" }).click(); +} + +export async function forkMostRecentAssistantTurnToNewWorkspace(page: Page): Promise { + await openMostRecentAssistantForkMenu(page); + await page.getByRole("button", { name: "Fork in a new workspace" }).click(); +} + +export async function expectChatHistoryAttachment(page: Page): Promise { + const attachment = page.getByRole("button", { name: "Open chat history attachment" }).first(); + await expect(attachment).toBeVisible({ timeout: 30_000 }); + await expect(attachment).toContainText("Chat history"); +} + +export async function expectLiveAssistantText(page: Page, text: string): Promise { + await expect(inFlightTurn(page)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("assistant-message").last()).toContainText(text, { + timeout: 60_000, + }); +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 5b7e01e8d..1fda1b1df 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -17,6 +17,7 @@ import { type AssistantForkTarget, } from "@/components/message"; import type { TurnFooterHost } from "./layout"; +import { AssistantForkMenu } from "@/components/assistant-fork-menu"; import { SyncedLoader } from "@/components/synced-loader"; import { useRetainedPanelActive } from "@/components/retained-panel"; @@ -33,6 +34,17 @@ export type AssistantTurnForkHandler = (input: { target: AssistantForkTarget; boundary: AssistantTurnForkBoundary; }) => Promise | void; +/** + * Fork handler for the turn that is still streaming. It deliberately takes no + * boundary: `selectForkContextRows` projects the entire timeline when neither + * boundary field is given, which is what captures the partially streamed text + * the user is watching. Pinning a boundary here would silently drop the live + * response — the opposite of what a fork button next to the loader promises. + * + * Kept separate from `AssistantTurnForkHandler` (whose `boundary` stays + * required) so the compiler keeps enforcing that completed turns always pin one. + */ +export type InFlightTurnForkHandler = (target: AssistantForkTarget) => Promise | void; export const TurnFooter = memo(function TurnFooter({ isRunning, @@ -41,6 +53,7 @@ export const TurnFooter = memo(function TurnFooter({ strategy, supportsTimelineCursor, onForkAssistantTurn, + onForkInFlightTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; @@ -48,11 +61,15 @@ export const TurnFooter = memo(function TurnFooter({ strategy: TurnContentStrategy; supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; + onForkInFlightTurn?: InFlightTurnForkHandler; }) { if (isRunning) { return ( - + ); } @@ -102,8 +119,10 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ const WorkingIndicator = memo(function WorkingIndicator({ inFlightTurnStartedAt = null, + onForkInFlightTurn, }: { inFlightTurnStartedAt?: Date | null; + onForkInFlightTurn?: InFlightTurnForkHandler; }) { const active = useRetainedPanelActive(); return ( @@ -119,14 +138,26 @@ const WorkingIndicator = memo(function WorkingIndicator({ testID="turn-working-elapsed" /> ) : null} + {/* Sits in the same 24px gapped row as the loader and elapsed timer, so + fork is reachable without waiting for the run to finish. */} + {onForkInFlightTurn ? : null} ); }); -function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) { +function RunningTurnFooter({ + inFlightTurnStartedAt, + onForkInFlightTurn, +}: { + inFlightTurnStartedAt: Date | null; + onForkInFlightTurn?: InFlightTurnForkHandler; +}) { return ( - + ); } diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 8639afbbc..c54555141 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -11,7 +11,6 @@ import React, { type ComponentProps, type ReactNode, } from "react"; -import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { View, @@ -71,6 +70,7 @@ import { CompletedTurnFooterRow, TurnFooter, type AssistantTurnForkHandler, + type InFlightTurnForkHandler, type TurnContentStrategy, } from "./turn-footer"; import { layoutStream, type StreamLayoutItem } from "./layout"; @@ -90,21 +90,12 @@ import { type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { useForkAgent } from "@/hooks/use-fork-agent"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { useRetainedPanelActive } from "@/components/retained-panel"; -import { generateDraftId } from "@/stores/draft-keys"; -import { - buildDraftWorkspaceAttachmentScopeKey, - useWorkspaceAttachmentsStore, -} from "@/attachments/workspace-attachments-store"; -import type { WorkspaceComposerAttachment } from "@/attachments/types"; -import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; -import { toErrorMessage } from "@/utils/error-messages"; -import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; function renderLiveAuxiliaryNode(input: { pendingPermissions: ReactNode; @@ -273,57 +264,6 @@ const EMPTY_STREAM_HEAD: StreamItem[] = []; const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = []; const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; -function buildChatHistoryAttachment(input: { - draftId: string; - serverId: string; - agentId: string; - payload: Awaited>; - missingAttachmentMessage: string; -}): WorkspaceComposerAttachment { - if (!input.payload.attachment) { - throw new Error(input.missingAttachmentMessage); - } - return { - kind: "chat_history", - id: `chat_history:${input.draftId}`, - attachment: input.payload.attachment, - source: { - serverId: input.serverId, - agentId: input.agentId, - boundaryMessageId: input.payload.boundaryMessageId, - boundaryCursor: input.payload.boundaryCursor, - itemCount: input.payload.itemCount, - }, - }; -} - -function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined { - if (!agent.provider) { - return undefined; - } - - const featureValues: Record = {}; - for (const feature of agent.features ?? []) { - featureValues[feature.id] = feature.value; - } - - return { - provider: agent.provider, - cwd: agent.cwd, - modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, - model: agent.model ?? agent.runtimeInfo?.model ?? null, - thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, - featureValues, - }; -} - -function buildForkDraftTabTarget( - setup: WorkspaceDraftTabSetup | undefined, - draftId: string, -): WorkspaceTabTarget { - return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId }; -} - const AgentStreamViewComponent = forwardRef( function AgentStreamView( { @@ -345,7 +285,6 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const viewportRef = useRef(null); @@ -380,11 +319,7 @@ const AgentStreamViewComponent = forwardRef - !readOnly && - state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, - ); + const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly }); const supportsAgentForkContextCursor = useSessionStore( (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, @@ -488,71 +423,29 @@ const AgentStreamViewComponent = forwardRef { - try { - if (!supportsAgentForkContext) { - toast?.error(t("message.actions.forkUnavailable")); - return; - } - if (!client) { - throw new Error(t("workspace.terminal.hostDisconnected")); - } - const draftSetup = buildForkDraftSetup(context); - const prepareForkDraft = async () => { - const draftId = generateDraftId(); - const payload = await client.buildAgentForkContext(agentId, boundary); - const attachment = buildChatHistoryAttachment({ - draftId, - serverId: resolvedServerId, - agentId, - payload, - missingAttachmentMessage: t("message.actions.forkFailed"), - }); - useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({ - scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId), - attachments: [attachment], - }); - return draftId; - }; - - if (target === "tab") { - const workspaceId = context.workspaceId; - if (!workspaceId) { - throw new Error(t("message.actions.forkMissingWorkspace")); - } - const draftId = await prepareForkDraft(); - navigateToWorkspace({ - serverId: resolvedServerId, - workspaceId, - target: buildForkDraftTabTarget(draftSetup, draftId), - }); - return; - } - - const draftId = await prepareForkDraft(); - const sourceDirectory = - context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined; - if (draftSetup) { - useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ - draftId, - setup: draftSetup, - sourceDirectory, - }); - } - router.push( - buildNewWorkspaceRoute({ - serverId: resolvedServerId, - sourceDirectory, - displayName: context.projectPlacement?.projectName, - projectId: context.projectPlacement?.projectKey, - draftId, - }), - ); - } catch (error) { - toast?.error(toErrorMessage(error) || t("message.actions.forkFailed")); - } + await forkAgent({ + agentId, + agent: context, + workspaceId: context.workspaceId, + target, + boundary, + }); }, ); + // The in-flight turn forks with no boundary at all: `selectForkContextRows` + // projects the whole timeline when neither boundary field is given, so the + // fork carries everything up to now, including the response still streaming + // in front of the user. + const handleForkInFlightTurn: InFlightTurnForkHandler = useStableEvent(async (target) => { + await forkAgent({ + agentId, + agent: context, + workspaceId: context.workspaceId, + target, + }); + }); + // Freeze stream data while this tab slot is hidden to prevent offscreen FlatList // cell-window renders on every 48ms flush from background agents. // When isActive flips back to true, the context change triggers a re-render and @@ -921,10 +814,12 @@ const AgentStreamViewComponent = forwardRef ) : null, [ handleForkAssistantTurn, + handleForkInFlightTurn, readOnly, isTurnActive, baseRenderModel.turnTiming.runningStartedAt, diff --git a/packages/app/src/hooks/use-fork-agent.ts b/packages/app/src/hooks/use-fork-agent.ts new file mode 100644 index 000000000..f2cdf3f91 --- /dev/null +++ b/packages/app/src/hooks/use-fork-agent.ts @@ -0,0 +1,198 @@ +import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import type { + AgentForkContextOptions, + DaemonClient, +} from "@getpaseo/client/internal/daemon-client"; +import type { WorkspaceComposerAttachment } from "@/attachments/types"; +import type { AssistantForkTarget } from "@/components/assistant-fork-menu"; +import type { ToastApi } from "@/components/toast-host"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useHostFeature } from "@/runtime/host-features"; +import { generateDraftId } from "@/stores/draft-keys"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { useSessionStore } from "@/stores/session-store"; +import { + buildDraftWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { toErrorMessage } from "@/utils/error-messages"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; + +/** + * The subset of an agent record that a fork needs in order to seed the new + * draft. Kept structural so both `AgentScreenAgent` (the agent-stream view's + * live context) and the session store's `Agent` record satisfy it without a + * projection step. + */ +export type ForkAgentSource = Pick< + AgentScreenAgent, + | "provider" + | "cwd" + | "currentModeId" + | "model" + | "thinkingOptionId" + | "runtimeInfo" + | "features" + | "projectPlacement" +>; + +/** + * Boundary marking where the forked context should stop. Omit it entirely to + * fork the whole timeline *up to now* — including a partially streamed + * in-flight turn. `selectForkContextRows` projects the full timeline when + * neither field is present, which is what makes mid-run forking work. + */ +export type ForkAgentBoundary = Pick< + AgentForkContextOptions, + "boundaryCursor" | "boundaryMessageId" +>; + +export interface ForkAgentRequest { + agentId: string; + agent: ForkAgentSource; + workspaceId?: string; + target: AssistantForkTarget; + boundary?: ForkAgentBoundary; +} + +export interface UseForkAgentInput { + serverId: string; + toast?: ToastApi | null; + /** Read-only surfaces (provider subagent panes) must never fork. */ + readOnly?: boolean; +} + +function buildChatHistoryAttachment(input: { + draftId: string; + serverId: string; + agentId: string; + payload: Awaited>; + missingAttachmentMessage: string; +}): WorkspaceComposerAttachment { + if (!input.payload.attachment) { + throw new Error(input.missingAttachmentMessage); + } + return { + kind: "chat_history", + id: `chat_history:${input.draftId}`, + attachment: input.payload.attachment, + source: { + serverId: input.serverId, + agentId: input.agentId, + boundaryMessageId: input.payload.boundaryMessageId, + boundaryCursor: input.payload.boundaryCursor, + itemCount: input.payload.itemCount, + }, + }; +} + +function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | undefined { + if (!agent.provider) { + return undefined; + } + + const featureValues: Record = {}; + for (const feature of agent.features ?? []) { + featureValues[feature.id] = feature.value; + } + + return { + provider: agent.provider, + cwd: agent.cwd, + modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, + model: agent.model ?? agent.runtimeInfo?.model ?? null, + thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, + featureValues, + }; +} + +function buildForkDraftTabTarget( + setup: WorkspaceDraftTabSetup | undefined, + draftId: string, +): WorkspaceTabTarget { + return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId }; +} + +/** + * Shared fork driver behind both turn-footer fork affordances: the completed + * turn's footer (which supplies a boundary pinned to that turn) and the + * in-flight turn's footer next to the progress loader (which omits the boundary + * so the fork captures the still-streaming response). + */ +export function useForkAgent( + input: UseForkAgentInput, +): (request: ForkAgentRequest) => Promise { + const { serverId, toast, readOnly = false } = input; + const { t } = useTranslation(); + const router = useRouter(); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const supportsAgentForkContext = useHostFeature(serverId, "agentForkContext") && !readOnly; + + return useStableEvent(async ({ agentId, agent, workspaceId, target, boundary }) => { + try { + if (!supportsAgentForkContext) { + toast?.error(t("message.actions.forkUnavailable")); + return; + } + if (!client) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const draftSetup = buildForkDraftSetup(agent); + const prepareForkDraft = async () => { + const draftId = generateDraftId(); + const payload = await client.buildAgentForkContext(agentId, boundary); + const attachment = buildChatHistoryAttachment({ + draftId, + serverId, + agentId, + payload, + missingAttachmentMessage: t("message.actions.forkFailed"), + }); + useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({ + scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId), + attachments: [attachment], + }); + return draftId; + }; + + if (target === "tab") { + if (!workspaceId) { + throw new Error(t("message.actions.forkMissingWorkspace")); + } + const draftId = await prepareForkDraft(); + navigateToWorkspace({ + serverId, + workspaceId, + target: buildForkDraftTabTarget(draftSetup, draftId), + }); + return; + } + + const draftId = await prepareForkDraft(); + const sourceDirectory = + agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined; + if (draftSetup) { + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId, + setup: draftSetup, + sourceDirectory, + }); + } + router.push( + buildNewWorkspaceRoute({ + serverId, + sourceDirectory, + displayName: agent.projectPlacement?.projectName, + projectId: agent.projectPlacement?.projectKey, + draftId, + }), + ); + } catch (error) { + toast?.error(toErrorMessage(error) || t("message.actions.forkFailed")); + } + }); +}