Fork assistant turns into new drafts (#1788)

* feat(chat): fork assistant turns into new drafts

Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace.

* fix(chat): clean up fork draft handling

* fix(chat): address fork draft review feedback

* fix(server): build fork context from bounded timeline

* fix(app): restore assistant turn footer in streams

* fix(app): preserve fork draft setup

* fix(app): tighten assistant fork footers

* fix(app): lock active fork draft handoff

* fix(app): preserve attachment-only draft retries

* fix(app): unify composer attachment scopes

* fix(app): align chat history attachment labels

* fix(app): centralize attachment pill content

* fix(app): trim fork context for workspace naming
This commit is contained in:
Mohamed Boudra
2026-06-29 18:29:59 +02:00
committed by GitHub
parent e63a971968
commit 85acaceb16
54 changed files with 2364 additions and 312 deletions

View File

@@ -9,6 +9,7 @@ import {
import { import {
expectScrollStaysFixed, expectScrollStaysFixed,
readScrollMetrics, readScrollMetrics,
scrollAgentChatToBottom,
scrollChatAwayFromBottom, scrollChatAwayFromBottom,
waitForScrollableChat, waitForScrollableChat,
} from "./helpers/agent-bottom-anchor"; } from "./helpers/agent-bottom-anchor";
@@ -18,6 +19,8 @@ import { clickNewChat } from "./helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer"; import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
test.describe("Agent stream UI", () => { test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => { test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
@@ -54,7 +57,10 @@ test.describe("Agent stream UI", () => {
timeout: 30_000, timeout: 30_000,
}); });
await awaitAssistantMessage(page); await awaitAssistantMessage(page);
await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 30_000 }); await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 30_000,
});
const baseline = await scrollChatAwayFromBottom(page, { const baseline = await scrollChatAwayFromBottom(page, {
deltaY: -900, deltaY: -900,
minDistanceFromBottom: 300, minDistanceFromBottom: 300,
@@ -100,7 +106,10 @@ test.describe("Agent stream UI", () => {
timeout: 30_000, timeout: 30_000,
}); });
await awaitAssistantMessage(page); await awaitAssistantMessage(page);
await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 45_000 }); await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 45_000,
});
const baseline = await scrollChatAwayFromBottom(page, { const baseline = await scrollChatAwayFromBottom(page, {
deltaY: -900, deltaY: -900,
minDistanceFromBottom: 300, minDistanceFromBottom: 300,
@@ -122,6 +131,7 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page); await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page); await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000); await expectAgentIdle(page, 30_000);
await scrollAgentChatToBottom(page);
await expectTurnCopyButton(page); await expectTurnCopyButton(page);
} finally { } finally {
await agent.cleanup(); await agent.cleanup();

View File

@@ -0,0 +1,121 @@
import { expect, test as base, type Page } from "./fixtures";
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "./helpers/agent-stream";
import { expectComposerVisible } from "./helpers/composer";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentOptions,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
const test = base.extend<{
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
}>({
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
const sessions: MockAgentWorkspace[] = [];
await provide(async (options) => {
const session = await seedMockAgentWorkspace(options);
sessions.push(session);
return session;
});
await Promise.allSettled(sessions.map((session) => session.cleanup()));
},
});
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 });
test("forks an assistant turn into a new workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-tab-",
title: "Assistant fork tab",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
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);
});
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
page,
seedForkWorkspace,
}) => {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: "secondary-assistant-fork-host",
label: "Secondary host",
// The host does not need to be reachable; this pins that the draft-scoped
// attachment survives changing the selected target host.
endpoint: "127.0.0.1:9",
},
]);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-workspace-",
title: "Assistant fork workspace",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
await expectChatHistoryPill(page);
await page.getByTestId("host-picker-trigger").click();
await page
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
.click();
await expectChatHistoryPill(page);
});
});

View File

@@ -55,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX); .toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
} }
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
const chatScroll = getVisibleChatScroll(page);
await chatScroll.evaluate((root: Element) => {
const scrollElement = root as HTMLElement;
scrollElement.scrollTop = scrollElement.scrollHeight;
});
await expect
.poll(async () =>
chatScroll.evaluate((root: Element) => {
const scrollElement = root as HTMLElement;
return Math.max(
0,
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
);
}),
)
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth( export async function waitForContentGrowth(
page: Page, page: Page,
previousContentHeight: number, previousContentHeight: number,

View File

@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { seedWorkspace, type SeedDaemonClient } from "./seed-client"; import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
export interface MockAgentWorkspace { export interface MockAgentWorkspace {
agentId: string; agentId: string;
@@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace(
} }
export function buildAgentRoute(workspaceId: string, agentId: string): string { export function buildAgentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent( return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
`agent:${agentId}`,
)}`;
} }
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */ /** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */

View File

@@ -289,4 +289,67 @@ describe("layoutStream", () => {
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull(); expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]); expect(footerOwners(layout)).toEqual([assistant.id]);
}); });
it.each(["web", "android"] as const)(
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const firstTool = toolCall("tool-1", 3);
const latestAssistant = assistantMessage("a2", 4);
const latestTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
firstTool,
latestAssistant,
latestTool,
userMessage("u2", 6),
],
timingIds: [firstAssistant.id, latestAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
latestAssistant.id,
);
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
},
);
it.each(["web", "android"] as const)(
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant, tool],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
},
);
}); });

View File

@@ -68,18 +68,48 @@ function createTurnFooterHost(input: {
}; };
} }
function findLatestAssistantIndexInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number | null {
for (
let index = input.startIndex;
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return index;
}
}
return null;
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null { function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
if (input.agentStatus === "running") { if (input.agentStatus === "running") {
return null; return null;
} }
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history; const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.strategy.getLatestItemIndex(footerItems); const latestIndex = input.strategy.getLatestItemIndex(footerItems);
if (startIndex === null) { if (latestIndex === null) {
return null; return null;
} }
const item = footerItems[startIndex]; const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (assistantIndex === null) {
return null;
}
const item = footerItems[assistantIndex];
if (!item || item.kind !== "assistant_message") { if (!item || item.kind !== "assistant_message") {
return null; return null;
} }
@@ -87,26 +117,79 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return createTurnFooterHost({ return createTurnFooterHost({
item, item,
items: footerItems, items: footerItems,
index: startIndex, index: assistantIndex,
timingByAssistantId: input.timingByAssistantId, timingByAssistantId: input.timingByAssistantId,
}); });
} }
function findTurnEndIndexInSegment(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number {
let endIndex = input.startIndex;
for (
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "below")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
break;
}
endIndex = index;
}
return endIndex;
}
function shouldRenderCompletedFooter(input: { function shouldRenderCompletedFooter(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
item: StreamItem; item: StreamItem;
belowItem: StreamItem | null; belowItem: StreamItem | null;
agentStatus: string; agentStatus: string;
auxiliaryTurnFooter: TurnFooterHost | null; auxiliaryTurnFooter: TurnFooterHost | null;
}): boolean { }): boolean {
return ( if (
input.item.kind === "assistant_message" && input.item.kind !== "assistant_message" ||
input.auxiliaryTurnFooter?.itemId !== input.item.id && input.auxiliaryTurnFooter?.itemId === input.item.id
(input.belowItem?.kind === "user_message" || ) {
(input.belowItem === null && input.agentStatus !== "running")) return false;
); }
if (
input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running")
) {
return true;
}
if (!isToolSequenceItem(input.belowItem)) {
return false;
}
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
return false;
}
const turnEndIndex = findTurnEndIndexInSegment({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
});
const assistantIndex = findLatestAssistantIndexInTurn({
strategy: input.strategy,
items: input.items,
startIndex: turnEndIndex,
});
return assistantIndex === input.index;
} }
function isToolSequenceItem(item: StreamItem | null): boolean { function isToolSequenceItem(
item: StreamItem | null,
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
} }
@@ -175,6 +258,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
belowItem, belowItem,
}); });
const completedFooter = shouldRenderCompletedFooter({ const completedFooter = shouldRenderCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item, item,
belowItem, belowItem,
agentStatus: input.agentStatus, agentStatus: input.agentStatus,

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: timestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
messageId?: string,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: timestamp(seed),
...(messageId ? { messageId } : {}),
};
}
describe("resolveAssistantTurnBoundaryMessageId", () => {
it("uses the selected assistant message id", () => {
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), selected],
startIndex: 1,
}),
).toBe("msg-assistant-1");
});
it("does not borrow a boundary id from another assistant in the same turn", () => {
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
const selected = assistantMessage("assistant-2", 3);
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), first, selected],
startIndex: 2,
}),
).toBeUndefined();
});
it("requires the selected item to be an assistant message", () => {
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
startIndex: 0,
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,13 @@
import type { StreamItem } from "@/types/stream";
export function resolveAssistantTurnBoundaryMessageId(input: {
items: readonly StreamItem[];
startIndex: number;
}): string | undefined {
const item = input.items[input.startIndex];
if (item?.kind !== "assistant_message") {
return undefined;
}
// Forking without the selected assistant's durable message id would send the wrong slice.
return item.messageId || undefined;
}

View File

@@ -9,7 +9,13 @@ import {
collectAssistantTurnContentForStreamRenderStrategy, collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy, type StreamStrategy,
} from "./strategy"; } from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message"; import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
import {
AssistantTurnFooter,
LiveElapsed,
STREAM_METADATA_FONT_SIZE,
type AssistantForkTarget,
} from "@/components/message";
import type { TurnFooterHost } from "./layout"; import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader"; import { SyncedLoader } from "@/components/synced-loader";
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
}); });
export type TurnContentStrategy = StreamStrategy; export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
target: AssistantForkTarget;
boundaryMessageId?: string;
}) => Promise<void> | void;
export const TurnFooter = memo(function TurnFooter({ export const TurnFooter = memo(function TurnFooter({
isRunning, isRunning,
inFlightTurnStartedAt, inFlightTurnStartedAt,
host, host,
strategy, strategy,
onForkAssistantTurn,
}: { }: {
isRunning: boolean; isRunning: boolean;
inFlightTurnStartedAt: Date | null; inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null; host: TurnFooterHost | null;
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) { }) {
if (isRunning) { if (isRunning) {
return ( return (
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
items={host.items} items={host.items}
timing={host.timing} timing={host.timing}
startIndex={host.startIndex} startIndex={host.startIndex}
onForkAssistantTurn={onForkAssistantTurn}
/> />
); );
}); });
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
items, items,
timing, timing,
startIndex, startIndex,
onForkAssistantTurn,
}: { }: {
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
items: StreamItem[]; items: StreamItem[];
timing?: TurnTiming; timing?: TurnTiming;
startIndex: number; startIndex: number;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) { }) {
return ( return (
<TurnFooterRow> <TurnFooterRow>
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
items={items} items={items}
timing={timing} timing={timing}
startIndex={startIndex} startIndex={startIndex}
onForkAssistantTurn={onForkAssistantTurn}
/> />
</TurnFooterRow> </TurnFooterRow>
); );
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
items, items,
timing, timing,
startIndex, startIndex,
onForkAssistantTurn,
}: { }: {
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
items: StreamItem[]; items: StreamItem[];
timing?: TurnTiming; timing?: TurnTiming;
startIndex: number; startIndex: number;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) { }) {
const getContent = useCallback( const getContent = useCallback(
() => () =>
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
}), }),
[strategy, items, startIndex], [strategy, items, startIndex],
); );
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
items,
startIndex,
});
return ( return (
<View style={stylesheet.turnFooterSlot}> <View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter <AssistantTurnFooter
getContent={getContent} getContent={getContent}
completedAt={timing?.completedAt} completedAt={timing?.completedAt}
durationMs={timing?.durationMs} durationMs={timing?.durationMs}
forkBoundaryMessageId={boundaryMessageId}
onFork={onForkAssistantTurn}
/> />
</View> </View>
); );

View File

@@ -11,6 +11,7 @@ import React, {
type ComponentProps, type ComponentProps,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
View, View,
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer"; import {
CompletedTurnFooterRow,
TurnFooter,
type AssistantTurnForkHandler,
type TurnContentStrategy,
} from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout"; import { layoutStream, type StreamLayoutItem } from "./layout";
import { import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
@@ -76,11 +82,21 @@ import {
type WorkspaceFileOpenRequest, type WorkspaceFileOpenRequest,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler"; import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { MountedTabActiveContext } from "@/components/split-container"; import { MountedTabActiveContext } from "@/components/split-container";
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 "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
function renderLiveAuxiliaryNode(input: { function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode; pendingPermissions: ReactNode;
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
content: ReactNode; content: ReactNode;
layoutItem: StreamLayoutItem; layoutItem: StreamLayoutItem;
strategy: TurnContentStrategy; strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}): ReactNode { }): ReactNode {
if (!input.content) { if (!input.content) {
return null; return null;
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
items={footerHost.items} items={footerHost.items}
timing={footerHost.timing} timing={footerHost.timing}
startIndex={footerHost.startIndex} startIndex={footerHost.startIndex}
onForkAssistantTurn={input.onForkAssistantTurn}
/> />
) : null; ) : null;
const content = ( const content = (
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
const EMPTY_STREAM_HEAD: StreamItem[] = []; const EMPTY_STREAM_HEAD: StreamItem[] = [];
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,
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>( const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView( function AgentStreamView(
{ {
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref, ref,
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const viewportRef = useRef<StreamViewportHandle | null>(null); const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo( const streamRenderStrategy = useMemo(
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamHead = useSessionStore((state) => const streamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
); );
const supportsAgentForkContext = useSessionStore(
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
);
const workspaceRoot = agent.cwd?.trim() || ""; const workspaceRoot = agent.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({ const { requestDirectoryListing } = useFileExplorerActions({
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
handleInlinePathPress({ raw: filePath, path: filePath }, "main"); handleInlinePathPress({ raw: filePath, path: filePath }, "main");
}); });
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
async ({ target, boundaryMessageId }) => {
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,
boundaryMessageId ? { boundaryMessageId } : {},
);
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 = agent.workspaceId;
if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace"));
}
const draftId = await prepareForkDraft();
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
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: resolvedServerId,
sourceDirectory,
displayName: agent.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey,
draftId,
}),
);
} catch (error) {
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
}
},
);
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList // Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents. // cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and // When isActive flips back to true, the context change triggers a re-render and
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content, content,
layoutItem, layoutItem,
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn,
}); });
}, },
[renderStreamItemContent, streamRenderStrategy], [handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt} inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost} host={bottomTurnFooterHost}
strategy={streamRenderStrategy} strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn}
/> />
) : null, ) : null,
[ [
handleForkAssistantTurn,
showRunningTurnFooter, showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt, baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost, bottomTurnFooterHost,
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]); return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
} }
function collectAgentProjectPlacementDiffs(
left: AgentScreenAgent["projectPlacement"],
right: AgentScreenAgent["projectPlacement"],
): string[] {
const reasons: string[] = [];
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
if (left?.projectName !== right?.projectName) {
reasons.push("agent.projectPlacement.projectName");
}
if (left?.projectKey !== right?.projectKey) {
reasons.push("agent.projectPlacement.projectKey");
}
return reasons;
}
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.provider !== right.provider) reasons.push("agent.provider");
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
if (left.model !== right.model) reasons.push("agent.model");
if (left.thinkingOptionId !== right.thinkingOptionId) {
reasons.push("agent.thinkingOptionId");
}
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
reasons.push("agent.runtimeInfo.modeId");
}
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
reasons.push("agent.runtimeInfo.model");
}
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] { function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = []; const reasons: string[] = [];
if (left.serverId !== right.serverId) reasons.push("agent.serverId"); if (left.serverId !== right.serverId) reasons.push("agent.serverId");
if (left.id !== right.id) reasons.push("agent.id"); if (left.id !== right.id) reasons.push("agent.id");
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
if (left.status !== right.status) reasons.push("agent.status"); if (left.status !== right.status) reasons.push("agent.status");
if (left.cwd !== right.cwd) reasons.push("agent.cwd"); if (left.cwd !== right.cwd) reasons.push("agent.cwd");
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) { if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
reasons.push("agent.capabilities"); reasons.push("agent.capabilities");
} }
if (left.lastError !== right.lastError) reasons.push("agent.lastError"); if (left.lastError !== right.lastError) reasons.push("agent.lastError");
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) { reasons.push(...collectAgentSetupDiffs(left, right));
reasons.push("agent.projectPlacement.checkout.cwd"); reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
}
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
return reasons; return reasons;
} }

View File

@@ -8,16 +8,19 @@ export default function NewWorkspaceRoute() {
dir?: string; dir?: string;
name?: string; name?: string;
projectId?: string; projectId?: string;
draftId?: string;
}>(); }>();
const serverId = typeof params.serverId === "string" ? params.serverId : ""; const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined; const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
const displayName = typeof params.name === "string" ? params.name : undefined; const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
const draftId = typeof params.draftId === "string" ? params.draftId : undefined;
const screenKey = JSON.stringify([ const screenKey = JSON.stringify([
serverId, serverId,
sourceDirectory ?? null, sourceDirectory ?? null,
displayName ?? null, displayName ?? null,
projectId ?? null, projectId ?? null,
draftId ?? null,
]); ]);
return ( return (
@@ -28,6 +31,7 @@ export default function NewWorkspaceRoute() {
sourceDirectory={sourceDirectory} sourceDirectory={sourceDirectory}
displayName={displayName} displayName={displayName}
projectId={projectId} projectId={projectId}
draftId={draftId}
/> />
</HostRouteBootstrapBoundary> </HostRouteBootstrapBoundary>
); );

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import {
CircleDot,
FileText,
GitPullRequest,
MessageSquareCode,
MousePointer2,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export interface AttachmentPillContent {
icon: ReactNode;
title: string;
subtitle: string;
}
function getReviewSubtitle(count: number, t: TFunction): string {
return count === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count });
}
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
return "Comment";
}
return "Review";
}
function getTextAttachmentSubtitle(
attachment: Extract<AgentAttachment, { type: "text" }>,
t: TFunction,
): string {
if (attachment.contextKind === "chat_history") {
return "Previous conversation";
}
return t("message.attachments.text");
}
export function getAgentAttachmentPillContent(
attachment: AgentAttachment,
t: TFunction,
): AttachmentPillContent {
switch (attachment.type) {
case "review":
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.comments.length, t),
};
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "github_issue":
return {
icon: attachmentGithubIssueIcon,
title: attachment.title,
subtitle: `Issue #${attachment.number}`,
};
case "text":
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
subtitle: getTextAttachmentSubtitle(attachment, t),
};
case "uploaded_file":
return {
icon: attachmentFileIcon,
title: attachment.fileName,
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
};
}
}
export function getWorkspaceAttachmentPillContent(
attachment: WorkspaceComposerAttachment,
t: TFunction,
): AttachmentPillContent {
if (attachment.kind === "browser_element") {
return {
icon: attachmentBrowserIcon,
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
return {
icon: attachmentFileIcon,
title: attachment.title,
subtitle: getPullRequestContextSubtitle(attachment),
};
}
if (attachment.kind === "chat_history") {
return {
icon: attachmentFileIcon,
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
};
}
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.commentCount, t),
};
}
const ThemedAttachmentFileText = withUnistyles(FileText);
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const attachmentReviewIcon = (
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentGithubPrIcon = (
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentGithubIssueIcon = (
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentFileIcon = (
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentBrowserIcon = (
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);

View File

@@ -63,6 +63,18 @@ export type PullRequestContextAttachment =
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields) | ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields); | ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source: {
serverId: string;
agentId: string;
boundaryMessageId?: string | null;
itemCount?: number;
};
}
export type UserComposerAttachment = export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata } | { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment } | { kind: "file"; attachment: UploadedFileAttachment }
@@ -75,6 +87,7 @@ export type WorkspaceComposerAttachment =
attachment: BrowserElementAttachment; attachment: BrowserElementAttachment;
} }
| PullRequestContextAttachment | PullRequestContextAttachment
| ChatHistoryContextAttachment
| { | {
kind: "review"; kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>; attachment: Extract<AgentAttachment, { type: "review" }>;

View File

@@ -22,6 +22,7 @@ export function isWorkspaceAttachment(
return ( return (
attachment?.kind === "review" || attachment?.kind === "review" ||
attachment?.kind === "browser_element" || attachment?.kind === "browser_element" ||
attachment?.kind === "chat_history" ||
isPullRequestContextAttachment(attachment) isPullRequestContextAttachment(attachment)
); );
} }
@@ -33,6 +34,7 @@ export function userAttachmentsOnly(
(attachment): attachment is UserComposerAttachment => (attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "review" &&
attachment.kind !== "browser_element" && attachment.kind !== "browser_element" &&
attachment.kind !== "chat_history" &&
!isPullRequestContextAttachment(attachment), !isPullRequestContextAttachment(attachment),
); );
} }
@@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment(
text: attachment.text, text: attachment.text,
}; };
} }
if (attachment.kind === "chat_history") {
return attachment.attachment;
}
return attachment.kind === "review" ? attachment.attachment : null; return attachment.kind === "review" ? attachment.attachment : null;
} }

