diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/agent-stream-ui.spec.ts index 6b52d77c5..7439a5604 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/agent-stream-ui.spec.ts @@ -9,6 +9,7 @@ import { import { expectScrollStaysFixed, readScrollMetrics, + scrollAgentChatToBottom, scrollChatAwayFromBottom, waitForScrollableChat, } from "./helpers/agent-bottom-anchor"; @@ -18,6 +19,8 @@ import { clickNewChat } from "./helpers/launcher"; import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360; + test.describe("Agent stream UI", () => { test("auto-scroll sticks to bottom across token bursts", async ({ page }) => { test.setTimeout(120_000); @@ -54,7 +57,10 @@ test.describe("Agent stream UI", () => { timeout: 30_000, }); 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, { deltaY: -900, minDistanceFromBottom: 300, @@ -100,7 +106,10 @@ test.describe("Agent stream UI", () => { timeout: 30_000, }); 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, { deltaY: -900, minDistanceFromBottom: 300, @@ -122,6 +131,7 @@ test.describe("Agent stream UI", () => { await awaitAssistantMessage(page); await expectInlineWorkingIndicator(page); await expectAgentIdle(page, 30_000); + await scrollAgentChatToBottom(page); await expectTurnCopyButton(page); } finally { await agent.cleanup(); diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts new file mode 100644 index 000000000..a1ab1eafb --- /dev/null +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -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; +}>({ + 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 { + await expect + .poll( + async () => { + await scrollAgentChatToBottom(page); + return page.getByTestId("assistant-fork-menu-trigger").count(); + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); + const trigger = page.getByTestId("assistant-fork-menu-trigger").last(); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await trigger.click(); + await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({ + timeout: 10_000, + }); +} + +async function expectChatHistoryPill(page: Page): Promise { + const pill = page.getByTestId("composer-chat-history-attachment-pill").first(); + await expect(pill).toBeVisible({ timeout: 30_000 }); + await expect(pill).toContainText("Chat history"); +} + +test.describe("Assistant fork menu", () => { + test.describe.configure({ timeout: 180_000 }); + + 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); + }); +}); diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index 561e8ea7e..a0ff65ba2 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -55,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise { .toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX); } +export async function scrollAgentChatToBottom(page: Page): Promise { + 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( page: Page, previousContentHeight: number, diff --git a/packages/app/e2e/helpers/mock-agent.ts b/packages/app/e2e/helpers/mock-agent.ts index 9cb7b1aaa..687891136 100644 --- a/packages/app/e2e/helpers/mock-agent.ts +++ b/packages/app/e2e/helpers/mock-agent.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test"; -import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; import { seedWorkspace, type SeedDaemonClient } from "./seed-client"; import { getServerId } from "./server-id"; +import { buildHostAgentDetailRoute } from "../../src/utils/host-routes"; export interface MockAgentWorkspace { agentId: string; @@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace( } export function buildAgentRoute(workspaceId: string, agentId: string): string { - return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent( - `agent:${agentId}`, - )}`; + return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId); } /** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */ diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index fba61bdba..719fdb2b4 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -289,4 +289,67 @@ describe("layoutStream", () => { expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull(); 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]); + }, + ); }); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index e70021342..0f5c4c97e 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -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 { if (input.agentStatus === "running") { return null; } const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history; - const startIndex = input.strategy.getLatestItemIndex(footerItems); - if (startIndex === null) { + const latestIndex = input.strategy.getLatestItemIndex(footerItems); + if (latestIndex === 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") { return null; } @@ -87,26 +117,79 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return createTurnFooterHost({ item, items: footerItems, - index: startIndex, + index: assistantIndex, 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: { + strategy: StreamStrategy; + items: StreamItem[]; + index: number; item: StreamItem; belowItem: StreamItem | null; agentStatus: string; auxiliaryTurnFooter: TurnFooterHost | null; }): boolean { - return ( - input.item.kind === "assistant_message" && - input.auxiliaryTurnFooter?.itemId !== input.item.id && - (input.belowItem?.kind === "user_message" || - (input.belowItem === null && input.agentStatus !== "running")) - ); + if ( + input.item.kind !== "assistant_message" || + input.auxiliaryTurnFooter?.itemId === input.item.id + ) { + 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 { return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; } @@ -175,6 +258,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { belowItem, }); const completedFooter = shouldRenderCompletedFooter({ + strategy: input.strategy, + items: input.items, + index, item, belowItem, agentStatus: input.agentStatus, diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts new file mode 100644 index 000000000..c18fad104 --- /dev/null +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -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 { + return { + kind: "user_message", + id, + text: id, + timestamp: timestamp(seed), + }; +} + +function assistantMessage( + id: string, + seed: number, + messageId?: string, +): Extract { + 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(); + }); +}); diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts new file mode 100644 index 000000000..75fa63a3e --- /dev/null +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -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; +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 4ca005234..f9e63960d 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -9,7 +9,13 @@ import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, } 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 { SyncedLoader } from "@/components/synced-loader"; @@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({ }); export type TurnContentStrategy = StreamStrategy; +export type AssistantTurnForkHandler = (input: { + target: AssistantForkTarget; + boundaryMessageId?: string; +}) => Promise | void; export const TurnFooter = memo(function TurnFooter({ isRunning, inFlightTurnStartedAt, host, strategy, + onForkAssistantTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; host: TurnFooterHost | null; strategy: TurnContentStrategy; + onForkAssistantTurn?: AssistantTurnForkHandler; }) { if (isRunning) { return ( @@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({ items={host.items} timing={host.timing} startIndex={host.startIndex} + onForkAssistantTurn={onForkAssistantTurn} /> ); }); @@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items, timing, startIndex, + onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + onForkAssistantTurn?: AssistantTurnForkHandler; }) { return ( @@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items={items} timing={timing} startIndex={startIndex} + onForkAssistantTurn={onForkAssistantTurn} /> ); @@ -111,11 +127,13 @@ function CompletedTurnFooter({ items, timing, startIndex, + onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + onForkAssistantTurn?: AssistantTurnForkHandler; }) { const getContent = useCallback( () => @@ -126,12 +144,18 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); + const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({ + items, + startIndex, + }); return ( ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 9b5f44f90..a73a17358 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -11,6 +11,7 @@ import React, { type ComponentProps, type ReactNode, } from "react"; +import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { View, @@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; 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 { type BottomAnchorLocalRequest, @@ -76,11 +82,21 @@ import { type WorkspaceFileOpenRequest, } from "@/workspace/file-open"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; 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: { pendingPermissions: ReactNode; @@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; + onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { return null; @@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: { items={footerHost.items} timing={footerHost.timing} startIndex={footerHost.startIndex} + onForkAssistantTurn={input.onForkAssistantTurn} /> ) : null; const content = ( @@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ const EMPTY_STREAM_HEAD: StreamItem[] = []; +function buildChatHistoryAttachment(input: { + draftId: string; + serverId: string; + agentId: string; + payload: Awaited>; + missingAttachmentMessage: string; +}): WorkspaceComposerAttachment { + if (!input.payload.attachment) { + throw new Error(input.missingAttachmentMessage); + } + return { + kind: "chat_history", + id: `chat_history:${input.draftId}`, + attachment: input.payload.attachment, + source: { + serverId: input.serverId, + agentId: input.agentId, + boundaryMessageId: input.payload.boundaryMessageId, + itemCount: input.payload.itemCount, + }, + }; +} + +function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined { + if (!agent.provider) { + return undefined; + } + + const featureValues: Record = {}; + for (const feature of agent.features ?? []) { + featureValues[feature.id] = feature.value; + } + + return { + provider: agent.provider, + cwd: agent.cwd, + modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, + model: agent.model ?? agent.runtimeInfo?.model ?? null, + thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, + featureValues, + }; +} + +function buildForkDraftTabTarget( + setup: WorkspaceDraftTabSetup | undefined, + draftId: string, +): WorkspaceTabTarget { + return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId }; +} + const AgentStreamViewComponent = forwardRef( function AgentStreamView( { @@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef(null); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( @@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), ); + const supportsAgentForkContext = useSessionStore( + (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, + ); const workspaceRoot = agent.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef { + try { + if (!supportsAgentForkContext) { + toast?.error(t("message.actions.forkUnavailable")); + return; + } + if (!client) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const draftSetup = buildForkDraftSetup(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 // cell-window renders on every 48ms flush from background agents. // When isActive flips back to true, the context change triggers a re-render and @@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef ) : null, [ + handleForkAssistantTurn, showRunningTurnFooter, baseRenderModel.turnTiming.runningStartedAt, bottomTurnFooterHost, @@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual( 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[] { const reasons: string[] = []; if (left.serverId !== right.serverId) reasons.push("agent.serverId"); 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.cwd !== right.cwd) reasons.push("agent.cwd"); if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) { reasons.push("agent.capabilities"); } if (left.lastError !== right.lastError) reasons.push("agent.lastError"); - if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) { - reasons.push("agent.projectPlacement.checkout.cwd"); - } - if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) { - reasons.push("agent.projectPlacement.checkout.isGit"); - } + reasons.push(...collectAgentSetupDiffs(left, right)); + reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement)); return reasons; } diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx index 98fc99b99..281ec3355 100644 --- a/packages/app/src/app/new.tsx +++ b/packages/app/src/app/new.tsx @@ -8,16 +8,19 @@ export default function NewWorkspaceRoute() { dir?: string; name?: string; projectId?: string; + draftId?: string; }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined; const displayName = typeof params.name === "string" ? params.name : undefined; const projectId = typeof params.projectId === "string" ? params.projectId : undefined; + const draftId = typeof params.draftId === "string" ? params.draftId : undefined; const screenKey = JSON.stringify([ serverId, sourceDirectory ?? null, displayName ?? null, projectId ?? null, + draftId ?? null, ]); return ( @@ -28,6 +31,7 @@ export default function NewWorkspaceRoute() { sourceDirectory={sourceDirectory} displayName={displayName} projectId={projectId} + draftId={draftId} /> ); diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx new file mode 100644 index 000000000..540e0230a --- /dev/null +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -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, + 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 = ( + +); +const attachmentGithubPrIcon = ( + +); +const attachmentGithubIssueIcon = ( + +); +const attachmentFileIcon = ( + +); +const attachmentBrowserIcon = ( + +); diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 24cbf02f1..e4dcde5ce 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -63,6 +63,18 @@ export type PullRequestContextAttachment = | ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields) | ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields); +export interface ChatHistoryContextAttachment { + kind: "chat_history"; + id: string; + attachment: Extract; + source: { + serverId: string; + agentId: string; + boundaryMessageId?: string | null; + itemCount?: number; + }; +} + export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "file"; attachment: UploadedFileAttachment } @@ -75,6 +87,7 @@ export type WorkspaceComposerAttachment = attachment: BrowserElementAttachment; } | PullRequestContextAttachment + | ChatHistoryContextAttachment | { kind: "review"; attachment: Extract; diff --git a/packages/app/src/attachments/workspace-attachment-utils.ts b/packages/app/src/attachments/workspace-attachment-utils.ts index 8284be001..141c51d01 100644 --- a/packages/app/src/attachments/workspace-attachment-utils.ts +++ b/packages/app/src/attachments/workspace-attachment-utils.ts @@ -22,6 +22,7 @@ export function isWorkspaceAttachment( return ( attachment?.kind === "review" || attachment?.kind === "browser_element" || + attachment?.kind === "chat_history" || isPullRequestContextAttachment(attachment) ); } @@ -33,6 +34,7 @@ export function userAttachmentsOnly( (attachment): attachment is UserComposerAttachment => attachment.kind !== "review" && attachment.kind !== "browser_element" && + attachment.kind !== "chat_history" && !isPullRequestContextAttachment(attachment), ); } @@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment( text: attachment.text, }; } + if (attachment.kind === "chat_history") { + return attachment.attachment; + } return attachment.kind === "review" ? attachment.attachment : null; } diff --git a/packages/app/src/attachments/workspace-attachments-store.test.ts b/packages/app/src/attachments/workspace-attachments-store.test.ts index 888a21d49..2b603e805 100644 --- a/packages/app/src/attachments/workspace-attachments-store.test.ts +++ b/packages/app/src/attachments/workspace-attachments-store.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { WorkspaceComposerAttachment } from "./types"; import { appendWorkspaceAttachment, + buildDraftWorkspaceAttachmentScopeKey, buildWorkspaceAttachmentScopeKey, + collectWorkspaceAttachmentsForScopes, resetWorkspaceAttachmentsStore, useWorkspaceAttachmentsStore, } 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", () => { it("scopes workspace attachments by server and workspace before cwd fallback", () => { expect( @@ -76,6 +96,12 @@ describe("workspace attachments store", () => { ).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", () => { resetWorkspaceAttachmentsStore(); const scopeKey = buildWorkspaceAttachmentScopeKey({ @@ -116,6 +142,13 @@ describe("workspace attachments store", () => { 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", () => { resetWorkspaceAttachmentsStore(); const scopeKey = buildWorkspaceAttachmentScopeKey({ @@ -138,4 +171,25 @@ describe("workspace attachments store", () => { 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]); + }); }); diff --git a/packages/app/src/attachments/workspace-attachments-store.ts b/packages/app/src/attachments/workspace-attachments-store.ts index 12ccc130c..7010c06dc 100644 --- a/packages/app/src/attachments/workspace-attachments-store.ts +++ b/packages/app/src/attachments/workspace-attachments-store.ts @@ -1,15 +1,26 @@ import { useMemo } from "react"; import { create } from "zustand"; +import { useShallow } from "zustand/shallow"; import type { WorkspaceComposerAttachment } from "./types"; const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = []; export interface WorkspaceAttachmentScopeInput { + kind?: "workspace"; serverId: string; workspaceId?: string | null; cwd: string; } +export interface DraftWorkspaceAttachmentScopeInput { + kind: "draft"; + draftId: string; +} + +export type WorkspaceAttachmentScope = + | WorkspaceAttachmentScopeInput + | DraftWorkspaceAttachmentScopeInput; + interface WorkspaceAttachmentsStoreState { attachmentsByScope: Record; } @@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope ); } +export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string { + return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":"); +} + function areWorkspaceAttachmentsEqual( left: readonly WorkspaceComposerAttachment[], right: readonly WorkspaceComposerAttachment[], @@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual( } function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null { - if ( - attachment.kind !== "github.pull_request_comment" && - attachment.kind !== "github.pull_request_review" && - attachment.kind !== "github.pull_request_check" - ) { + const isContextAttachment = + attachment.kind === "chat_history" || + attachment.kind === "github.pull_request_comment" || + attachment.kind === "github.pull_request_review" || + attachment.kind === "github.pull_request_check"; + if (!isContextAttachment) { return null; } return JSON.stringify({ @@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create()( }, })); -export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string { - const { serverId, workspaceId, cwd } = input; +export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string { + 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( - () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }), - [serverId, workspaceId, cwd], + () => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""), + [normalizedDraftId], ); } @@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom ); } +export function collectWorkspaceAttachmentsForScopes(input: { + attachmentsByScope: Record; + 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 { useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} }); } diff --git a/packages/app/src/components/assistant-fork-menu.tsx b/packages/app/src/components/assistant-fork-menu.tsx new file mode 100644 index 000000000..59c052197 --- /dev/null +++ b/packages/app/src/components/assistant-fork-menu.tsx @@ -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; + 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 ; + case "workspace": + return ; + } +} + +export const AssistantForkMenu = memo(function AssistantForkMenu({ + onFork, + testID = "assistant-fork-menu", +}: AssistantForkMenuProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [pendingTarget, setPendingTarget] = useState(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( + () => ( + + {t("message.actions.forkMenu")} + + ), + [t], + ); + + return ( + + + + + + {({ hovered, open }) => ( + + )} + + + + {tooltipContent} + + + + {t("message.actions.forkInNewTab")} + + + {t("message.actions.forkInNewWorkspace")} + + + + ); +}); + +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, + }, +})); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 30f45c541..6a8cf8922 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -32,7 +32,6 @@ import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, - CircleDot, Info, CheckCircle, XCircle, @@ -42,15 +41,13 @@ import { Check, CheckSquare, Copy, - GitPullRequest, - MessageSquareCode, TriangleAlertIcon, Scissors, MicVocal, FileSymlink, } from "lucide-react-native"; 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 Animated, { Easing, @@ -89,6 +86,7 @@ import { getFileNameFromPath, parseImageDataUrl, } from "@/attachments/utils"; +import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -104,7 +102,6 @@ import { import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; -import { getFileTypeLabel } from "@/attachments/file-types"; import { AttachmentFrame, AttachmentLabel, @@ -116,7 +113,9 @@ import { isWeb, isNative } from "@/constants/platform"; import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types"; import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu"; 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 { AssistantForkTarget }; interface UserMessageProps { serverId?: string; @@ -170,10 +169,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); 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 foregroundMutedColorMapping = (theme: Theme) => ({ @@ -402,19 +397,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ }, })); -const attachmentReviewIcon = ( - -); -const attachmentGithubPrIcon = ( - -); -const attachmentGithubIssueIcon = ( - -); -const attachmentFileIcon = ( - -); - interface UserMessageImagePillProps { image: UserMessageImageAttachment; 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["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({ serverId, agentId, @@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({ {hasAttachments ? ( {attachments.map((attachment, index) => { - const content = getUserMessageAttachmentContent(attachment, t); + const content = getAgentAttachmentPillContent(attachment, t); return ( string; completedAt?: Date; durationMs?: number; + forkBoundaryMessageId?: string; + onFork?: (input: { + target: AssistantForkTarget; + boundaryMessageId?: string; + }) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ @@ -672,6 +610,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, + forkBoundaryMessageId, + onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); const [pressedReveal, setPressedReveal] = useState(false); @@ -711,6 +651,13 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ revealTimerRef.current = null; }, TIMESTAMP_REVEAL_MS); }, [canSwap]); + const handleFork = useCallback( + (target: AssistantForkTarget) => { + return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId }); + }, + [forkBoundaryMessageId, onFork], + ); + const canFork = Boolean(onFork && forkBoundaryMessageId); return ( @@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent={getContent} containerStyle={assistantTurnFooterStylesheet.copyButton} /> + {canFork ? : null} {durationLabel ? ( ({ + 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); + } +} diff --git a/packages/app/src/composer/attachments/workspace.test.ts b/packages/app/src/composer/attachments/workspace.test.ts new file mode 100644 index 000000000..0a4353f62 --- /dev/null +++ b/packages/app/src/composer/attachments/workspace.test.ts @@ -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: '', + 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(); + }); +}); diff --git a/packages/app/src/composer/attachments/workspace.tsx b/packages/app/src/composer/attachments/workspace.tsx index e41424793..0beff1fb5 100644 --- a/packages/app/src/composer/attachments/workspace.tsx +++ b/packages/app/src/composer/attachments/workspace.tsx @@ -1,25 +1,25 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { withUnistyles } from "react-native-unistyles"; -import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native"; import type { ComposerAttachment, UserComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; +import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; -import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { isWorkspaceAttachment, isPullRequestContextAttachment, userAttachmentsOnly, workspaceAttachmentToSubmitAttachment, } 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"; -type TranslationFn = ReturnType["t"]; - interface WorkspaceAttachmentBindingInput { normalAttachments: UserComposerAttachment[]; workspaceAttachments: readonly WorkspaceComposerAttachment[]; @@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding { 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( attachment: WorkspaceComposerAttachment, - t: TranslationFn, + t: ReturnType["t"], ): string { if (attachment.kind === "browser_element") { return t("composer.attachments.openBrowserElement"); @@ -148,12 +60,15 @@ function getOpenAccessibilityLabel( if (isPullRequestContextAttachment(attachment)) { return "Open context attachment"; } + if (attachment.kind === "chat_history") { + return "Open chat history attachment"; + } return t("composer.attachments.openReview"); } function getRemoveAccessibilityLabel( attachment: WorkspaceComposerAttachment, - t: TranslationFn, + t: ReturnType["t"], ): string { if (attachment.kind === "browser_element") { return t("composer.attachments.removeBrowserElement"); @@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel( if (isPullRequestContextAttachment(attachment)) { return "Remove context attachment"; } + if (attachment.kind === "chat_history") { + return "Remove chat history attachment"; + } return t("composer.attachments.removeReview"); } -function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement { - if (attachment.kind === "browser_element") { - return ; +function getPillTestID(attachment: WorkspaceComposerAttachment): string { + if (attachment.kind === "chat_history") { + return "composer-chat-history-attachment-pill"; } - if (isPullRequestContextAttachment(attachment)) { - return ; - } - return ; + return "composer-review-attachment-pill"; } function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement { @@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({ ({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => { const selected = current[index]; 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); removeWorkspaceAttachmentsMatching(selectedKey); return true; @@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({ onRemove, }: WorkspaceAttachmentPillProps) { const { t } = useTranslation(); - const content = getPillContent(attachment, t); + const content = getWorkspaceAttachmentPillContent(attachment, t); const handleOpen = useCallback(() => { onOpen(attachment); }, [onOpen, attachment]); @@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({ }, [onRemove, index]); return ( - + ); } @@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = { userAttachmentsOnly, useBinding: useWorkspaceAttachmentBinding, }; - -const ThemedMousePointer2 = withUnistyles(MousePointer2); -const ThemedMessageSquareCode = withUnistyles(MessageSquareCode); -const ThemedFileText = withUnistyles(FileText); -const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 70ae16b66..41ea2a4d9 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => { }); 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); + }); }); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 3e85b2a8b..82d063aac 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow({ const images = wirePayload.images; 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")); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts index 7e31b03a9..30411aeb0 100644 --- a/packages/app/src/composer/draft/workspace-tab-core.ts +++ b/packages/app/src/composer/draft/workspace-tab-core.ts @@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig { model: string | null; } +export function shouldAllowEmptyDraftText(input: { + allowsEmptyAutoSubmit: boolean; + attachments: readonly unknown[]; +}): boolean { + return input.allowsEmptyAutoSubmit || input.attachments.length > 0; +} + export function validateDraftSubmission(input: { text: string; allowsEmptyAutoSubmit: boolean; diff --git a/packages/app/src/composer/draft/workspace-tab.test.ts b/packages/app/src/composer/draft/workspace-tab.test.ts index 4f9a0e202..5952083a6 100644 --- a/packages/app/src/composer/draft/workspace-tab.test.ts +++ b/packages/app/src/composer/draft/workspace-tab.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { validateDraftSubmission } from "./workspace-tab-core"; +import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core"; const baseComposerState = { providerDefinitions: [{ id: "codewhale" }], @@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => { ).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); + }); +}); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 14507a386..dc1befb39 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -27,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; 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 { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { - useWorkspaceAttachments, + useDraftWorkspaceAttachmentScopeKey, useWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; import { @@ -118,6 +122,7 @@ async function submitDraftCreateRequest(input: { text: string; images?: UserMessageImageAttachment[]; attachments?: unknown; + cwd: string; client: DaemonClient | null; workspaceDirectory: string | null; workspaceId: string | null; @@ -138,6 +143,7 @@ async function submitDraftCreateRequest(input: { text, images, attachments, + cwd, client, workspaceDirectory, workspaceId, @@ -162,7 +168,7 @@ async function submitDraftCreateRequest(input: { }); const config = buildWorkspaceDraftAgentConfig({ provider, - cwd: workspaceDirectory, + cwd, ...modeIdOverride, model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), thinkingOptionId: @@ -400,7 +406,14 @@ export function WorkspaceDraftAgentTab({ cwd: composerState.workingDir, 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 setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const handleOpenWorkspaceAttachment = useCallback( @@ -437,15 +450,20 @@ export function WorkspaceDraftAgentTab({ getPendingServerId: () => serverId, initialAttempt: initialCreateAttempt, allowEmptyText: allowsEmptyAutoSubmit, - validateBeforeSubmit: ({ text }) => - validateDraftSubmission({ - text, + validateBeforeSubmit: ({ text, attachments }) => { + const allowsEmptyDraftText = shouldAllowEmptyDraftText({ allowsEmptyAutoSubmit, + attachments, + }); + return validateDraftSubmission({ + text, + allowsEmptyAutoSubmit: allowsEmptyDraftText, composerState, autoSubmitConfig, workspaceDirectory: draftWorkingDirectory, hasClient: Boolean(client), - }), + }); + }, onBeforeSubmit: async () => { await composerState.persistFormPreferences(); if (isWeb) { @@ -463,12 +481,13 @@ export function WorkspaceDraftAgentTab({ composerState, selectModelMessage: t("workspaceSetup.errors.selectModel"), }), - createRequest: async ({ attempt, text, images, attachments }) => + createRequest: async ({ attempt, text, images, attachments, cwd }) => submitDraftCreateRequest({ attempt, text, images, attachments, + cwd, client, workspaceDirectory: draftWorkingDirectory, workspaceId: workspaceFields?.id ?? null, @@ -479,6 +498,8 @@ export function WorkspaceDraftAgentTab({ }), onCreateSuccess: ({ result }) => { clearDraftInput("sent"); + clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); onCreated(result); }, }); @@ -690,7 +711,7 @@ export function WorkspaceDraftAgentTab({ value={draftInput.text} onChangeText={draftInput.setText} attachments={draftInput.attachments} - workspaceAttachments={workspaceAttachments} + attachmentScopeKeys={attachmentScopeKeys} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onChangeAttachments={draftInput.setAttachments} cwd={composerState.workingDir} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 26fa355d9..396b279ad 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -103,6 +103,7 @@ import type { } from "@/attachments/types"; import type { PickedFile } from "@/attachments/picked-file"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; +import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; import { droppedItemsToPickedFiles } from "@/composer/attachments/drop"; import { getFileTypeLabel } from "@/attachments/file-types"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; @@ -121,6 +122,8 @@ type AttachmentListUpdater = | UserComposerAttachment[] | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); +const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = []; + function noop() {} const noopCallback = () => {}; @@ -759,7 +762,7 @@ interface ComposerProps { value: string; onChangeText: (text: string) => void; attachments: UserComposerAttachment[]; - workspaceAttachments?: readonly WorkspaceComposerAttachment[]; + attachmentScopeKeys?: readonly string[]; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; onChangeAttachments: (updater: AttachmentListUpdater) => void; cwd: string; @@ -971,7 +974,7 @@ export function Composer({ value, onChangeText, attachments, - workspaceAttachments = [], + attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, onOpenWorkspaceAttachment, onChangeAttachments, cwd, @@ -1026,6 +1029,7 @@ export function Composer({ const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t); const userInput = value; const setUserInput = onChangeText; + const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys); const { selectedAttachments, buildOutgoingAttachments, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 6a332b70c..9882ff5cf 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -1,15 +1,31 @@ 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 { serverId: string; id: string; + provider?: AgentProvider; status: "initializing" | "idle" | "running" | "error" | "closed"; cwd: string; workspaceId?: string; 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; projectPlacement?: { + projectKey?: string; + projectName?: string; checkout?: { cwd?: string; isGit?: boolean; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 24bf54b36..860175303 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -245,6 +245,12 @@ export const ar: TranslationResources = { copyCode: "نسخ الرمز", copyTurn: "نسخ بدوره", copyMessage: "انسخ الرسالة", + forkMenu: "تفريع الرسالة", + forkInNewTab: "تفريع في تبويب جديد", + forkInNewWorkspace: "تفريع في مساحة عمل جديدة", + forkUnavailable: "حدّث المضيف لاستخدام هذا.", + forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.", + forkFailed: "فشل تفريع المحادثة", openFile: "افتح الملف", copied: "منقول", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 35a12a7ff..e473546e1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -244,6 +244,12 @@ export const en = { copyCode: "Copy code", copyTurn: "Copy turn", 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", copied: "Copied", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2b2f0fbbe..95a1ef551 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -248,6 +248,12 @@ export const es: TranslationResources = { copyCode: "Copiar código", copyTurn: "Copiar turno", 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", copied: "Copiado", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5c8a33779..0a8ed5bbb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -248,6 +248,12 @@ export const fr: TranslationResources = { copyCode: "Copier le code", copyTurn: "Copier le tour", 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", copied: "Copié", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4dc29ae4e..6deb07889 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -248,6 +248,12 @@ export const ja: TranslationResources = { copyCode: "コードをコピー", copyTurn: "ターンをコピー", copyMessage: "メッセージをコピー", + forkMenu: "メッセージをフォーク", + forkInNewTab: "新しいタブにフォーク", + forkInNewWorkspace: "新しいワークスペースにフォーク", + forkUnavailable: "これを使用するにはホストを更新してください。", + forkMissingWorkspace: "このエージェントはワークスペース内にありません。", + forkFailed: "チャットのフォークに失敗しました", openFile: "ファイルを開く", copied: "コピーしました", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 3dcad94fa..a38038048 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -248,6 +248,12 @@ export const ptBR: TranslationResources = { copyCode: "Copiar código", copyTurn: "Copiar turno", 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", copied: "Copiado", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5e66f20fd..39a42b5a6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -247,6 +247,12 @@ export const ru: TranslationResources = { copyCode: "Скопировать код", copyTurn: "Копировать ход", copyMessage: "Копировать сообщение", + forkMenu: "Форкнуть сообщение", + forkInNewTab: "Форкнуть в новой вкладке", + forkInNewWorkspace: "Форкнуть в новом рабочем пространстве", + forkUnavailable: "Обновите хост, чтобы использовать это.", + forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.", + forkFailed: "Не удалось форкнуть чат", openFile: "Открыть файл", copied: "Скопировано", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4504959b6..6cf1982bc 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -245,6 +245,12 @@ export const zhCN: TranslationResources = { copyCode: "复制代码", copyTurn: "复制回合", copyMessage: "复制消息", + forkMenu: "分叉消息", + forkInNewTab: "分叉到新标签页", + forkInNewWorkspace: "分叉到新工作区", + forkUnavailable: "请更新主机以使用此功能。", + forkMissingWorkspace: "此 Agent 不在工作区中。", + forkFailed: "分叉聊天失败", openFile: "打开文件", copied: "已复制", }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index b34b7f57a..f806f68c6 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -24,10 +24,7 @@ import { type ToastState, } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; -import { - useWorkspaceAttachments, - useWorkspaceAttachmentScopeKey, -} from "@/attachments/workspace-attachments-store"; +import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; @@ -86,10 +83,16 @@ import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-co interface ChatAgentStateShape { serverId: string | null; id: string | null; + provider?: Agent["provider"]; status: Agent["status"] | null; cwd: string | null; workspaceId?: string; capabilities?: Agent["capabilities"]; + currentModeId?: Agent["currentModeId"]; + model?: Agent["model"]; + thinkingOptionId?: Agent["thinkingOptionId"]; + runtimeInfo?: Agent["runtimeInfo"]; + features?: Agent["features"]; lastError?: Agent["lastError"] | null; } @@ -130,10 +133,16 @@ function selectChatAgentState( return { serverId: agent.serverId, id: agent.id, + provider: agent.provider, status: agent.status, cwd: agent.cwd, workspaceId: agent.workspaceId, capabilities: agent.capabilities, + currentModeId: agent.currentModeId, + model: agent.model, + thinkingOptionId: agent.thinkingOptionId, + runtimeInfo: agent.runtimeInfo, + features: agent.features, lastError: agent.lastError ?? null, archivedAt: agent.archivedAt ?? null, requiresAttention: agent.requiresAttention ?? false, @@ -151,10 +160,16 @@ function buildChatAgentFromState( return { serverId: state.serverId, id: state.id, + provider: state.provider, status: state.status, cwd: state.cwd, workspaceId: state.workspaceId, capabilities: state.capabilities, + currentModeId: state.currentModeId, + model: state.model, + thinkingOptionId: state.thinkingOptionId, + runtimeInfo: state.runtimeInfo, + features: state.features, lastError: state.lastError ?? null, projectPlacement, }; @@ -546,18 +561,7 @@ function AgentPanelBody({ (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), ); const agentState = useSessionStore( - useShallow((state) => { - 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, - }; - }), + useShallow((state) => selectChatAgentState(state, serverId, agentId)), ); const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); @@ -644,9 +648,16 @@ function AgentPanelBody({ ? { serverId: agentState.serverId, id: agentState.id, + provider: agentState.provider, status: agentState.status, cwd: agentState.cwd, 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, projectPlacement, } @@ -1378,7 +1389,10 @@ function ActiveAgentComposer({ cwd, workspaceId, }); - const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey); + const attachmentScopeKeys = useMemo( + () => [workspaceAttachmentScopeKey], + [workspaceAttachmentScopeKey], + ); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const handleOpenWorkspaceAttachment = useCallback( @@ -1479,7 +1493,7 @@ function ActiveAgentComposer({ value={agentInputDraft.text} onChangeText={agentInputDraft.setText} attachments={agentInputDraft.attachments} - workspaceAttachments={workspaceAttachments} + attachmentScopeKeys={attachmentScopeKeys} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} onChangeAttachments={agentInputDraft.setAttachments} cwd={cwd} diff --git a/packages/app/src/screens/new-workspace-fork-context.test.ts b/packages/app/src/screens/new-workspace-fork-context.test.ts new file mode 100644 index 000000000..6299c4560 --- /dev/null +++ b/packages/app/src/screens/new-workspace-fork-context.test.ts @@ -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]); + }); +}); diff --git a/packages/app/src/screens/new-workspace-fork-context.ts b/packages/app/src/screens/new-workspace-fork-context.ts new file mode 100644 index 000000000..1189f59c4 --- /dev/null +++ b/packages/app/src/screens/new-workspace-fork-context.ts @@ -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); +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 62d710828..e9a44fc2f 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -36,10 +36,14 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { useWorkspace } from "@/stores/session-store-hooks"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; +import { + useWorkspaceDraftSubmissionStore, + type PendingWorkspaceDraftSetup, +} from "@/stores/workspace-draft-submission-store"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useFormPreferences } from "@/hooks/use-form-preferences"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; @@ -57,11 +61,17 @@ import { } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; +import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; 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 { + getWorkspaceNamingAttachments, + remapDraftCwdToWorkspace, +} from "./new-workspace-fork-context"; import { pickerItemToCheckoutRequest, 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: { prompt: string; attachments: AgentAttachment[]; @@ -102,6 +143,7 @@ interface NewWorkspaceScreenProps { sourceDirectory?: string; projectId?: string; displayName?: string; + draftId?: string; } interface PickerOptionData { @@ -714,6 +756,18 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { 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 { if (!selection) return null; return selection.item; @@ -733,12 +787,28 @@ function normalizeBranchDetails( interface SubmitDraftInput { serverId: string; draftKey: string; + draftId?: string; + initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; workspaceDirectory: string; text: string; attachments: ComposerAttachment[]; provider: AgentProvider; - composerState: NonNullable["composerState"]>; + composerState: NewWorkspaceComposerState; +} + +type NewWorkspaceComposerState = NonNullable< + ReturnType["composerState"] +>; + +interface WorkspaceDraftSubmissionConfig { + cwd: string; + provider: AgentProvider; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record | undefined; + target: WorkspaceTabTarget; } async function createAndMergeWorkspace(input: { @@ -820,6 +890,7 @@ async function createMultiplicityWorkspace(input: { interface CreateChatAgentInput { payload: MessagePayload; composerState: ReturnType["composerState"]; + forkDraftSetup?: PendingWorkspaceDraftSetup | null; ensureWorkspace: (input: { cwd: string; prompt: string; @@ -828,12 +899,67 @@ interface CreateChatAgentInput { }) => Promise>; serverId: string; draftKey: string; + draftId?: string; labels: { composerStateRequired: 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 { const { payload, composerState, ensureWorkspace, serverId, draftKey } = input; const { text, attachments, cwd } = payload; @@ -845,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { throw new Error(input.labels.selectModel); } const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments); + const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments); const ensuredWorkspace = await ensureWorkspace({ cwd, prompt: text, - attachments: reviewAttachments, + attachments: workspaceNamingAttachments, withInitialAgent: true, }); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: ensuredWorkspace.workspaceDirectory, + provider, + composerState, + }); submitWorkspaceDraft({ serverId, draftKey, + draftId: input.draftId, + initialSetup, workspaceId: ensuredWorkspace.id, workspaceDirectory: ensuredWorkspace.workspaceDirectory, text, @@ -868,12 +1003,14 @@ function buildComposerConfig(input: { isConnected: boolean; workspaceDirectory: string | null; sourceDirectory: string | null; + initialSetup?: WorkspaceDraftTabSetup | null; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input; + const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; return { initialServerId: serverId || null, - initialValues: workingDir ? { workingDir } : undefined, + initialValues: buildComposerInitialValues({ workingDir, initialSetup }), + initialFeatureValues: initialSetup?.featureValues, isVisible: true, onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, @@ -921,21 +1058,72 @@ function useCheckoutHintDismissals(attachments: ReadonlyArray { + 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 { const { serverId, draftKey, + draftId: draftIdInput, workspaceId, workspaceDirectory, text, attachments, provider, composerState, + initialSetup, } = input; - const draftId = generateDraftId(); + const draftId = draftIdInput?.trim() || generateDraftId(); const clientMessageId = generateMessageId(); const timestamp = Date.now(); const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const submission = resolveWorkspaceDraftSubmissionConfig({ + draftId, + workspaceDirectory, + provider, + composerState, + initialSetup, + }); useCreateFlowStore.getState().setPending({ serverId, draftId, @@ -953,23 +1141,21 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { draftId, text: text.trim(), attachments, - cwd: workspaceDirectory, - provider, + cwd: submission.cwd, + provider: submission.provider, clientMessageId, timestamp, - ...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}), - ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), - ...(composerState.effectiveThinkingOptionId - ? { thinkingOptionId: composerState.effectiveThinkingOptionId } - : {}), - ...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}), + ...(submission.modeId ? { modeId: submission.modeId } : {}), + ...(submission.model ? { model: submission.model } : {}), + ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), + ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), allowEmptyText: true, }); navigateToPreparedWorkspaceTab({ serverId, workspaceId, currentPathname: "/new", - target: { kind: "draft", draftId }, + target: submission.target, }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); } @@ -1360,6 +1546,7 @@ export function NewWorkspaceScreen({ sourceDirectory: sourceDirectoryProp, projectId, displayName: displayNameProp, + draftId, }: NewWorkspaceScreenProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -1396,6 +1583,7 @@ export function NewWorkspaceScreen({ const projectPickerAnchorRef = useRef(null); const isolationPickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); + const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); useEffect(() => { const trimmed = pickerSearchQuery.trim(); @@ -1404,7 +1592,7 @@ export function NewWorkspaceScreen({ }, [pickerSearchQuery]); const workspace = createdWorkspace; - const isPending = pendingAction !== null; + const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1441,7 +1629,17 @@ export function NewWorkspaceScreen({ const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ 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({ draftKey, composer: buildComposerConfig({ @@ -1449,6 +1647,7 @@ export function NewWorkspaceScreen({ isConnected, workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, + initialSetup: forkDraftSetup?.setup, }), }); const composerState = chatDraft.composerState; @@ -1793,9 +1992,11 @@ export function NewWorkspaceScreen({ await runCreateChatAgent({ payload, composerState, + forkDraftSetup, ensureWorkspace, serverId: selectedServerId, draftKey, + draftId, labels: { composerStateRequired: t("newWorkspace.errors.composerStateRequired"), selectModel: t("newWorkspace.errors.selectModel"), @@ -1808,7 +2009,7 @@ export function NewWorkspaceScreen({ toast.error(message); } }, - [composerState, draftKey, ensureWorkspace, selectedServerId, t, toast], + [composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast], ); const renderPickerOption = useCallback( @@ -1987,12 +2188,13 @@ export function NewWorkspaceScreen({ submitButtonAccessibilityLabel={t("newWorkspace.create")} submitButtonTestID="workspace-create-submit" submitIcon="return" - isSubmitLoading={pendingAction !== null} + isSubmitLoading={isPending} submitBehavior="preserve-and-lock" blurOnSubmit={true} value={chatDraft.text} onChangeText={chatDraft.setText} attachments={chatDraft.attachments} + attachmentScopeKeys={visibleDraftContextScopeKeys} onChangeAttachments={chatDraft.setAttachments} cwd={selectedSourceDirectory ?? ""} clearDraft={handleClearDraft} diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts index 17844e4bd..c1b4ec8d7 100644 --- a/packages/app/src/stores/create-flow-store.test.ts +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { useCreateFlowStore } from "./create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "./create-flow-store"; describe("create-flow-store", () => { beforeEach(() => { @@ -88,4 +88,40 @@ describe("create-flow-store", () => { 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); + }); }); diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 5f4cfea5d..5ca6b9532 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -17,6 +17,20 @@ export interface PendingCreateAttempt { 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 { pendingByDraftId: Record; setPending: (pending: Omit) => void; diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 014d5f929..d31a14485 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; export interface PendingWorkspaceDraftSubmission { serverId: string; @@ -19,9 +20,21 @@ export interface PendingWorkspaceDraftSubmission { allowEmptyText?: boolean; } +export interface PendingWorkspaceDraftSetup { + setup: WorkspaceDraftTabSetup; + sourceDirectory?: string | null; +} + interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; + setupByDraftId: Record; setPending: (submission: PendingWorkspaceDraftSubmission) => void; + setDraftSetup: (input: { + draftId: string; + setup: WorkspaceDraftTabSetup; + sourceDirectory?: string | null; + }) => void; + clearDraftSetup: (input: { draftId: string }) => void; consumePending: (input: { serverId: string; workspaceId: string; @@ -40,9 +53,14 @@ function matchesPendingSubmission( ); } +function normalizeDraftId(draftId: string): string { + return draftId.trim(); +} + export const useWorkspaceDraftSubmissionStore = create( (set, get) => ({ pendingByDraftId: {}, + setupByDraftId: {}, setPending: (submission) => set((state) => ({ pendingByDraftId: { @@ -50,6 +68,25 @@ export const useWorkspaceDraftSubmissionStore = create { + 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) => { const pending = get().pendingByDraftId[input.draftId]; if (!matchesPendingSubmission(pending, input)) { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index c67e0d0ed..67c7480d0 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -986,6 +986,7 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str groupId: blockGroupId, blockIndex: firstBlockIndex + offset, }), + ...(activeItem.messageId ? { messageId: activeItem.messageId } : {}), blockGroupId, blockIndex: firstBlockIndex + offset, text: block, diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index c2db2032d..7d9754df6 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -238,6 +238,16 @@ describe("global routes", () => { }), ).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", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 0b8dfcbb6..d32257243 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -424,6 +424,7 @@ interface NewWorkspaceRouteOptions { sourceDirectory?: string; displayName?: string; projectId?: string; + draftId?: string; } function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string { @@ -441,6 +442,9 @@ function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string { if (options.projectId) { params.set("projectId", options.projectId); } + if (options.draftId) { + params.set("draftId", options.draftId); + } return params.toString(); } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9a317540b..18240ceb2 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -26,6 +26,7 @@ import type { FileUploadResponse, FileExplorerResponse, FetchAgentTimelineResponseMessage, + AgentForkContextResponseMessage, GitSetupOptions, CheckoutStatusResponse, CheckoutCommitResponse, @@ -456,6 +457,7 @@ type ScheduleUpdatePayload = Extract< { type: "schedule/update/response" } >["payload"]; export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"]; +export type AgentForkContextPayload = AgentForkContextResponseMessage["payload"]; export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"]; export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"]; @@ -502,6 +504,10 @@ function normalizeListCommandsOptions( } return { agentId: input, ...legacyOptions }; } +export interface AgentForkContextOptions { + boundaryMessageId?: string; + requestId?: string; +} type AgentRefreshedStatusPayload = z.infer; type RestartRequestedStatusPayload = z.infer; @@ -2405,6 +2411,41 @@ export class DaemonClient { return payload; } + async buildAgentForkContext( + agentId: string, + options: AgentForkContextOptions = {}, + ): Promise { + 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 // ============================================================================ diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 114e2a72d..57f52d240 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -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", () => { const parsed = CreatePaseoWorktreeRequestSchema.parse({ type: "create_paseo_worktree_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 499de202a..7eb0edaa1 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -843,12 +843,18 @@ export const GitHubIssueAttachmentSchema = z.object({ body: z.string().nullable().optional(), }); -export const TextAttachmentSchema = z.object({ - type: z.literal("text"), - mimeType: z.literal("text/plain"), - title: z.string().nullable().optional(), - text: z.string(), -}); +export const TextAttachmentSchema = z + .object({ + type: z.literal("text"), + mimeType: z.literal("text/plain"), + 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({ oldLineNumber: z.number().int().positive().nullable(), @@ -1274,6 +1280,13 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ 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({ type: z.literal("set_agent_mode_request"), agentId: z.string(), @@ -2060,6 +2073,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + AgentForkContextRequestMessageSchema, SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, @@ -2335,6 +2349,8 @@ export const ServerInfoStatusPayloadSchema = z daemonDiagnostics: z.boolean().optional(), // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: z.boolean().optional(), + // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. + agentForkContext: z.boolean().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({ type: z.literal("cancel_agent_response"), payload: z.object({ @@ -4158,6 +4186,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + AgentForkContextResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, @@ -4320,6 +4349,7 @@ export type FetchAgentResponseMessage = z.infer; +export type AgentForkContextResponseMessage = z.infer; export type CancelAgentResponseMessage = z.infer; export type SendAgentMessageResponseMessage = z.infer; export type SetVoiceModeResponseMessage = z.infer; @@ -4410,6 +4440,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer< >; export type FetchWorkspacesRequestMessage = z.infer; export type FetchAgentRequestMessage = z.infer; +export type AgentForkContextRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; export type WaitForFinishRequest = z.infer; export type DictationStreamStartMessage = z.infer; diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index f6dfc7bb5..d7500ac73 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -1,6 +1,7 @@ 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 { AgentTimelineRow } from "./agent-timeline-store-types.js"; function toolCallItem(params: { 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", () => { it("renders user/assistant/reasoning entries", () => { const timeline: AgentTimelineItem[] = [ @@ -217,4 +226,140 @@ second line'`, it("returns a default message when timeline is empty", () => { 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."); + }); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 7bfb5f973..cb248cb62 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,4 +1,6 @@ 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 { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { projectTimelineRows } from "./timeline-projection.js"; @@ -10,12 +12,16 @@ const MAX_TOOL_SUMMARY_CHARS = 200; interface ActivityCuratorOptions { maxItems?: number; labelAssistantMessages?: boolean; + includeKinds?: readonly AgentTimelineItem["type"][]; + includeExternalToolInput?: boolean; } interface ActivityEntry { text: string; } +type TextAgentAttachment = Extract; + function appendText(buffer: string, text: string): string { const normalized = text.trim(); if (!normalized) { @@ -95,24 +101,56 @@ function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineI return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item); } -function curateAgentActivityEntries( - timeline: AgentTimelineItem[], +function shouldIncludeItem(item: AgentTimelineItem, options?: ActivityCuratorOptions): boolean { + if (!options?.includeKinds) { + return true; + } + return options.includeKinds.includes(item.type); +} + +function formatToolCallEntry( + item: Extract, + 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, ): ActivityEntry[] { - if (timeline.length === 0) { + if (items.length === 0) { return []; } - const collapsed = projectForCuration(timeline); - const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS; - const recentItems = - maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed; + const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items; const entries: ActivityEntry[] = []; const buffers = { message: "", thought: "" }; for (const item of recentItems) { + if (!shouldIncludeItem(item, options)) { + continue; + } + switch (item.type) { case "user_message": flushBuffers(entries, buffers, options); @@ -126,25 +164,7 @@ function curateAgentActivityEntries( break; case "tool_call": { flushBuffers(entries, buffers, options); - 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 (isLikelyExternalToolName(item.name) && inputJson) { - entries.push(activityEntry(`[${displayName}] ${inputJson}`)); - break; - } - if (summary) { - entries.push(activityEntry(`[${displayName}] ${summary}`)); - } else { - entries.push(activityEntry(`[${displayName}]`)); - } + entries.push(formatToolCallEntry(item, options)); break; } case "todo": @@ -172,6 +192,14 @@ function curateAgentActivityEntries( 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. */ @@ -184,3 +212,90 @@ export function curateAgentActivity( ? entries.map((entry) => entry.text).join("\n") : "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, + }; +} diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 439628d2d..1609fe4a2 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -97,6 +97,7 @@ const MODELS: AgentModelDefinition[] = [ interface ActiveTurn { turnId: string; + assistantMessageId: string; prompt: AgentPromptInput; startedAt: number; cycle: number; @@ -609,12 +610,14 @@ export class MockLoadTestAgentSession implements AgentSession { const profile = resolveModelProfile(this.modelId); const turnId = randomUUID(); + const assistantMessageId = randomUUID(); let resolve!: (result: AgentRunResult) => void; const completed = new Promise((promiseResolve) => { resolve = promiseResolve; }); const turn: ActiveTurn = { turnId, + assistantMessageId, prompt, startedAt: Date.now(), cycle: 0, @@ -860,6 +863,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: finalText, + messageId: turn.assistantMessageId, }); this.activeTurn = null; this.emit({ @@ -874,6 +878,7 @@ export class MockLoadTestAgentSession implements AgentSession { { type: "assistant_message", text: finalText, + messageId: turn.assistantMessageId, }, ], canceled: false, @@ -989,6 +994,7 @@ export class MockLoadTestAgentSession implements AgentSession { ? { type: "assistant_message", text: `stress-update-${index}`, + messageId: turn.assistantMessageId, } : { type: "todo", @@ -1067,6 +1073,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: `data:image/png;base64,${payload}`, + messageId: turn.assistantMessageId, }); } @@ -1133,6 +1140,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: event.text, + messageId: turn.assistantMessageId, }); return; } @@ -1178,6 +1186,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline(turn.turnId, { type: "assistant_message", text: "\n\n_(end of synthetic stream)_\n", + messageId: turn.assistantMessageId, }); this.finishTurnWithText(turn, "Synthetic load test complete"); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7b7a0931d..38cab91fe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -99,6 +99,7 @@ import { type TimelineProjectionEntry, type TimelineProjectionMode, } from "./agent/timeline-projection.js"; +import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, @@ -1371,6 +1372,7 @@ export class Session { this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRelationshipMessage(msg) ?? + this.dispatchAgentTimelineMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1450,6 +1452,17 @@ export class Session { } } + private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise | 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 | undefined { switch (msg.type) { case "fetch_agents_request": @@ -1484,8 +1497,6 @@ export class Session { return this.handleRefreshAgentRequest(msg); case "cancel_agent_request": return this.handleCancelAgentRequest(msg.agentId, msg.requestId); - case "fetch_agent_timeline_request": - return this.handleFetchAgentTimelineRequest(msg); case "agent_permission_response": return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); case "clear_agent_attention": @@ -5355,6 +5366,57 @@ export class Session { } } + private async handleAgentForkContextRequest( + msg: Extract, + ): Promise { + 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( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d9ccaccae..5387b63ea 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1200,6 +1200,8 @@ export class VoiceAssistantWebSocketServer { daemonDiagnostics: true, // COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13. daemonSelfUpdate: true, + // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. + agentForkContext: true, }, }; }