Fork an agent session mid-run from the in-flight turn footer (#2638)
* 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 <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
e35ad9f692
commit
47c18a9213
@@ -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 `<thing> 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).
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
} {
|
||||
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<Page["getByRole"]>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await openMostRecentAssistantForkMenu(page);
|
||||
await page.getByRole("button", { name: "Fork in a new tab" }).click();
|
||||
}
|
||||
|
||||
export async function expectInFlightForkAvailable(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await openMostRecentAssistantForkMenu(page);
|
||||
await page.getByRole("button", { name: "Fork in a new workspace" }).click();
|
||||
}
|
||||
|
||||
export async function expectChatHistoryAttachment(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
await expect(inFlightTurn(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("assistant-message").last()).toContainText(text, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -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> | 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> | 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 (
|
||||
<TurnFooterRow>
|
||||
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
||||
<RunningTurnFooter
|
||||
inFlightTurnStartedAt={inFlightTurnStartedAt}
|
||||
onForkInFlightTurn={onForkInFlightTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <AssistantForkMenu onFork={onForkInFlightTurn} /> : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
|
||||
function RunningTurnFooter({
|
||||
inFlightTurnStartedAt,
|
||||
onForkInFlightTurn,
|
||||
}: {
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
onForkInFlightTurn?: InFlightTurnForkHandler;
|
||||
}) {
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
|
||||
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
||||
<WorkingIndicator
|
||||
inFlightTurnStartedAt={inFlightTurnStartedAt}
|
||||
onForkInFlightTurn={onForkInFlightTurn}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
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<string, unknown> = {};
|
||||
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<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -345,7 +285,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -380,11 +319,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const streamHead = providedStreamHead ?? sessionStreamHead;
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) =>
|
||||
!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<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundary }) => {
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
strategy={streamRenderStrategy}
|
||||
supportsTimelineCursor={supportsAgentForkContextCursor}
|
||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
||||
onForkInFlightTurn={readOnly ? undefined : handleForkInFlightTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
handleForkInFlightTurn,
|
||||
readOnly,
|
||||
isTurnActive,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
|
||||
@@ -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<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
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<string, unknown> = {};
|
||||
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<void> {
|
||||
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"));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user