View File

@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "./types"; import type { WorkspaceComposerAttachment } from "./types";
import { import {
appendWorkspaceAttachment, appendWorkspaceAttachment,
buildDraftWorkspaceAttachmentScopeKey,
buildWorkspaceAttachmentScopeKey, buildWorkspaceAttachmentScopeKey,
collectWorkspaceAttachmentsForScopes,
resetWorkspaceAttachmentsStore, resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore, useWorkspaceAttachmentsStore,
} from "./workspace-attachments-store"; } from "./workspace-attachments-store";
@@ -57,6 +59,24 @@ function contextAttachment(id: string): WorkspaceComposerAttachment {
}; };
} }
function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment {
return {
kind: "chat_history",
id,
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text,
},
source: {
serverId: "local",
agentId: "agent-1",
},
};
}
describe("workspace attachments store", () => { describe("workspace attachments store", () => {
it("scopes workspace attachments by server and workspace before cwd fallback", () => { it("scopes workspace attachments by server and workspace before cwd fallback", () => {
expect( expect(
@@ -76,6 +96,12 @@ describe("workspace attachments store", () => {
).toBe("workspace-attachments:server=local:cwd=%2Frepo"); ).toBe("workspace-attachments:server=local:cwd=%2Frepo");
}); });
it("scopes draft attachments by draft id", () => {
expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe(
"workspace-attachments:draft=draft-1",
);
});
it("publishes and clears attachments for a workspace scope", () => { it("publishes and clears attachments for a workspace scope", () => {
resetWorkspaceAttachmentsStore(); resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({ const scopeKey = buildWorkspaceAttachmentScopeKey({
@@ -116,6 +142,13 @@ describe("workspace attachments store", () => {
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]); expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
}); });
it("dedupes repeated chat history attachments by id", () => {
const original = chatHistoryAttachment("chat_history:draft-1", "Original chat.");
const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat.");
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
});
it("adds a workspace attachment against the current scope state", () => { it("adds a workspace attachment against the current scope state", () => {
resetWorkspaceAttachmentsStore(); resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({ const scopeKey = buildWorkspaceAttachmentScopeKey({
@@ -138,4 +171,25 @@ describe("workspace attachments store", () => {
context, context,
]); ]);
}); });
it("collects attachments across requested scopes in scope order", () => {
const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: "workspace-1",
cwd: "/repo",
});
const draftContext = chatHistoryAttachment("chat_history:draft-1");
const workspaceContext = contextAttachment("comment-1");
expect(
collectWorkspaceAttachmentsForScopes({
attachmentsByScope: {
[workspaceScopeKey]: [workspaceContext],
[draftScopeKey]: [draftContext],
},
scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey],
}),
).toEqual([draftContext, workspaceContext]);
});
}); });

View File

@@ -1,15 +1,26 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { create } from "zustand"; import { create } from "zustand";
import { useShallow } from "zustand/shallow";
import type { WorkspaceComposerAttachment } from "./types"; import type { WorkspaceComposerAttachment } from "./types";
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = []; const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
export interface WorkspaceAttachmentScopeInput { export interface WorkspaceAttachmentScopeInput {
kind?: "workspace";
serverId: string; serverId: string;
workspaceId?: string | null; workspaceId?: string | null;
cwd: string; cwd: string;
} }
export interface DraftWorkspaceAttachmentScopeInput {
kind: "draft";
draftId: string;
}
export type WorkspaceAttachmentScope =
| WorkspaceAttachmentScopeInput
| DraftWorkspaceAttachmentScopeInput;
interface WorkspaceAttachmentsStoreState { interface WorkspaceAttachmentsStoreState {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>; attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
} }
@@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
); );
} }
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
}
function areWorkspaceAttachmentsEqual( function areWorkspaceAttachmentsEqual(
left: readonly WorkspaceComposerAttachment[], left: readonly WorkspaceComposerAttachment[],
right: readonly WorkspaceComposerAttachment[], right: readonly WorkspaceComposerAttachment[],
@@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual(
} }
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null { function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
if ( const isContextAttachment =
attachment.kind !== "github.pull_request_comment" && attachment.kind === "chat_history" ||
attachment.kind !== "github.pull_request_review" && attachment.kind === "github.pull_request_comment" ||
attachment.kind !== "github.pull_request_check" attachment.kind === "github.pull_request_review" ||
) { attachment.kind === "github.pull_request_check";
if (!isContextAttachment) {
return null; return null;
} }
return JSON.stringify({ return JSON.stringify({
@@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
}, },
})); }));
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string { export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string {
const { serverId, workspaceId, cwd } = input; const isDraftScope = input.kind === "draft";
const draftId = isDraftScope ? input.draftId : "";
const serverId = isDraftScope ? "" : input.serverId;
const workspaceId = isDraftScope ? undefined : input.workspaceId;
const cwd = isDraftScope ? "" : input.cwd;
return useMemo(() => {
if (isDraftScope) {
return buildDraftWorkspaceAttachmentScopeKey(draftId);
}
return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
}
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
return useMemo( return useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), () => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""),
[serverId, workspaceId, cwd], [normalizedDraftId],
); );
} }
@@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom
); );
} }
export function collectWorkspaceAttachmentsForScopes(input: {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
scopeKeys: readonly string[];
}): readonly WorkspaceComposerAttachment[] {
const attachments: WorkspaceComposerAttachment[] = [];
for (const scopeKey of input.scopeKeys) {
const normalizedScopeKey = scopeKey.trim();
if (!normalizedScopeKey) {
continue;
}
attachments.push(
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
);
}
return attachments;
}
export function useWorkspaceAttachmentsForScopes(
scopeKeys: readonly string[] | undefined,
): readonly WorkspaceComposerAttachment[] {
const normalizedScopeKeys = useMemo(
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
[scopeKeys],
);
const attachmentsByScope = useWorkspaceAttachmentsStore(
useShallow((state) => state.attachmentsByScope),
);
return useMemo(
() =>
collectWorkspaceAttachmentsForScopes({
attachmentsByScope,
scopeKeys: normalizedScopeKeys,
}),
[attachmentsByScope, normalizedScopeKeys],
);
}
export function resetWorkspaceAttachmentsStore(): void { export function resetWorkspaceAttachmentsStore(): void {
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} }); useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
} }

