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")); + } + }); +}