View File

@@ -0,0 +1,148 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { FolderPlus, Split } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
export type AssistantForkTarget = "tab" | "workspace";
interface AssistantForkMenuProps {
onFork: (target: AssistantForkTarget) => Promise<void> | void;
testID?: string;
}
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedSplit = withUnistyles(Split);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function getIcon(target: AssistantForkTarget) {
switch (target) {
case "tab":
return <ThemedSplit size={16} uniProps={foregroundColorMapping} />;
case "workspace":
return <ThemedFolderPlus size={16} uniProps={foregroundColorMapping} />;
}
}
export const AssistantForkMenu = memo(function AssistantForkMenu({
onFork,
testID = "assistant-fork-menu",
}: AssistantForkMenuProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [pendingTarget, setPendingTarget] = useState<AssistantForkTarget | null>(null);
const isLocked = pendingTarget !== null;
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next && pendingTarget !== null) return;
setIsOpen(next);
},
[pendingTarget],
);
const handleSelect = useCallback(
(target: AssistantForkTarget) => async () => {
if (isLocked) return;
setPendingTarget(target);
try {
await onFork(target);
} finally {
setPendingTarget(null);
setIsOpen(false);
}
},
[isLocked, onFork],
);
const triggerStyle = useCallback(
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
[isLocked],
);
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("message.actions.forkMenu")}</Text>
</TooltipContent>
),
[t],
);
return (
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger
accessibilityLabel={t("message.actions.forkMenu")}
accessibilityRole="button"
disabled={isLocked}
style={triggerStyle}
testID={`${testID}-trigger`}
>
{({ hovered, open }) => (
<ThemedSplit
size={16}
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
{tooltipContent}
</Tooltip>
<DropdownMenuContent align="start" minWidth={220} side="bottom" testID={`${testID}-content`}>
<DropdownMenuItem
closeOnSelect={false}
disabled={isLocked && pendingTarget !== "tab"}
leading={getIcon("tab")}
onSelect={handleSelect("tab")}
status={pendingTarget === "tab" ? "pending" : undefined}
testID={`${testID}-new-tab`}
>
{t("message.actions.forkInNewTab")}
</DropdownMenuItem>
<DropdownMenuItem
closeOnSelect={false}
disabled={isLocked && pendingTarget !== "workspace"}
leading={getIcon("workspace")}
onSelect={handleSelect("workspace")}
status={pendingTarget === "workspace" ? "pending" : undefined}
testID={`${testID}-new-workspace`}
>
{t("message.actions.forkInNewWorkspace")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
});
const styles = StyleSheet.create((theme) => ({
trigger: {
padding: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
},
triggerDisabled: {
opacity: theme.opacity[50],
},
triggerSlot: {
alignSelf: "center",
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
}));

View File

@@ -32,7 +32,6 @@ import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view"; import MaskedView from "@react-native-masked-view/masked-view";
import { import {
Circle, Circle,
CircleDot,
Info, Info,
CheckCircle, CheckCircle,
XCircle, XCircle,
@@ -42,15 +41,13 @@ import {
Check, Check,
CheckSquare, CheckSquare,
Copy, Copy,
GitPullRequest,
MessageSquareCode,
TriangleAlertIcon, TriangleAlertIcon,
Scissors, Scissors,
MicVocal, MicVocal,
FileSymlink, FileSymlink,
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, { import Animated, {
Easing, Easing,
@@ -89,6 +86,7 @@ import {
getFileNameFromPath, getFileNameFromPath,
parseImageDataUrl, parseImageDataUrl,
} from "@/attachments/utils"; } from "@/attachments/utils";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { PlanCard } from "./plan-card"; import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet"; import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -104,7 +102,6 @@ import {
import { getCompactionMarkerLabel } from "./message-compaction-label"; import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import { getFileTypeLabel } from "@/attachments/file-types";
import { import {
AttachmentFrame, AttachmentFrame,
AttachmentLabel, AttachmentLabel,
@@ -116,7 +113,9 @@ import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu"; import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation"; import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
export type { InlinePathTarget } from "@/assistant-file-links"; export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps { interface UserMessageProps {
serverId?: string; serverId?: string;
@@ -170,10 +169,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedAttachmentFileText = withUnistyles(FileText);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -402,19 +397,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
}, },
})); }));
const attachmentReviewIcon = (
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentGithubPrIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentGithubIssueIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentFileIcon = (
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
interface UserMessageImagePillProps { interface UserMessageImagePillProps {
image: UserMessageImageAttachment; image: UserMessageImageAttachment;
onOpen: (image: UserMessageImageAttachment) => void; onOpen: (image: UserMessageImageAttachment) => void;
@@ -432,55 +414,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
); );
} }
interface UserMessageAttachmentContent {
icon: ReactNode;
title: string;
subtitle: string;
}
function getUserMessageAttachmentContent(
attachment: AgentAttachment,
t: ReturnType<typeof useTranslation>["t"],
): UserMessageAttachmentContent {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle:
count === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count }),
};
}
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "github_issue":
return {
icon: attachmentGithubIssueIcon,
title: attachment.title,
subtitle: `Issue #${attachment.number}`,
};
case "text":
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
subtitle: t("message.attachments.text"),
};
case "uploaded_file":
return {
icon: attachmentFileIcon,
title: attachment.fileName,
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
};
}
}
export const UserMessage = memo(function UserMessage({ export const UserMessage = memo(function UserMessage({
serverId, serverId,
agentId, agentId,
@@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({
{hasAttachments ? ( {hasAttachments ? (
<View style={attachmentPreviewContainerStyle}> <View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => { {attachments.map((attachment, index) => {
const content = getUserMessageAttachmentContent(attachment, t); const content = getAgentAttachmentPillContent(attachment, t);
return ( return (
<AttachmentFrame <AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`} key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
@@ -628,6 +561,11 @@ interface AssistantTurnFooterProps {
getContent: () => string; getContent: () => string;
completedAt?: Date; completedAt?: Date;
durationMs?: number; durationMs?: number;
forkBoundaryMessageId?: string;
onFork?: (input: {
target: AssistantForkTarget;
boundaryMessageId?: string;
}) => Promise<void> | void;
} }
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
@@ -672,6 +610,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent, getContent,
completedAt, completedAt,
durationMs, durationMs,
forkBoundaryMessageId,
onFork,
}: AssistantTurnFooterProps) { }: AssistantTurnFooterProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false); const [pressedReveal, setPressedReveal] = useState(false);
@@ -711,6 +651,13 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
revealTimerRef.current = null; revealTimerRef.current = null;
}, TIMESTAMP_REVEAL_MS); }, TIMESTAMP_REVEAL_MS);
}, [canSwap]); }, [canSwap]);
const handleFork = useCallback(
(target: AssistantForkTarget) => {
return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId });
},
[forkBoundaryMessageId, onFork],
);
const canFork = Boolean(onFork && forkBoundaryMessageId);
return ( return (
<View style={assistantTurnFooterStylesheet.container}> <View style={assistantTurnFooterStylesheet.container}>
@@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent={getContent} getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton} containerStyle={assistantTurnFooterStylesheet.copyButton}
/> />
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{durationLabel ? ( {durationLabel ? (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}

View File

@@ -0,0 +1,70 @@
import type { ComposerAttachment, WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
export function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
if (attachment.kind === "chat_history") {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
export function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function isSentContextAttachment(
attachment: ComposerAttachment,
): attachment is WorkspaceComposerAttachment {
return (
attachment.kind === "browser_element" ||
attachment.kind === "chat_history" ||
isPullRequestContextAttachment(attachment)
);
}
export function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isSentContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
buildDraftWorkspaceAttachmentScopeKey,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import { removeSentContextAttachments } from "./workspace-cleanup";
function chatHistoryAttachment(): WorkspaceComposerAttachment {
return {
kind: "chat_history",
id: "chat_history:draft-1",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Previous chat.",
},
source: {
serverId: "local",
agentId: "agent-1",
},
};
}
function pullRequestContextAttachment(): WorkspaceComposerAttachment {
return {
kind: "github.pull_request_comment",
id: "comment-1",
title: "Comment",
text: "Please check this.",
};
}
function browserElementAttachment(): WorkspaceComposerAttachment {
return {
kind: "browser_element",
attachment: {
url: "https://example.com",
selector: "button.primary",
tag: "button",
text: "Click me",
outerHTML: '<button class="primary">Click me</button>',
computedStyles: {},
boundingRect: { x: 0, y: 0, width: 100, height: 40 },
reactSource: null,
parentChain: [],
children: [],
formatted: "button.primary\nClick me",
},
};
}
describe("workspace composer attachment cleanup", () => {
it("clears sent scoped context attachments from their stores", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
const chatHistory = chatHistoryAttachment();
const pullRequestContext = pullRequestContextAttachment();
const browserElement = browserElementAttachment();
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
scopeKey,
attachments: [chatHistory, pullRequestContext, browserElement],
});
removeSentContextAttachments([chatHistory, pullRequestContext, browserElement]);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
});
});

View File

@@ -1,25 +1,25 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type { import type {
ComposerAttachment, ComposerAttachment,
UserComposerAttachment, UserComposerAttachment,
WorkspaceComposerAttachment, WorkspaceComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { import {
isWorkspaceAttachment, isWorkspaceAttachment,
isPullRequestContextAttachment, isPullRequestContextAttachment,
userAttachmentsOnly, userAttachmentsOnly,
workspaceAttachmentToSubmitAttachment, workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils"; } from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import {
getAttachmentKey,
removeSentContextAttachments,
removeWorkspaceAttachmentsMatching,
} from "./workspace-cleanup";
import { useClearReviewDraft } from "@/review/store"; import { useClearReviewDraft } from "@/review/store";
type TranslationFn = ReturnType<typeof useTranslation>["t"];
interface WorkspaceAttachmentBindingInput { interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[]; normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[]; workspaceAttachments: readonly WorkspaceComposerAttachment[];
@@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding {
resetSuppression: () => void; resetSuppression: () => void;
} }
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
return "Comment";
}
return "Review";
}
interface PillContent {
title: string;
subtitle: string;
}
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
if (attachment.kind === "browser_element") {
return {
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
return {
title: attachment.title,
subtitle: getContextSourceLabel(attachment),
};
}
return {
title: t("message.attachments.review"),
subtitle:
attachment.commentCount === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
};
}
function getOpenAccessibilityLabel( function getOpenAccessibilityLabel(
attachment: WorkspaceComposerAttachment, attachment: WorkspaceComposerAttachment,
t: TranslationFn, t: ReturnType<typeof useTranslation>["t"],
): string { ): string {
if (attachment.kind === "browser_element") { if (attachment.kind === "browser_element") {
return t("composer.attachments.openBrowserElement"); return t("composer.attachments.openBrowserElement");
@@ -148,12 +60,15 @@ function getOpenAccessibilityLabel(
if (isPullRequestContextAttachment(attachment)) { if (isPullRequestContextAttachment(attachment)) {
return "Open context attachment"; return "Open context attachment";
} }
if (attachment.kind === "chat_history") {
return "Open chat history attachment";
}
return t("composer.attachments.openReview"); return t("composer.attachments.openReview");
} }
function getRemoveAccessibilityLabel( function getRemoveAccessibilityLabel(
attachment: WorkspaceComposerAttachment, attachment: WorkspaceComposerAttachment,
t: TranslationFn, t: ReturnType<typeof useTranslation>["t"],
): string { ): string {
if (attachment.kind === "browser_element") { if (attachment.kind === "browser_element") {
return t("composer.attachments.removeBrowserElement"); return t("composer.attachments.removeBrowserElement");
@@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel(
if (isPullRequestContextAttachment(attachment)) { if (isPullRequestContextAttachment(attachment)) {
return "Remove context attachment"; return "Remove context attachment";
} }
if (attachment.kind === "chat_history") {
return "Remove chat history attachment";
}
return t("composer.attachments.removeReview"); return t("composer.attachments.removeReview");
} }
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement { function getPillTestID(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") { if (attachment.kind === "chat_history") {
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />; return "composer-chat-history-attachment-pill";
} }
if (isPullRequestContextAttachment(attachment)) { return "composer-review-attachment-pill";
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
}
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
} }
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement { function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
@@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => { ({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index]; const selected = current[index];
if (isWorkspaceAttachment(selected)) { if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) { if (
selected.kind === "browser_element" ||
selected.kind === "chat_history" ||
isPullRequestContextAttachment(selected)
) {
const selectedKey = getAttachmentKey(selected); const selectedKey = getAttachmentKey(selected);
removeWorkspaceAttachmentsMatching(selectedKey); removeWorkspaceAttachmentsMatching(selectedKey);
return true; return true;
@@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({
onRemove, onRemove,
}: WorkspaceAttachmentPillProps) { }: WorkspaceAttachmentPillProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const content = getPillContent(attachment, t); const content = getWorkspaceAttachmentPillContent(attachment, t);
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
onOpen(attachment); onOpen(attachment);
}, [onOpen, attachment]); }, [onOpen, attachment]);
@@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({
}, [onRemove, index]); }, [onRemove, index]);
return ( return (
<AttachmentPill <AttachmentPill
testID="composer-review-attachment-pill" testID={getPillTestID(attachment)}
onOpen={handleOpen} onOpen={handleOpen}
onRemove={handleRemove} onRemove={handleRemove}
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)} openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)} removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
disabled={disabled} disabled={disabled}
> >
<AttachmentLabel <AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
icon={renderPillIcon(attachment)}
title={content.title}
subtitle={content.subtitle}
/>
</AttachmentPill> </AttachmentPill>
); );
} }
@@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = {
userAttachmentsOnly, userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding, useBinding: useWorkspaceAttachmentBinding,
}; };
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedFileText = withUnistyles(FileText);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });

View File

@@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => {
}); });
expect(onCreateSuccess).toHaveBeenCalledTimes(1); expect(onCreateSuccess).toHaveBeenCalledTimes(1);
}); });
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = {
kind: "chat_history",
id: "chat-history-1",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Previous conversation",
},
source: {
serverId: "server-1",
agentId: "agent-source",
},
} as const;
const createRequest = vi.fn(async () => ({
agentId: "agent-1",
result: { id: "agent-1" },
}));
const onCreateSuccess = vi.fn();
const validateBeforeSubmit = vi.fn(() => null);
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
createRequest,
onCreateSuccess,
validateBeforeSubmit,
}),
);
await act(async () => {
await result.current.handleCreateFromInput({
text: " ",
attachments: [attachment],
cwd: "/repo",
});
});
expect(validateBeforeSubmit).toHaveBeenCalledWith({
text: "",
attachments: [attachment],
cwd: "/repo",
});
expect(createRequest).toHaveBeenCalledWith({
attempt: expect.objectContaining({
text: "",
attachments: [attachment.attachment],
}),
text: "",
attachments: [attachment.attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const images = wirePayload.images; const images = wirePayload.images;
const trimmedPrompt = text.trim(); const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) { const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
const error = new Error(t("composer.errors.initialPromptRequired")); const error = new Error(t("composer.errors.initialPromptRequired"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error; throw error;

View File

@@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig {
model: string | null; model: string | null;
} }
export function shouldAllowEmptyDraftText(input: {
allowsEmptyAutoSubmit: boolean;
attachments: readonly unknown[];
}): boolean {
return input.allowsEmptyAutoSubmit || input.attachments.length > 0;
}
export function validateDraftSubmission(input: { export function validateDraftSubmission(input: {
text: string; text: string;
allowsEmptyAutoSubmit: boolean; allowsEmptyAutoSubmit: boolean;

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { validateDraftSubmission } from "./workspace-tab-core"; import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core";
const baseComposerState = { const baseComposerState = {
providerDefinitions: [{ id: "codewhale" }], providerDefinitions: [{ id: "codewhale" }],
@@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => {
).toBe("No model is available for the selected provider"); ).toBe("No model is available for the selected provider");
}); });
}); });
describe("workspace draft empty text readiness", () => {
test("allows attachment-only retries after a fork draft create fails", () => {
expect(
shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit: false,
attachments: [{ kind: "chat_history" }],
}),
).toBe(true);
});
test("still rejects empty drafts with no auto-submit and no attachments", () => {
expect(
shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit: false,
attachments: [],
}),
).toBe(false);
});
});

View File

@@ -27,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core"; import {
shouldAllowEmptyDraftText,
validateDraftSubmission,
} from "@/composer/draft/workspace-tab-core";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceComposerAttachment } from "@/attachments/types"; import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { import {
useWorkspaceAttachments, useDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentScopeKey, useWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store"; } from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream"; import type { UserMessageImageAttachment } from "@/types/stream";
import { import {
@@ -118,6 +122,7 @@ async function submitDraftCreateRequest(input: {
text: string; text: string;
images?: UserMessageImageAttachment[]; images?: UserMessageImageAttachment[];
attachments?: unknown; attachments?: unknown;
cwd: string;
client: DaemonClient | null; client: DaemonClient | null;
workspaceDirectory: string | null; workspaceDirectory: string | null;
workspaceId: string | null; workspaceId: string | null;
@@ -138,6 +143,7 @@ async function submitDraftCreateRequest(input: {
text, text,
images, images,
attachments, attachments,
cwd,
client, client,
workspaceDirectory, workspaceDirectory,
workspaceId, workspaceId,
@@ -162,7 +168,7 @@ async function submitDraftCreateRequest(input: {
}); });
const config = buildWorkspaceDraftAgentConfig({ const config = buildWorkspaceDraftAgentConfig({
provider, provider,
cwd: workspaceDirectory, cwd,
...modeIdOverride, ...modeIdOverride,
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId: thinkingOptionId:
@@ -400,7 +406,14 @@ export function WorkspaceDraftAgentTab({
cwd: composerState.workingDir, cwd: composerState.workingDir,
workspaceId, workspaceId,
}); });
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const attachmentScopeKeys = useMemo(
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
);
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback( const handleOpenWorkspaceAttachment = useCallback(
@@ -437,15 +450,20 @@ export function WorkspaceDraftAgentTab({
getPendingServerId: () => serverId, getPendingServerId: () => serverId,
initialAttempt: initialCreateAttempt, initialAttempt: initialCreateAttempt,
allowEmptyText: allowsEmptyAutoSubmit, allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text }) => validateBeforeSubmit: ({ text, attachments }) => {
validateDraftSubmission({ const allowsEmptyDraftText = shouldAllowEmptyDraftText({
text,
allowsEmptyAutoSubmit, allowsEmptyAutoSubmit,
attachments,
});
return validateDraftSubmission({
text,
allowsEmptyAutoSubmit: allowsEmptyDraftText,
composerState, composerState,
autoSubmitConfig, autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory, workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client), hasClient: Boolean(client),
}), });
},
onBeforeSubmit: async () => { onBeforeSubmit: async () => {
await composerState.persistFormPreferences(); await composerState.persistFormPreferences();
if (isWeb) { if (isWeb) {
@@ -463,12 +481,13 @@ export function WorkspaceDraftAgentTab({
composerState, composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"), selectModelMessage: t("workspaceSetup.errors.selectModel"),
}), }),
createRequest: async ({ attempt, text, images, attachments }) => createRequest: async ({ attempt, text, images, attachments, cwd }) =>
submitDraftCreateRequest({ submitDraftCreateRequest({
attempt, attempt,
text, text,
images, images,
attachments, attachments,
cwd,
client, client,
workspaceDirectory: draftWorkingDirectory, workspaceDirectory: draftWorkingDirectory,
workspaceId: workspaceFields?.id ?? null, workspaceId: workspaceFields?.id ?? null,
@@ -479,6 +498,8 @@ export function WorkspaceDraftAgentTab({
}), }),
onCreateSuccess: ({ result }) => { onCreateSuccess: ({ result }) => {
clearDraftInput("sent"); clearDraftInput("sent");
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result); onCreated(result);
}, },
}); });
@@ -690,7 +711,7 @@ export function WorkspaceDraftAgentTab({
value={draftInput.text} value={draftInput.text}
onChangeText={draftInput.setText} onChangeText={draftInput.setText}
attachments={draftInput.attachments} attachments={draftInput.attachments}
workspaceAttachments={workspaceAttachments} attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments} onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir} cwd={composerState.workingDir}

View File

@@ -103,6 +103,7 @@ import type {
} from "@/attachments/types"; } from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file"; import type { PickedFile } from "@/attachments/picked-file";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop"; import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types"; import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -121,6 +122,8 @@ type AttachmentListUpdater =
| UserComposerAttachment[] | UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {} function noop() {}
const noopCallback = () => {}; const noopCallback = () => {};
@@ -759,7 +762,7 @@ interface ComposerProps {
value: string; value: string;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
attachments: UserComposerAttachment[]; attachments: UserComposerAttachment[];
workspaceAttachments?: readonly WorkspaceComposerAttachment[]; attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string; cwd: string;
@@ -971,7 +974,7 @@ export function Composer({
value, value,
onChangeText, onChangeText,
attachments, attachments,
workspaceAttachments = [], attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment, onOpenWorkspaceAttachment,
onChangeAttachments, onChangeAttachments,
cwd, cwd,
@@ -1026,6 +1029,7 @@ export function Composer({
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t); const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value; const userInput = value;
const setUserInput = onChangeText; const setUserInput = onChangeText;
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
const { const {
selectedAttachments, selectedAttachments,
buildOutgoingAttachments, buildOutgoingAttachments,

View File

@@ -1,15 +1,31 @@
import { useRef } from "react"; import { useRef } from "react";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import type {
AgentCapabilityFlags,
AgentFeature,
AgentProvider,
} from "@getpaseo/protocol/agent-types";
export interface AgentScreenAgent { export interface AgentScreenAgent {
serverId: string; serverId: string;
id: string; id: string;
provider?: AgentProvider;
status: "initializing" | "idle" | "running" | "error" | "closed"; status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string; cwd: string;
workspaceId?: string; workspaceId?: string;
capabilities?: AgentCapabilityFlags; capabilities?: AgentCapabilityFlags;
currentModeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
runtimeInfo?: {
model?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
} | null;
features?: readonly AgentFeature[];
lastError?: string | null; lastError?: string | null;
projectPlacement?: { projectPlacement?: {
projectKey?: string;
projectName?: string;
checkout?: { checkout?: {
cwd?: string; cwd?: string;
isGit?: boolean; isGit?: boolean;

View File

@@ -245,6 +245,12 @@ export const ar: TranslationResources = {
copyCode: "نسخ الرمز", copyCode: "نسخ الرمز",
copyTurn: "نسخ بدوره", copyTurn: "نسخ بدوره",
copyMessage: "انسخ الرسالة", copyMessage: "انسخ الرسالة",
forkMenu: "تفريع الرسالة",
forkInNewTab: "تفريع في تبويب جديد",
forkInNewWorkspace: "تفريع في مساحة عمل جديدة",
forkUnavailable: "حدّث المضيف لاستخدام هذا.",
forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.",
forkFailed: "فشل تفريع المحادثة",
openFile: "افتح الملف", openFile: "افتح الملف",
copied: "منقول", copied: "منقول",
}, },

View File

@@ -244,6 +244,12 @@ export const en = {
copyCode: "Copy code", copyCode: "Copy code",
copyTurn: "Copy turn", copyTurn: "Copy turn",
copyMessage: "Copy message", copyMessage: "Copy message",
forkMenu: "Fork message",
forkInNewTab: "Fork in a new tab",
forkInNewWorkspace: "Fork in a new workspace",
forkUnavailable: "Update the host to use this.",
forkMissingWorkspace: "This agent is not in a workspace.",
forkFailed: "Failed to fork chat",
openFile: "Open file", openFile: "Open file",
copied: "Copied", copied: "Copied",
}, },

View File

@@ -248,6 +248,12 @@ export const es: TranslationResources = {
copyCode: "Copiar código", copyCode: "Copiar código",
copyTurn: "Copiar turno", copyTurn: "Copiar turno",
copyMessage: "Copiar mensaje", copyMessage: "Copiar mensaje",
forkMenu: "Bifurcar mensaje",
forkInNewTab: "Bifurcar en una pestaña nueva",
forkInNewWorkspace: "Bifurcar en un espacio de trabajo nuevo",
forkUnavailable: "Actualiza el host para usar esto.",
forkMissingWorkspace: "Este agente no está en un espacio de trabajo.",
forkFailed: "No se pudo bifurcar el chat",
openFile: "Abrir archivo", openFile: "Abrir archivo",
copied: "Copiado", copied: "Copiado",
}, },

View File

@@ -248,6 +248,12 @@ export const fr: TranslationResources = {
copyCode: "Copier le code", copyCode: "Copier le code",
copyTurn: "Copier le tour", copyTurn: "Copier le tour",
copyMessage: "Copier le message", copyMessage: "Copier le message",
forkMenu: "Dupliquer le message",
forkInNewTab: "Dupliquer dans un nouvel onglet",
forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail",
forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.",
forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.",
forkFailed: "Impossible de dupliquer le chat",
openFile: "Ouvrir le fichier", openFile: "Ouvrir le fichier",
copied: "Copié", copied: "Copié",
}, },

View File

@@ -248,6 +248,12 @@ export const ja: TranslationResources = {
copyCode: "コードをコピー", copyCode: "コードをコピー",
copyTurn: "ターンをコピー", copyTurn: "ターンをコピー",
copyMessage: "メッセージをコピー", copyMessage: "メッセージをコピー",
forkMenu: "メッセージをフォーク",
forkInNewTab: "新しいタブにフォーク",
forkInNewWorkspace: "新しいワークスペースにフォーク",
forkUnavailable: "これを使用するにはホストを更新してください。",
forkMissingWorkspace: "このエージェントはワークスペース内にありません。",
forkFailed: "チャットのフォークに失敗しました",
openFile: "ファイルを開く", openFile: "ファイルを開く",
copied: "コピーしました", copied: "コピーしました",
}, },

View File

@@ -248,6 +248,12 @@ export const ptBR: TranslationResources = {
copyCode: "Copiar código", copyCode: "Copiar código",
copyTurn: "Copiar turno", copyTurn: "Copiar turno",
copyMessage: "Copiar mensagem", copyMessage: "Copiar mensagem",
forkMenu: "Bifurcar mensagem",
forkInNewTab: "Bifurcar em uma nova aba",
forkInNewWorkspace: "Bifurcar em um novo workspace",
forkUnavailable: "Atualize o host para usar isto.",
forkMissingWorkspace: "Este agente não está em um workspace.",
forkFailed: "Falha ao bifurcar o chat",
openFile: "Abrir arquivo", openFile: "Abrir arquivo",
copied: "Copiado", copied: "Copiado",
}, },

View File

@@ -247,6 +247,12 @@ export const ru: TranslationResources = {
copyCode: "Скопировать код", copyCode: "Скопировать код",
copyTurn: "Копировать ход", copyTurn: "Копировать ход",
copyMessage: "Копировать сообщение", copyMessage: "Копировать сообщение",
forkMenu: "Форкнуть сообщение",
forkInNewTab: "Форкнуть в новой вкладке",
forkInNewWorkspace: "Форкнуть в новом рабочем пространстве",
forkUnavailable: "Обновите хост, чтобы использовать это.",
forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.",
forkFailed: "Не удалось форкнуть чат",
openFile: "Открыть файл", openFile: "Открыть файл",
copied: "Скопировано", copied: "Скопировано",
}, },

View File

@@ -245,6 +245,12 @@ export const zhCN: TranslationResources = {
copyCode: "复制代码", copyCode: "复制代码",
copyTurn: "复制回合", copyTurn: "复制回合",
copyMessage: "复制消息", copyMessage: "复制消息",
forkMenu: "分叉消息",
forkInNewTab: "分叉到新标签页",
forkInNewWorkspace: "分叉到新工作区",
forkUnavailable: "请更新主机以使用此功能。",
forkMissingWorkspace: "此 Agent 不在工作区中。",
forkFailed: "分叉聊天失败",
openFile: "打开文件", openFile: "打开文件",
copied: "已复制", copied: "已复制",
}, },

View File

@@ -24,10 +24,7 @@ import {
type ToastState, type ToastState,
} from "@/components/toast-host"; } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types"; import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
useWorkspaceAttachments,
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
@@ -86,10 +83,16 @@ import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-co
interface ChatAgentStateShape { interface ChatAgentStateShape {
serverId: string | null; serverId: string | null;
id: string | null; id: string | null;
provider?: Agent["provider"];
status: Agent["status"] | null; status: Agent["status"] | null;
cwd: string | null; cwd: string | null;
workspaceId?: string; workspaceId?: string;
capabilities?: Agent["capabilities"]; capabilities?: Agent["capabilities"];
currentModeId?: Agent["currentModeId"];
model?: Agent["model"];
thinkingOptionId?: Agent["thinkingOptionId"];
runtimeInfo?: Agent["runtimeInfo"];
features?: Agent["features"];
lastError?: Agent["lastError"] | null; lastError?: Agent["lastError"] | null;
} }
@@ -130,10 +133,16 @@ function selectChatAgentState(
return { return {
serverId: agent.serverId, serverId: agent.serverId,
id: agent.id, id: agent.id,
provider: agent.provider,
status: agent.status, status: agent.status,
cwd: agent.cwd, cwd: agent.cwd,
workspaceId: agent.workspaceId, workspaceId: agent.workspaceId,
capabilities: agent.capabilities, capabilities: agent.capabilities,
currentModeId: agent.currentModeId,
model: agent.model,
thinkingOptionId: agent.thinkingOptionId,
runtimeInfo: agent.runtimeInfo,
features: agent.features,
lastError: agent.lastError ?? null, lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null, archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false, requiresAttention: agent.requiresAttention ?? false,
@@ -151,10 +160,16 @@ function buildChatAgentFromState(
return { return {
serverId: state.serverId, serverId: state.serverId,
id: state.id, id: state.id,
provider: state.provider,
status: state.status, status: state.status,
cwd: state.cwd, cwd: state.cwd,
workspaceId: state.workspaceId, workspaceId: state.workspaceId,
capabilities: state.capabilities, capabilities: state.capabilities,
currentModeId: state.currentModeId,
model: state.model,
thinkingOptionId: state.thinkingOptionId,
runtimeInfo: state.runtimeInfo,
features: state.features,
lastError: state.lastError ?? null, lastError: state.lastError ?? null,
projectPlacement, projectPlacement,
}; };
@@ -546,18 +561,7 @@ function AgentPanelBody({
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
); );
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => { useShallow((state) => selectChatAgentState(state, serverId, agentId)),
const agent = resolveChatAgentFromSession(state, serverId, agentId);
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
workspaceId: agent?.workspaceId,
lastError: agent?.lastError ?? null,
archivedAt: agent?.archivedAt ?? null,
};
}),
); );
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" }); const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0); const lookupAttemptTokenRef = useRef(0);
@@ -644,9 +648,16 @@ function AgentPanelBody({
? { ? {
serverId: agentState.serverId, serverId: agentState.serverId,
id: agentState.id, id: agentState.id,
provider: agentState.provider,
status: agentState.status, status: agentState.status,
cwd: agentState.cwd, cwd: agentState.cwd,
workspaceId: agentState.workspaceId, workspaceId: agentState.workspaceId,
capabilities: agentState.capabilities,
currentModeId: agentState.currentModeId,
model: agentState.model,
thinkingOptionId: agentState.thinkingOptionId,
runtimeInfo: agentState.runtimeInfo,
features: agentState.features,
lastError: agentState.lastError ?? null, lastError: agentState.lastError ?? null,
projectPlacement, projectPlacement,
} }
@@ -1378,7 +1389,10 @@ function ActiveAgentComposer({
cwd, cwd,
workspaceId, workspaceId,
}); });
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); const attachmentScopeKeys = useMemo(
() => [workspaceAttachmentScopeKey],
[workspaceAttachmentScopeKey],
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback( const handleOpenWorkspaceAttachment = useCallback(
@@ -1479,7 +1493,7 @@ function ActiveAgentComposer({
value={agentInputDraft.text} value={agentInputDraft.text}
onChangeText={agentInputDraft.setText} onChangeText={agentInputDraft.setText}
attachments={agentInputDraft.attachments} attachments={agentInputDraft.attachments}
workspaceAttachments={workspaceAttachments} attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={agentInputDraft.setAttachments} onChangeAttachments={agentInputDraft.setAttachments}
cwd={cwd} cwd={cwd}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
describe("remapDraftCwdToWorkspace", () => {
it("preserves a Windows subdirectory when source path casing differs", () => {
expect(
remapDraftCwdToWorkspace({
cwd: "c:\\Repo\\packages\\app",
sourceDirectory: "C:\\Repo",
workspaceDirectory: "D:\\Worktrees\\fork",
}),
).toBe("D:\\Worktrees\\fork\\packages\\app");
});
it("falls back to the workspace root when the cwd is outside the source directory", () => {
expect(
remapDraftCwdToWorkspace({
cwd: "/other/repo/packages/app",
sourceDirectory: "/repo",
workspaceDirectory: "/worktrees/fork",
}),
).toBe("/worktrees/fork");
});
});
describe("getWorkspaceNamingAttachments", () => {
it("removes full chat history from workspace naming context", () => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Long prior conversation",
} satisfies AgentAttachment;
const prContext = {
type: "github_pr",
mimeType: "application/github-pr",
number: 1788,
title: "Fork assistant turns into new drafts",
url: "https://github.com/getpaseo/paseo/pull/1788",
} satisfies AgentAttachment;
expect(getWorkspaceNamingAttachments([chatHistory, prContext])).toEqual([prContext]);
});
});

View File

@@ -0,0 +1,50 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
function isLikelyWindowsPath(path: string): boolean {
return /^[a-zA-Z]:\//.test(path);
}
function isChatHistoryTextAttachment(attachment: AgentAttachment): boolean {
return attachment.type === "text" && attachment.contextKind === "chat_history";
}
export function getWorkspaceNamingAttachments(
attachments: readonly AgentAttachment[],
): AgentAttachment[] {
return attachments.filter((attachment) => !isChatHistoryTextAttachment(attachment));
}
export function remapDraftCwdToWorkspace(input: {
cwd: string;
sourceDirectory?: string | null;
workspaceDirectory: string;
}): string {
const cwd = input.cwd.trim();
const sourceDirectory = input.sourceDirectory?.trim();
const workspaceDirectory = input.workspaceDirectory.trim();
if (!cwd || !sourceDirectory) {
return workspaceDirectory;
}
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedSource = sourceDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
const compareCaseInsensitively =
isLikelyWindowsPath(normalizedCwd) || isLikelyWindowsPath(normalizedSource);
const comparableCwd = compareCaseInsensitively ? normalizedCwd.toLowerCase() : normalizedCwd;
const comparableSource = compareCaseInsensitively
? normalizedSource.toLowerCase()
: normalizedSource;
if (comparableCwd === comparableSource) {
return workspaceDirectory;
}
const relativePath = comparableCwd.startsWith(`${comparableSource}/`)
? normalizedCwd.slice(normalizedSource.length + 1)
: "";
if (!relativePath) {
return workspaceDirectory;
}
const separator =
workspaceDirectory.includes("\\") && !workspaceDirectory.includes("/") ? "\\" : "/";
return [workspaceDirectory.replace(/[\\/]+$/, ""), ...relativePath.split("/")]
.filter(Boolean)
.join(separator);
}

View File

@@ -36,10 +36,14 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import {
useWorkspaceDraftSubmissionStore,
type PendingWorkspaceDraftSetup,
} from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
@@ -57,11 +61,17 @@ import {
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
import { import {
pickerItemToCheckoutRequest, pickerItemToCheckoutRequest,
type PickerCheckoutRequest, type PickerCheckoutRequest,
@@ -82,6 +92,37 @@ function resolveCheckoutRequest(
}; };
} }
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
selectedServerId: string;
}): boolean {
const normalizedDraftId = input.draftId?.trim() ?? "";
return useCreateFlowStore((state) =>
isActiveCreateFlowForDraft({
draftId: normalizedDraftId,
serverId: input.selectedServerId,
pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null,
}),
);
}
function resolveVisibleDraftContextScopeKeys(input: {
isDraftHandoffActive: boolean;
draftContextScopeKey: string;
}): readonly string[] {
if (input.isDraftHandoffActive || !input.draftContextScopeKey) {
return [];
}
return [input.draftContextScopeKey];
}
function isNewWorkspacePending(input: {
pendingAction: "chat" | "empty" | null;
isDraftHandoffActive: boolean;
}): boolean {
return input.pendingAction !== null || input.isDraftHandoffActive;
}
function buildFirstAgentContext(input: { function buildFirstAgentContext(input: {
prompt: string; prompt: string;
attachments: AgentAttachment[]; attachments: AgentAttachment[];
@@ -102,6 +143,7 @@ interface NewWorkspaceScreenProps {
sourceDirectory?: string; sourceDirectory?: string;
projectId?: string; projectId?: string;
displayName?: string; displayName?: string;
draftId?: string;
} }
interface PickerOptionData { interface PickerOptionData {
@@ -714,6 +756,18 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
return [styles.content, styles.contentCentered]; return [styles.content, styles.contentCentered];
} }
function buildNewWorkspaceDraftKey(input: {
selectedServerId: string;
selectedSourceDirectory: string | null;
draftId?: string;
}): string {
const explicitDraftId = input.draftId?.trim();
if (explicitDraftId) {
return `new-workspace:draft:${explicitDraftId}`;
}
return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`;
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null { function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null; if (!selection) return null;
return selection.item; return selection.item;
@@ -733,12 +787,28 @@ function normalizeBranchDetails(
interface SubmitDraftInput { interface SubmitDraftInput {
serverId: string; serverId: string;
draftKey: string; draftKey: string;
draftId?: string;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string; workspaceId: string;
workspaceDirectory: string; workspaceDirectory: string;
text: string; text: string;
attachments: ComposerAttachment[]; attachments: ComposerAttachment[];
provider: AgentProvider; provider: AgentProvider;
composerState: NonNullable<ReturnType<typeof useAgentInputDraft>["composerState"]>; composerState: NewWorkspaceComposerState;
}
type NewWorkspaceComposerState = NonNullable<
ReturnType<typeof useAgentInputDraft>["composerState"]
>;
interface WorkspaceDraftSubmissionConfig {
cwd: string;
provider: AgentProvider;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget;
} }
async function createAndMergeWorkspace(input: { async function createAndMergeWorkspace(input: {
@@ -820,6 +890,7 @@ async function createMultiplicityWorkspace(input: {
interface CreateChatAgentInput { interface CreateChatAgentInput {
payload: MessagePayload; payload: MessagePayload;
composerState: ReturnType<typeof useAgentInputDraft>["composerState"]; composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
ensureWorkspace: (input: { ensureWorkspace: (input: {
cwd: string; cwd: string;
prompt: string; prompt: string;
@@ -828,12 +899,67 @@ interface CreateChatAgentInput {
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>; }) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string; serverId: string;
draftKey: string; draftKey: string;
draftId?: string;
labels: { labels: {
composerStateRequired: string; composerStateRequired: string;
selectModel: string; selectModel: string;
}; };
} }
function buildWorkspaceDraftSetupFromComposer(input: {
cwd: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup {
return {
provider: input.provider,
cwd: input.cwd,
modeId: input.composerState.selectedMode || null,
model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
featureValues: input.composerState.featureValues ?? {},
};
}
function buildWorkspaceDraftSetupForCreatedWorkspace(input: {
forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup | undefined {
if (!input.forkDraftSetup) {
return undefined;
}
return buildWorkspaceDraftSetupFromComposer({
cwd: remapDraftCwdToWorkspace({
cwd: input.forkDraftSetup.setup.cwd,
sourceDirectory: input.forkDraftSetup.sourceDirectory,
workspaceDirectory: input.workspaceDirectory,
}),
provider: input.provider,
composerState: input.composerState,
});
}
function buildComposerInitialValues(input: {
workingDir: string | undefined;
initialSetup?: WorkspaceDraftTabSetup | null;
}): CreateAgentInitialValues | undefined {
if (input.initialSetup) {
return {
workingDir: input.workingDir ?? input.initialSetup.cwd,
provider: input.initialSetup.provider,
modeId: input.initialSetup.modeId,
model: input.initialSetup.model,
thinkingOptionId: input.initialSetup.thinkingOptionId,
};
}
if (input.workingDir) {
return { workingDir: input.workingDir };
}
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> { async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input; const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
const { text, attachments, cwd } = payload; const { text, attachments, cwd } = payload;
@@ -845,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
throw new Error(input.labels.selectModel); throw new Error(input.labels.selectModel);
} }
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
const ensuredWorkspace = await ensureWorkspace({ const ensuredWorkspace = await ensureWorkspace({
cwd, cwd,
prompt: text, prompt: text,
attachments: reviewAttachments, attachments: workspaceNamingAttachments,
withInitialAgent: true, withInitialAgent: true,
}); });
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
forkDraftSetup: input.forkDraftSetup,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
provider,
composerState,
});
submitWorkspaceDraft({ submitWorkspaceDraft({
serverId, serverId,
draftKey, draftKey,
draftId: input.draftId,
initialSetup,
workspaceId: ensuredWorkspace.id, workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory, workspaceDirectory: ensuredWorkspace.workspaceDirectory,
text, text,
@@ -868,12 +1003,14 @@ function buildComposerConfig(input: {
isConnected: boolean; isConnected: boolean;
workspaceDirectory: string | null; workspaceDirectory: string | null;
sourceDirectory: string | null; sourceDirectory: string | null;
initialSetup?: WorkspaceDraftTabSetup | null;
}): Parameters<typeof useAgentInputDraft>[0]["composer"] { }): Parameters<typeof useAgentInputDraft>[0]["composer"] {
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input; const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input;
const workingDir = workspaceDirectory || sourceDirectory || undefined; const workingDir = workspaceDirectory || sourceDirectory || undefined;
return { return {
initialServerId: serverId || null, initialServerId: serverId || null,
initialValues: workingDir ? { workingDir } : undefined, initialValues: buildComposerInitialValues({ workingDir, initialSetup }),
initialFeatureValues: initialSetup?.featureValues,
isVisible: true, isVisible: true,
onlineServerIds: isConnected && serverId ? [serverId] : [], onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workingDir, lockedWorkingDir: workingDir,
@@ -921,21 +1058,72 @@ function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttach
return [dismissedPrNumbers, setDismissedPrNumbers] as const; return [dismissedPrNumbers, setDismissedPrNumbers] as const;
} }
function usePendingWorkspaceDraftSetup(
draftId: string | undefined,
): PendingWorkspaceDraftSetup | null {
const normalizedDraftId = draftId?.trim() ?? "";
return useWorkspaceDraftSubmissionStore((state) => {
if (!normalizedDraftId) {
return null;
}
return state.setupByDraftId[normalizedDraftId] ?? null;
});
}
function resolveWorkspaceDraftSubmissionConfig(input: {
draftId: string;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
initialSetup?: WorkspaceDraftTabSetup;
}): WorkspaceDraftSubmissionConfig {
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
if (initialSetup) {
return {
cwd: initialSetup.cwd,
provider: initialSetup.provider,
modeId: initialSetup.modeId,
model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId,
featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup },
};
}
return {
cwd: workspaceDirectory,
provider,
modeId: composerState.selectedMode || null,
model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
featureValues: composerState.featureValues,
target: { kind: "draft", draftId },
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): void { function submitWorkspaceDraft(input: SubmitDraftInput): void {
const { const {
serverId, serverId,
draftKey, draftKey,
draftId: draftIdInput,
workspaceId, workspaceId,
workspaceDirectory, workspaceDirectory,
text, text,
attachments, attachments,
provider, provider,
composerState, composerState,
initialSetup,
} = input; } = input;
const draftId = generateDraftId(); const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = generateMessageId(); const clientMessageId = generateMessageId();
const timestamp = Date.now(); const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments); const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const submission = resolveWorkspaceDraftSubmissionConfig({
draftId,
workspaceDirectory,
provider,
composerState,
initialSetup,
});
useCreateFlowStore.getState().setPending({ useCreateFlowStore.getState().setPending({
serverId, serverId,
draftId, draftId,
@@ -953,23 +1141,21 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
draftId, draftId,
text: text.trim(), text: text.trim(),
attachments, attachments,
cwd: workspaceDirectory, cwd: submission.cwd,
provider, provider: submission.provider,
clientMessageId, clientMessageId,
timestamp, timestamp,
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), ...(submission.modeId ? { modeId: submission.modeId } : {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), ...(submission.model ? { model: submission.model } : {}),
...(composerState.effectiveThinkingOptionId ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
? { thinkingOptionId: composerState.effectiveThinkingOptionId } ...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
: {}),
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
allowEmptyText: true, allowEmptyText: true,
}); });
navigateToPreparedWorkspaceTab({ navigateToPreparedWorkspaceTab({
serverId, serverId,
workspaceId, workspaceId,
currentPathname: "/new", currentPathname: "/new",
target: { kind: "draft", draftId }, target: submission.target,
}); });
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
} }
@@ -1360,6 +1546,7 @@ export function NewWorkspaceScreen({
sourceDirectory: sourceDirectoryProp, sourceDirectory: sourceDirectoryProp,
projectId, projectId,
displayName: displayNameProp, displayName: displayNameProp,
draftId,
}: NewWorkspaceScreenProps) { }: NewWorkspaceScreenProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -1396,6 +1583,7 @@ export function NewWorkspaceScreen({
const projectPickerAnchorRef = useRef<View>(null); const projectPickerAnchorRef = useRef<View>(null);
const isolationPickerAnchorRef = useRef<View>(null); const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null); const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
useEffect(() => { useEffect(() => {
const trimmed = pickerSearchQuery.trim(); const trimmed = pickerSearchQuery.trim();
@@ -1404,7 +1592,7 @@ export function NewWorkspaceScreen({
}, [pickerSearchQuery]); }, [pickerSearchQuery]);
const workspace = createdWorkspace; const workspace = createdWorkspace;
const isPending = pendingAction !== null; const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const client = useHostRuntimeClient(selectedServerId); const client = useHostRuntimeClient(selectedServerId);
const isConnected = useHostRuntimeIsConnected(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId);
const { const {
@@ -1441,7 +1629,17 @@ export function NewWorkspaceScreen({
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
projects: projectIconTargets, projects: projectIconTargets,
}); });
const draftKey = `new-workspace:${selectedServerId}:${selectedSourceDirectory ?? "choose-project"}`; const draftKey = buildNewWorkspaceDraftKey({
selectedServerId,
selectedSourceDirectory,
draftId,
});
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo(
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
[draftContextScopeKey, isDraftHandoffActive],
);
const chatDraft = useAgentInputDraft({ const chatDraft = useAgentInputDraft({
draftKey, draftKey,
composer: buildComposerConfig({ composer: buildComposerConfig({
@@ -1449,6 +1647,7 @@ export function NewWorkspaceScreen({
isConnected, isConnected,
workspaceDirectory: workspace?.workspaceDirectory ?? null, workspaceDirectory: workspace?.workspaceDirectory ?? null,
sourceDirectory: selectedSourceDirectory, sourceDirectory: selectedSourceDirectory,
initialSetup: forkDraftSetup?.setup,
}), }),
}); });
const composerState = chatDraft.composerState; const composerState = chatDraft.composerState;
@@ -1793,9 +1992,11 @@ export function NewWorkspaceScreen({
await runCreateChatAgent({ await runCreateChatAgent({
payload, payload,
composerState, composerState,
forkDraftSetup,
ensureWorkspace, ensureWorkspace,
serverId: selectedServerId, serverId: selectedServerId,
draftKey, draftKey,
draftId,
labels: { labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"), composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"), selectModel: t("newWorkspace.errors.selectModel"),
@@ -1808,7 +2009,7 @@ export function NewWorkspaceScreen({
toast.error(message); toast.error(message);
} }
}, },
[composerState, draftKey, ensureWorkspace, selectedServerId, t, toast], [composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast],
); );
const renderPickerOption = useCallback( const renderPickerOption = useCallback(
@@ -1987,12 +2188,13 @@ export function NewWorkspaceScreen({
submitButtonAccessibilityLabel={t("newWorkspace.create")} submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit" submitButtonTestID="workspace-create-submit"
submitIcon="return" submitIcon="return"
isSubmitLoading={pendingAction !== null} isSubmitLoading={isPending}
submitBehavior="preserve-and-lock" submitBehavior="preserve-and-lock"
blurOnSubmit={true} blurOnSubmit={true}
value={chatDraft.text} value={chatDraft.text}
onChangeText={chatDraft.setText} onChangeText={chatDraft.setText}
attachments={chatDraft.attachments} attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments} onChangeAttachments={chatDraft.setAttachments}
cwd={selectedSourceDirectory ?? ""} cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft} clearDraft={handleClearDraft}

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { useCreateFlowStore } from "./create-flow-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "./create-flow-store";
describe("create-flow-store", () => { describe("create-flow-store", () => {
beforeEach(() => { beforeEach(() => {
@@ -88,4 +88,40 @@ describe("create-flow-store", () => {
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined(); expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
}); });
it("matches only active pending create flows for a draft and server", () => {
useCreateFlowStore.getState().setPending({
draftId: "draft-1",
serverId: "server-1",
agentId: null,
clientMessageId: "msg-1",
text: "hello",
timestamp: Date.now(),
});
const pending = useCreateFlowStore.getState().pendingByDraftId["draft-1"];
expect(
isActiveCreateFlowForDraft({
pending,
serverId: "server-1",
draftId: " draft-1 ",
}),
).toBe(true);
expect(
isActiveCreateFlowForDraft({
pending,
serverId: "server-2",
draftId: "draft-1",
}),
).toBe(false);
useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "sent" });
expect(
isActiveCreateFlowForDraft({
pending: useCreateFlowStore.getState().pendingByDraftId["draft-1"],
serverId: "server-1",
draftId: "draft-1",
}),
).toBe(false);
});
}); });

View File

@@ -17,6 +17,20 @@ export interface PendingCreateAttempt {
attachments?: AgentAttachment[]; attachments?: AgentAttachment[];
} }
export function isActiveCreateFlowForDraft(input: {
pending: PendingCreateAttempt | null | undefined;
serverId: string;
draftId: string | null | undefined;
}): boolean {
const draftId = input.draftId?.trim();
return Boolean(
draftId &&
input.pending?.draftId === draftId &&
input.pending.serverId === input.serverId &&
input.pending.lifecycle === "active",
);
}
interface CreateFlowState { interface CreateFlowState {
pendingByDraftId: Record<string, PendingCreateAttempt>; pendingByDraftId: Record<string, PendingCreateAttempt>;
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void; setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;

View File

@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export interface PendingWorkspaceDraftSubmission { export interface PendingWorkspaceDraftSubmission {
serverId: string; serverId: string;
@@ -19,9 +20,21 @@ export interface PendingWorkspaceDraftSubmission {
allowEmptyText?: boolean; allowEmptyText?: boolean;
} }
export interface PendingWorkspaceDraftSetup {
setup: WorkspaceDraftTabSetup;
sourceDirectory?: string | null;
}
interface WorkspaceDraftSubmissionState { interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>; pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void; setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
setup: WorkspaceDraftTabSetup;
sourceDirectory?: string | null;
}) => void;
clearDraftSetup: (input: { draftId: string }) => void;
consumePending: (input: { consumePending: (input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -40,9 +53,14 @@ function matchesPendingSubmission(
); );
} }
function normalizeDraftId(draftId: string): string {
return draftId.trim();
}
export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>( export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>(
(set, get) => ({ (set, get) => ({
pendingByDraftId: {}, pendingByDraftId: {},
setupByDraftId: {},
setPending: (submission) => setPending: (submission) =>
set((state) => ({ set((state) => ({
pendingByDraftId: { pendingByDraftId: {
@@ -50,6 +68,25 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
[submission.draftId]: submission, [submission.draftId]: submission,
}, },
})), })),
setDraftSetup: ({ draftId, setup, sourceDirectory }) => {
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => ({
setupByDraftId: {
...state.setupByDraftId,
[normalizedDraftId]: { setup, sourceDirectory: sourceDirectory ?? null },
},
}));
},
clearDraftSetup: ({ draftId }) => {
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
if (!state.setupByDraftId[normalizedDraftId]) return state;
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId };
});
},
consumePending: (input) => { consumePending: (input) => {
const pending = get().pendingByDraftId[input.draftId]; const pending = get().pendingByDraftId[input.draftId];
if (!matchesPendingSubmission(pending, input)) { if (!matchesPendingSubmission(pending, input)) {

View File

@@ -986,6 +986,7 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
groupId: blockGroupId, groupId: blockGroupId,
blockIndex: firstBlockIndex + offset, blockIndex: firstBlockIndex + offset,
}), }),
...(activeItem.messageId ? { messageId: activeItem.messageId } : {}),
blockGroupId, blockGroupId,
blockIndex: firstBlockIndex + offset, blockIndex: firstBlockIndex + offset,
text: block, text: block,

View File

@@ -238,6 +238,16 @@ describe("global routes", () => {
}), }),
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1"); ).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
}); });
it("buildNewWorkspaceRoute carries a draft context id", () => {
expect(
buildNewWorkspaceRoute({
serverId: "local",
sourceDirectory: "/repo/project",
draftId: "draft-1",
}),
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&draftId=draft-1");
});
}); });
describe("host settings section slugs", () => { describe("host settings section slugs", () => {

View File

@@ -424,6 +424,7 @@ interface NewWorkspaceRouteOptions {
sourceDirectory?: string; sourceDirectory?: string;
displayName?: string; displayName?: string;
projectId?: string; projectId?: string;
draftId?: string;
} }
function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string { function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
@@ -441,6 +442,9 @@ function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
if (options.projectId) { if (options.projectId) {
params.set("projectId", options.projectId); params.set("projectId", options.projectId);
} }
if (options.draftId) {
params.set("draftId", options.draftId);
}
return params.toString(); return params.toString();
} }

View File

@@ -26,6 +26,7 @@ import type {
FileUploadResponse, FileUploadResponse,
FileExplorerResponse, FileExplorerResponse,
FetchAgentTimelineResponseMessage, FetchAgentTimelineResponseMessage,
AgentForkContextResponseMessage,
GitSetupOptions, GitSetupOptions,
CheckoutStatusResponse, CheckoutStatusResponse,
CheckoutCommitResponse, CheckoutCommitResponse,
@@ -456,6 +457,7 @@ type ScheduleUpdatePayload = Extract<
{ type: "schedule/update/response" } { type: "schedule/update/response" }
>["payload"]; >["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type AgentForkContextPayload = AgentForkContextResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
@@ -502,6 +504,10 @@ function normalizeListCommandsOptions(
} }
return { agentId: input, ...legacyOptions }; return { agentId: input, ...legacyOptions };
} }
export interface AgentForkContextOptions {
boundaryMessageId?: string;
requestId?: string;
}
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>; type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>; type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
@@ -2405,6 +2411,41 @@ export class DaemonClient {
return payload; return payload;
} }
async buildAgentForkContext(
agentId: string,
options: AgentForkContextOptions = {},
): Promise<AgentForkContextPayload> {
const resolvedRequestId = this.createRequestId(options.requestId);
const message = SessionInboundMessageSchema.parse({
type: "agent.fork_context.request",
agentId,
requestId: resolvedRequestId,
...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}),
});
const payload = await this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent.fork_context.response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
if (payload.error) {
throw new Error(payload.error);
}
return payload;
}
// ============================================================================ // ============================================================================
// Agent Interaction // Agent Interaction
// ============================================================================ // ============================================================================

View File

@@ -226,6 +226,47 @@ describe("shared messages attachments", () => {
]); ]);
}); });
it("keeps known text attachment context kinds and ignores future ones", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-text-context",
agentId: "agent-1",
text: "Continue",
attachments: [
{
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Earlier context",
},
{
type: "text",
mimeType: "text/plain",
contextKind: "future_context",
title: "Future context",
text: "Future client hint",
},
],
});
expect(parsed.attachments).toEqual([
{
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Earlier context",
},
{
type: "text",
mimeType: "text/plain",
title: "Future context",
text: "Future client hint",
},
]);
});
it("keeps known firstAgentContext attachments and drops unknown ones", () => { it("keeps known firstAgentContext attachments and drops unknown ones", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({ const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request", type: "create_paseo_worktree_request",

View File

@@ -843,12 +843,18 @@ export const GitHubIssueAttachmentSchema = z.object({
body: z.string().nullable().optional(), body: z.string().nullable().optional(),
}); });
export const TextAttachmentSchema = z.object({ export const TextAttachmentSchema = z
type: z.literal("text"), .object({
mimeType: z.literal("text/plain"), type: z.literal("text"),
title: z.string().nullable().optional(), mimeType: z.literal("text/plain"),
text: z.string(), contextKind: z.string().optional(),
}); title: z.string().nullable().optional(),
text: z.string(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
...(contextKind === "chat_history" ? { contextKind } : {}),
}));
export const ReviewAttachmentContextLineSchema = z.object({ export const ReviewAttachmentContextLineSchema = z.object({
oldLineNumber: z.number().int().positive().nullable(), oldLineNumber: z.number().int().positive().nullable(),
@@ -1274,6 +1280,13 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
projection: z.enum(["projected", "canonical"]).optional(), projection: z.enum(["projected", "canonical"]).optional(),
}); });
export const AgentForkContextRequestMessageSchema = z.object({
type: z.literal("agent.fork_context.request"),
agentId: z.string(),
boundaryMessageId: z.string().optional(),
requestId: z.string(),
});
export const SetAgentModeRequestMessageSchema = z.object({ export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"), type: z.literal("set_agent_mode_request"),
agentId: z.string(), agentId: z.string(),
@@ -2060,6 +2073,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
RestartServerRequestMessageSchema, RestartServerRequestMessageSchema,
DaemonUpdateRequestMessageSchema, DaemonUpdateRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema, FetchAgentTimelineRequestMessageSchema,
AgentForkContextRequestMessageSchema,
SetAgentModeRequestMessageSchema, SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema, SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema, SetAgentThinkingRequestMessageSchema,
@@ -2335,6 +2349,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonDiagnostics: z.boolean().optional(), daemonDiagnostics: z.boolean().optional(),
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
daemonSelfUpdate: z.boolean().optional(), daemonSelfUpdate: z.boolean().optional(),
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: z.boolean().optional(),
}) })
.optional(), .optional(),
}) })
@@ -2926,6 +2942,18 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
}), }),
}); });
export const AgentForkContextResponseMessageSchema = z.object({
type: z.literal("agent.fork_context.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
attachment: TextAttachmentSchema.nullable(),
itemCount: z.number().int().nonnegative(),
boundaryMessageId: z.string().nullable(),
error: z.string().nullable(),
}),
});
export const CancelAgentResponseMessageSchema = z.object({ export const CancelAgentResponseMessageSchema = z.object({
type: z.literal("cancel_agent_response"), type: z.literal("cancel_agent_response"),
payload: z.object({ payload: z.object({
@@ -4158,6 +4186,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArchiveWorkspaceResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema, FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema, FetchAgentTimelineResponseMessageSchema,
AgentForkContextResponseMessageSchema,
CancelAgentResponseMessageSchema, CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema, ClearAgentAttentionResponseMessageSchema,
WorkspaceCreateResponseSchema, WorkspaceCreateResponseSchema,
@@ -4320,6 +4349,7 @@ export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessage
export type FetchAgentTimelineResponseMessage = z.infer< export type FetchAgentTimelineResponseMessage = z.infer<
typeof FetchAgentTimelineResponseMessageSchema typeof FetchAgentTimelineResponseMessageSchema
>; >;
export type AgentForkContextResponseMessage = z.infer<typeof AgentForkContextResponseMessageSchema>;
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>; export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>; export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>; export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
@@ -4410,6 +4440,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
>; >;
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>; export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>; export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>; export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>; export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
export type DictationStreamStartMessage = z.infer<typeof DictationStreamStartMessageSchema>; export type DictationStreamStartMessage = z.infer<typeof DictationStreamStartMessageSchema>;

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { curateAgentActivity } from "./activity-curator.js"; import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
function toolCallItem(params: { function toolCallItem(params: {
callId: string; callId: string;
@@ -29,6 +30,14 @@ function toolCallItem(params: {
}; };
} }
function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
return {
seq,
timestamp: `2026-06-28T00:00:${String(seq).padStart(2, "0")}.000Z`,
item,
};
}
describe("curateAgentActivity", () => { describe("curateAgentActivity", () => {
it("renders user/assistant/reasoning entries", () => { it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [ const timeline: AgentTimelineItem[] = [
@@ -217,4 +226,140 @@ second line'`,
it("returns a default message when timeline is empty", () => { it("returns a default message when timeline is empty", () => {
expect(curateAgentActivity([])).toBe("No activity to display."); expect(curateAgentActivity([])).toBe("No activity to display.");
}); });
it("builds fork context from user messages, assistant messages, and tool summaries", () => {
const result = buildAgentForkContextAttachment({
agentTitle: "Source Agent",
cwd: "/repo",
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Ship the thing", messageId: "user-1" }),
row(2, { type: "reasoning", text: "private chain of thought" }),
row(
3,
toolCallItem({
callId: "read-1",
name: "read_file",
detail: {
type: "read",
filePath: "src/index.ts",
content: "console.log('hi')",
},
}),
),
row(
4,
toolCallItem({
callId: "external-1",
name: "paseo__create_agent",
input: { initialPrompt: "do not include raw external tool input" },
}),
),
row(5, {
type: "assistant_message",
text: "Done.",
messageId: "assistant-1",
}),
row(6, {
type: "assistant_message",
text: "Later answer.",
messageId: "assistant-2",
}),
],
});
expect(result.boundaryMessageId).toBe("assistant-1");
expect(result.attachment).toMatchObject({
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
});
expect(result.attachment.text).toContain("Source agent: Source Agent");
expect(result.attachment.text).toContain("Source directory: /repo");
expect(result.attachment.text).toContain("[User] Ship the thing");
expect(result.attachment.text).toContain("[Read] src/index.ts");
expect(result.attachment.text).toContain("[paseo__create_agent]");
expect(result.attachment.text).toContain("[Assistant] Done.");
expect(result.attachment.text).not.toContain("private chain of thought");
expect(result.attachment.text).not.toContain("do not include raw external tool input");
expect(result.attachment.text).not.toContain("Later answer.");
});
it("does not cap fork context to the generic recent activity limit", () => {
const messageRows = Array.from({ length: 25 }, (_, index) =>
row(index + 1, {
type: "user_message",
text: `Message ${index + 1}`,
messageId: `user-${index + 1}`,
}),
);
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
...messageRows,
row(26, {
type: "assistant_message",
text: "Done.",
messageId: "assistant-1",
}),
],
});
expect(result.itemCount).toBe(26);
expect(result.attachment.text).toContain("[User] Message 1");
expect(result.attachment.text).toContain("[User] Message 25");
expect(result.attachment.text).toContain("[Assistant] Done.");
});
it("selects the fork boundary before collapsing later tool updates", () => {
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
row(
2,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "running",
detail: {
type: "plain_text",
label: "before boundary",
},
}),
),
row(3, {
type: "assistant_message",
text: "Partial result.",
messageId: "assistant-1",
}),
row(
4,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "completed",
detail: {
type: "plain_text",
label: "after boundary",
},
}),
),
],
});
expect(result.attachment.text).toContain("[Terminal] before boundary");
expect(result.attachment.text).toContain("[Assistant] Partial result.");
expect(result.attachment.text).not.toContain("after boundary");
});
it("rejects missing assistant boundaries instead of silently using the wrong context", () => {
expect(() =>
buildAgentForkContextAttachment({
boundaryMessageId: "missing",
rows: [row(1, { type: "assistant_message", text: "Done.", messageId: "assistant-1" })],
}),
).toThrow("Selected assistant message is no longer available.");
});
}); });

View File

@@ -1,4 +1,6 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization"; import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { projectTimelineRows } from "./timeline-projection.js"; import { projectTimelineRows } from "./timeline-projection.js";
@@ -10,12 +12,16 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
interface ActivityCuratorOptions { interface ActivityCuratorOptions {
maxItems?: number; maxItems?: number;
labelAssistantMessages?: boolean; labelAssistantMessages?: boolean;
includeKinds?: readonly AgentTimelineItem["type"][];
includeExternalToolInput?: boolean;
} }
interface ActivityEntry { interface ActivityEntry {
text: string; text: string;
} }
type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>;
function appendText(buffer: string, text: string): string { function appendText(buffer: string, text: string): string {
const normalized = text.trim(); const normalized = text.trim();
if (!normalized) { if (!normalized) {
@@ -95,24 +101,56 @@ function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineI
return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item); return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item);
} }
function curateAgentActivityEntries( function shouldIncludeItem(item: AgentTimelineItem, options?: ActivityCuratorOptions): boolean {
timeline: AgentTimelineItem[], if (!options?.includeKinds) {
return true;
}
return options.includeKinds.includes(item.type);
}
function formatToolCallEntry(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
options?: ActivityCuratorOptions,
): ActivityEntry {
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
const display = buildToolCallDisplayModel({
name: item.name,
status: item.status,
error: item.error,
detail: item.detail,
metadata: item.metadata,
});
const displayName = display.displayName;
const summary = formatToolSummary(display.summary);
if (
(options?.includeExternalToolInput ?? true) &&
isLikelyExternalToolName(item.name) &&
inputJson
) {
return activityEntry(`[${displayName}] ${inputJson}`);
}
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
}
function curateProjectedActivityEntries(
items: readonly AgentTimelineItem[],
options?: ActivityCuratorOptions, options?: ActivityCuratorOptions,
): ActivityEntry[] { ): ActivityEntry[] {
if (timeline.length === 0) { if (items.length === 0) {
return []; return [];
} }
const collapsed = projectForCuration(timeline);
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS; const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
const recentItems = const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items;
maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed;
const entries: ActivityEntry[] = []; const entries: ActivityEntry[] = [];
const buffers = { message: "", thought: "" }; const buffers = { message: "", thought: "" };
for (const item of recentItems) { for (const item of recentItems) {
if (!shouldIncludeItem(item, options)) {
continue;
}
switch (item.type) { switch (item.type) {
case "user_message": case "user_message":
flushBuffers(entries, buffers, options); flushBuffers(entries, buffers, options);
@@ -126,25 +164,7 @@ function curateAgentActivityEntries(
break; break;
case "tool_call": { case "tool_call": {
flushBuffers(entries, buffers, options); flushBuffers(entries, buffers, options);
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail)); entries.push(formatToolCallEntry(item, options));
const display = buildToolCallDisplayModel({
name: item.name,
status: item.status,
error: item.error,
detail: item.detail,
metadata: item.metadata,
});
const displayName = display.displayName;
const summary = formatToolSummary(display.summary);
if (isLikelyExternalToolName(item.name) && inputJson) {
entries.push(activityEntry(`[${displayName}] ${inputJson}`));
break;
}
if (summary) {
entries.push(activityEntry(`[${displayName}] ${summary}`));
} else {
entries.push(activityEntry(`[${displayName}]`));
}
break; break;
} }
case "todo": case "todo":
@@ -172,6 +192,14 @@ function curateAgentActivityEntries(
return entries; return entries;
} }
function curateAgentActivityEntries(
timeline: AgentTimelineItem[],
options?: ActivityCuratorOptions,
): ActivityEntry[] {
const collapsed = projectForCuration(timeline);
return curateProjectedActivityEntries(collapsed, options);
}
/** /**
* Convert normalized agent timeline items into a concise text summary. * Convert normalized agent timeline items into a concise text summary.
*/ */
@@ -184,3 +212,90 @@ export function curateAgentActivity(
? entries.map((entry) => entry.text).join("\n") ? entries.map((entry) => entry.text).join("\n")
: "No activity to display."; : "No activity to display.";
} }
function selectForkContextRows(input: {
rows: readonly AgentTimelineRow[];
boundaryMessageId?: string | null;
}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } {
const boundaryMessageId = input.boundaryMessageId?.trim() || null;
if (!boundaryMessageId) {
const projected = projectTimelineRows({ rows: input.rows, mode: "projected" });
return {
items: projected.map((entry) => entry.item),
boundaryMessageId: null,
};
}
const boundaryIndex = input.rows.findLastIndex(
(row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId,
);
if (boundaryIndex < 0) {
throw new Error("Selected assistant message is no longer available.");
}
const selectedRows = input.rows.slice(0, boundaryIndex + 1);
const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" });
return {
items: projected.map((entry) => entry.item),
boundaryMessageId,
};
}
function trimContextMetadata(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function buildForkContextText(input: {
body: string;
agentTitle?: string | null;
cwd?: string | null;
}): string {
const header = ["Chat history from a previous Paseo agent."];
const agentTitle = trimContextMetadata(input.agentTitle);
const cwd = trimContextMetadata(input.cwd);
if (agentTitle) {
header.push(`Source agent: ${agentTitle}`);
}
if (cwd) {
header.push(`Source directory: ${cwd}`);
}
return `${header.join("\n")}\n\n${input.body}`;
}
export function buildAgentForkContextAttachment(input: {
rows: readonly AgentTimelineRow[];
boundaryMessageId?: string | null;
agentTitle?: string | null;
cwd?: string | null;
}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } {
const selected = selectForkContextRows({
rows: input.rows,
boundaryMessageId: input.boundaryMessageId,
});
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
labelAssistantMessages: true,
includeKinds: ["user_message", "assistant_message", "tool_call"],
includeExternalToolInput: false,
});
const body =
entries.length > 0
? entries.map((entry) => entry.text).join("\n")
: "No chat history to display.";
return {
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: buildForkContextText({
body,
agentTitle: input.agentTitle,
cwd: input.cwd,
}),
},
itemCount: selected.items.length,
boundaryMessageId: selected.boundaryMessageId,
};
}

View File

@@ -97,6 +97,7 @@ const MODELS: AgentModelDefinition[] = [
interface ActiveTurn { interface ActiveTurn {
turnId: string; turnId: string;
assistantMessageId: string;
prompt: AgentPromptInput; prompt: AgentPromptInput;
startedAt: number; startedAt: number;
cycle: number; cycle: number;
@@ -609,12 +610,14 @@ export class MockLoadTestAgentSession implements AgentSession {
const profile = resolveModelProfile(this.modelId); const profile = resolveModelProfile(this.modelId);
const turnId = randomUUID(); const turnId = randomUUID();
const assistantMessageId = randomUUID();
let resolve!: (result: AgentRunResult) => void; let resolve!: (result: AgentRunResult) => void;
const completed = new Promise<AgentRunResult>((promiseResolve) => { const completed = new Promise<AgentRunResult>((promiseResolve) => {
resolve = promiseResolve; resolve = promiseResolve;
}); });
const turn: ActiveTurn = { const turn: ActiveTurn = {
turnId, turnId,
assistantMessageId,
prompt, prompt,
startedAt: Date.now(), startedAt: Date.now(),
cycle: 0, cycle: 0,
@@ -860,6 +863,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, { this.emitTimeline(turn.turnId, {
type: "assistant_message", type: "assistant_message",
text: finalText, text: finalText,
messageId: turn.assistantMessageId,
}); });
this.activeTurn = null; this.activeTurn = null;
this.emit({ this.emit({
@@ -874,6 +878,7 @@ export class MockLoadTestAgentSession implements AgentSession {
{ {
type: "assistant_message", type: "assistant_message",
text: finalText, text: finalText,
messageId: turn.assistantMessageId,
}, },
], ],
canceled: false, canceled: false,
@@ -989,6 +994,7 @@ export class MockLoadTestAgentSession implements AgentSession {
? { ? {
type: "assistant_message", type: "assistant_message",
text: `stress-update-${index}`, text: `stress-update-${index}`,
messageId: turn.assistantMessageId,
} }
: { : {
type: "todo", type: "todo",
@@ -1067,6 +1073,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, { this.emitTimeline(turn.turnId, {
type: "assistant_message", type: "assistant_message",
text: `data:image/png;base64,${payload}`, text: `data:image/png;base64,${payload}`,
messageId: turn.assistantMessageId,
}); });
} }
@@ -1133,6 +1140,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, { this.emitTimeline(turn.turnId, {
type: "assistant_message", type: "assistant_message",
text: event.text, text: event.text,
messageId: turn.assistantMessageId,
}); });
return; return;
} }
@@ -1178,6 +1186,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, { this.emitTimeline(turn.turnId, {
type: "assistant_message", type: "assistant_message",
text: "\n\n_(end of synthetic stream)_\n", text: "\n\n_(end of synthetic stream)_\n",
messageId: turn.assistantMessageId,
}); });
this.finishTurnWithText(turn, "Synthetic load test complete"); this.finishTurnWithText(turn, "Synthetic load test complete");
} }

View File

@@ -99,6 +99,7 @@ import {
type TimelineProjectionEntry, type TimelineProjectionEntry,
type TimelineProjectionMode, type TimelineProjectionMode,
} from "./agent/timeline-projection.js"; } from "./agent/timeline-projection.js";
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
import { import {
getAgentStreamEventTurnId, getAgentStreamEventTurnId,
@@ -1371,6 +1372,7 @@ export class Session {
this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentRelationshipMessage(msg) ?? this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentTimelineMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ?? this.dispatchCheckoutMessage(msg) ??
@@ -1450,6 +1452,17 @@ export class Session {
} }
} }
private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default:
return undefined;
}
}
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) { switch (msg.type) {
case "fetch_agents_request": case "fetch_agents_request":
@@ -1484,8 +1497,6 @@ export class Session {
return this.handleRefreshAgentRequest(msg); return this.handleRefreshAgentRequest(msg);
case "cancel_agent_request": case "cancel_agent_request":
return this.handleCancelAgentRequest(msg.agentId, msg.requestId); return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
case "agent_permission_response": case "agent_permission_response":
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
case "clear_agent_attention": case "clear_agent_attention":
@@ -5355,6 +5366,57 @@ export class Session {
} }
} }
private async handleAgentForkContextRequest(
msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>,
): Promise<void> {
try {
const snapshot = await ensureAgentLoaded(msg.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
const agentPayload = await this.buildAgentPayload(snapshot);
const rows = this.agentManager.fetchTimeline(msg.agentId, {
direction: "tail",
limit: 0,
}).rows;
const forkContext = buildAgentForkContextAttachment({
rows,
boundaryMessageId: msg.boundaryMessageId,
agentTitle: agentPayload.title,
cwd: snapshot.cwd,
});
this.emit({
type: "agent.fork_context.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
attachment: forkContext.attachment,
itemCount: forkContext.itemCount,
boundaryMessageId: forkContext.boundaryMessageId,
error: null,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId: msg.agentId },
"Failed to handle agent.fork_context.request",
);
this.emit({
type: "agent.fork_context.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
attachment: null,
itemCount: 0,
boundaryMessageId: msg.boundaryMessageId ?? null,
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleSendAgentMessageRequest( private async handleSendAgentMessageRequest(
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>, msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
): Promise<void> { ): Promise<void> {

View File

@@ -1200,6 +1200,8 @@ export class VoiceAssistantWebSocketServer {
daemonDiagnostics: true, daemonDiagnostics: true,
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
daemonSelfUpdate: true, daemonSelfUpdate: true,
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: true,
}, },
}; };
} }