mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fork assistant turns into new drafts (#1788)
* feat(chat): fork assistant turns into new drafts Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace. * fix(chat): clean up fork draft handling * fix(chat): address fork draft review feedback * fix(server): build fork context from bounded timeline * fix(app): restore assistant turn footer in streams * fix(app): preserve fork draft setup * fix(app): tighten assistant fork footers * fix(app): lock active fork draft handoff * fix(app): preserve attachment-only draft retries * fix(app): unify composer attachment scopes * fix(app): align chat history attachment labels * fix(app): centralize attachment pill content * fix(app): trim fork context for workspace naming
This commit is contained in:
@@ -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();
|
||||
|
||||
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
121
packages/app/e2e/assistant-fork-menu.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentOptions,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
}>({
|
||||
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
await provide(async (options) => {
|
||||
const session = await seedMockAgentWorkspace(options);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
},
|
||||
});
|
||||
|
||||
async function openAssistantForkMenu(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await scrollAgentChatToBottom(page);
|
||||
return page.getByTestId("assistant-fork-menu-trigger").count();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("Chat history");
|
||||
}
|
||||
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-",
|
||||
title: "Assistant fork tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-assistant-fork-host",
|
||||
label: "Secondary host",
|
||||
// The host does not need to be reachable; this pins that the draft-scoped
|
||||
// attachment survives changing the selected target host.
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-workspace-",
|
||||
title: "Assistant fork workspace",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page
|
||||
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||
const chatScroll = getVisibleChatScroll(page);
|
||||
await chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
|
||||
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,
|
||||
|
||||
64
packages/app/src/agent-stream/turn-boundary.test.ts
Normal file
64
packages/app/src/agent-stream/turn-boundary.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
messageId?: string,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAssistantTurnBoundaryMessageId", () => {
|
||||
it("uses the selected assistant message id", () => {
|
||||
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), selected],
|
||||
startIndex: 1,
|
||||
}),
|
||||
).toBe("msg-assistant-1");
|
||||
});
|
||||
|
||||
it("does not borrow a boundary id from another assistant in the same turn", () => {
|
||||
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
|
||||
const selected = assistantMessage("assistant-2", 3);
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), first, selected],
|
||||
startIndex: 2,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires the selected item to be an assistant message", () => {
|
||||
expect(
|
||||
resolveAssistantTurnBoundaryMessageId({
|
||||
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
|
||||
startIndex: 0,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
13
packages/app/src/agent-stream/turn-boundary.ts
Normal file
13
packages/app/src/agent-stream/turn-boundary.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
items: readonly StreamItem[];
|
||||
startIndex: number;
|
||||
}): string | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
// Forking without the selected assistant's durable message id would send the wrong slice.
|
||||
return item.messageId || undefined;
|
||||
}
|
||||
@@ -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> | 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 (
|
||||
<TurnFooterRow>
|
||||
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
@@ -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 (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
missingAttachmentMessage: string;
|
||||
}): WorkspaceComposerAttachment {
|
||||
if (!input.payload.attachment) {
|
||||
throw new Error(input.missingAttachmentMessage);
|
||||
}
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: `chat_history:${input.draftId}`,
|
||||
attachment: input.payload.attachment,
|
||||
source: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
itemCount: input.payload.itemCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
|
||||
if (!agent.provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftTabTarget(
|
||||
setup: WorkspaceDraftTabSetup | undefined,
|
||||
draftId: string,
|
||||
): WorkspaceTabTarget {
|
||||
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamHead = useSessionStore((state) =>
|
||||
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<AgentStreamViewHandle, AgentStreamVi
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
agentId,
|
||||
boundaryMessageId ? { boundaryMessageId } : {},
|
||||
);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
payload,
|
||||
missingAttachmentMessage: t("message.actions.forkFailed"),
|
||||
});
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
||||
attachments: [attachment],
|
||||
});
|
||||
return draftId;
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
const draftId = await prepareForkDraft();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: buildForkDraftTabTarget(draftSetup, draftId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
setup: draftSetup,
|
||||
sourceDirectory,
|
||||
});
|
||||
}
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// 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<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
/>
|
||||
) : 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
|
||||
141
packages/app/src/attachments/attachment-pill-content.tsx
Normal file
141
packages/app/src/attachments/attachment-pill-content.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
MousePointer2,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export interface AttachmentPillContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getReviewSubtitle(count: number, t: TFunction): string {
|
||||
return count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count });
|
||||
}
|
||||
|
||||
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
}
|
||||
|
||||
function getTextAttachmentSubtitle(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return "Previous conversation";
|
||||
}
|
||||
return t("message.attachments.text");
|
||||
}
|
||||
|
||||
export function getAgentAttachmentPillContent(
|
||||
attachment: AgentAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
switch (attachment.type) {
|
||||
case "review":
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.comments.length, t),
|
||||
};
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment, t),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceAttachmentPillContent(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title,
|
||||
subtitle: getPullRequestContextSubtitle(attachment),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.commentCount, t),
|
||||
};
|
||||
}
|
||||
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
|
||||
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentBrowserIcon = (
|
||||
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
@@ -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<AgentAttachment, { type: "text" }>;
|
||||
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<AgentAttachment, { type: "review" }>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, readonly WorkspaceComposerAttachment[]>;
|
||||
}
|
||||
@@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
|
||||
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
|
||||
}
|
||||
|
||||
function areWorkspaceAttachmentsEqual(
|
||||
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<WorkspaceAttachmentsStore>()(
|
||||
},
|
||||
}));
|
||||
|
||||
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<string, readonly WorkspaceComposerAttachment[]>;
|
||||
scopeKeys: readonly string[];
|
||||
}): readonly WorkspaceComposerAttachment[] {
|
||||
const attachments: WorkspaceComposerAttachment[] = [];
|
||||
for (const scopeKey of input.scopeKeys) {
|
||||
const normalizedScopeKey = scopeKey.trim();
|
||||
if (!normalizedScopeKey) {
|
||||
continue;
|
||||
}
|
||||
attachments.push(
|
||||
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
|
||||
);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export function useWorkspaceAttachmentsForScopes(
|
||||
scopeKeys: readonly string[] | undefined,
|
||||
): readonly WorkspaceComposerAttachment[] {
|
||||
const normalizedScopeKeys = useMemo(
|
||||
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
|
||||
[scopeKeys],
|
||||
);
|
||||
const attachmentsByScope = useWorkspaceAttachmentsStore(
|
||||
useShallow((state) => state.attachmentsByScope),
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope,
|
||||
scopeKeys: normalizedScopeKeys,
|
||||
}),
|
||||
[attachmentsByScope, normalizedScopeKeys],
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkspaceAttachmentsStore(): void {
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
||||
}
|
||||
|
||||
148
packages/app/src/components/assistant-fork-menu.tsx
Normal file
148
packages/app/src/components/assistant-fork-menu.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { FolderPlus, Split } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export type AssistantForkTarget = "tab" | "workspace";
|
||||
|
||||
interface AssistantForkMenuProps {
|
||||
onFork: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedSplit = withUnistyles(Split);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function getIcon(target: AssistantForkTarget) {
|
||||
switch (target) {
|
||||
case "tab":
|
||||
return <ThemedSplit size={16} uniProps={foregroundColorMapping} />;
|
||||
case "workspace":
|
||||
return <ThemedFolderPlus size={16} uniProps={foregroundColorMapping} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const AssistantForkMenu = memo(function AssistantForkMenu({
|
||||
onFork,
|
||||
testID = "assistant-fork-menu",
|
||||
}: AssistantForkMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingTarget, setPendingTarget] = useState<AssistantForkTarget | null>(null);
|
||||
const isLocked = pendingTarget !== null;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next && pendingTarget !== null) return;
|
||||
setIsOpen(next);
|
||||
},
|
||||
[pendingTarget],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(target: AssistantForkTarget) => async () => {
|
||||
if (isLocked) return;
|
||||
setPendingTarget(target);
|
||||
try {
|
||||
await onFork(target);
|
||||
} finally {
|
||||
setPendingTarget(null);
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[isLocked, onFork],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
|
||||
[isLocked],
|
||||
);
|
||||
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("message.actions.forkMenu")}</Text>
|
||||
</TooltipContent>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<View style={styles.triggerSlot} collapsable={false}>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel={t("message.actions.forkMenu")}
|
||||
accessibilityRole="button"
|
||||
disabled={isLocked}
|
||||
style={triggerStyle}
|
||||
testID={`${testID}-trigger`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<ThemedSplit
|
||||
size={16}
|
||||
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent}
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" minWidth={220} side="bottom" testID={`${testID}-content`}>
|
||||
<DropdownMenuItem
|
||||
closeOnSelect={false}
|
||||
disabled={isLocked && pendingTarget !== "tab"}
|
||||
leading={getIcon("tab")}
|
||||
onSelect={handleSelect("tab")}
|
||||
status={pendingTarget === "tab" ? "pending" : undefined}
|
||||
testID={`${testID}-new-tab`}
|
||||
>
|
||||
{t("message.actions.forkInNewTab")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnSelect={false}
|
||||
disabled={isLocked && pendingTarget !== "workspace"}
|
||||
leading={getIcon("workspace")}
|
||||
onSelect={handleSelect("workspace")}
|
||||
status={pendingTarget === "workspace" ? "pending" : undefined}
|
||||
testID={`${testID}-new-workspace`}
|
||||
>
|
||||
{t("message.actions.forkInNewWorkspace")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
triggerSlot: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
@@ -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 = (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
interface UserMessageImagePillProps {
|
||||
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<typeof useTranslation>["t"],
|
||||
): UserMessageAttachmentContent {
|
||||
switch (attachment.type) {
|
||||
case "review": {
|
||||
const count = attachment.comments.length;
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle:
|
||||
count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count }),
|
||||
};
|
||||
}
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: t("message.attachments.text"),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getUserMessageAttachmentContent(attachment, t);
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
@@ -628,6 +561,11 @@ interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
forkBoundaryMessageId?: string;
|
||||
onFork?: (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | 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 (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
@@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{durationLabel ? (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
|
||||
70
packages/app/src/composer/attachments/workspace-cleanup.ts
Normal file
70
packages/app/src/composer/attachments/workspace-cleanup.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ComposerAttachment, WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
|
||||
export function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return JSON.stringify({
|
||||
type: "browser_element",
|
||||
url: attachment.attachment.url,
|
||||
selector: attachment.attachment.selector,
|
||||
tag: attachment.attachment.tag,
|
||||
text: attachment.attachment.text,
|
||||
html: attachment.attachment.outerHTML,
|
||||
});
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return JSON.stringify({
|
||||
kind: attachment.kind,
|
||||
id: attachment.id,
|
||||
});
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return JSON.stringify({
|
||||
kind: attachment.kind,
|
||||
id: attachment.id,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
type: "review",
|
||||
cwd: attachment.attachment.cwd,
|
||||
mode: attachment.attachment.mode,
|
||||
baseRef: attachment.attachment.baseRef ?? null,
|
||||
reviewDraftKey: attachment.reviewDraftKey,
|
||||
comments: attachment.attachment.comments.map((comment) => ({
|
||||
filePath: comment.filePath,
|
||||
side: comment.side,
|
||||
lineNumber: comment.lineNumber,
|
||||
body: comment.body,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
|
||||
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
|
||||
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
|
||||
const nextAttachments = attachments.filter(
|
||||
(attachment) => getAttachmentKey(attachment) !== selectedKey,
|
||||
);
|
||||
if (nextAttachments.length !== attachments.length) {
|
||||
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSentContextAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return (
|
||||
attachment.kind === "browser_element" ||
|
||||
attachment.kind === "chat_history" ||
|
||||
isPullRequestContextAttachment(attachment)
|
||||
);
|
||||
}
|
||||
|
||||
export function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
|
||||
const sentContextKeys = attachments.filter(isSentContextAttachment).map(getAttachmentKey);
|
||||
for (const key of sentContextKeys) {
|
||||
removeWorkspaceAttachmentsMatching(key);
|
||||
}
|
||||
}
|
||||
72
packages/app/src/composer/attachments/workspace.test.ts
Normal file
72
packages/app/src/composer/attachments/workspace.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import { removeSentContextAttachments } from "./workspace-cleanup";
|
||||
|
||||
function chatHistoryAttachment(): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: "chat_history:draft-1",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Previous chat.",
|
||||
},
|
||||
source: {
|
||||
serverId: "local",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pullRequestContextAttachment(): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: "comment-1",
|
||||
title: "Comment",
|
||||
text: "Please check this.",
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementAttachment(): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Click me",
|
||||
outerHTML: '<button class="primary">Click me</button>',
|
||||
computedStyles: {},
|
||||
boundingRect: { x: 0, y: 0, width: 100, height: 40 },
|
||||
reactSource: null,
|
||||
parentChain: [],
|
||||
children: [],
|
||||
formatted: "button.primary\nClick me",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace composer attachment cleanup", () => {
|
||||
it("clears sent scoped context attachments from their stores", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
|
||||
const chatHistory = chatHistoryAttachment();
|
||||
const pullRequestContext = pullRequestContextAttachment();
|
||||
const browserElement = browserElementAttachment();
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey,
|
||||
attachments: [chatHistory, pullRequestContext, browserElement],
|
||||
});
|
||||
|
||||
removeSentContextAttachments([chatHistory, pullRequestContext, browserElement]);
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useTranslation>["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<typeof useTranslation>["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<typeof useTranslation>["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 <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
function getPillTestID(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "chat_history") {
|
||||
return "composer-chat-history-attachment-pill";
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
}
|
||||
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
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 (
|
||||
<AttachmentPill
|
||||
testID="composer-review-attachment-pill"
|
||||
testID={getPillTestID(attachment)}
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
|
||||
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={renderPillIcon(attachment)}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -245,6 +245,12 @@ export const ar: TranslationResources = {
|
||||
copyCode: "نسخ الرمز",
|
||||
copyTurn: "نسخ بدوره",
|
||||
copyMessage: "انسخ الرسالة",
|
||||
forkMenu: "تفريع الرسالة",
|
||||
forkInNewTab: "تفريع في تبويب جديد",
|
||||
forkInNewWorkspace: "تفريع في مساحة عمل جديدة",
|
||||
forkUnavailable: "حدّث المضيف لاستخدام هذا.",
|
||||
forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.",
|
||||
forkFailed: "فشل تفريع المحادثة",
|
||||
openFile: "افتح الملف",
|
||||
copied: "منقول",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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é",
|
||||
},
|
||||
|
||||
@@ -248,6 +248,12 @@ export const ja: TranslationResources = {
|
||||
copyCode: "コードをコピー",
|
||||
copyTurn: "ターンをコピー",
|
||||
copyMessage: "メッセージをコピー",
|
||||
forkMenu: "メッセージをフォーク",
|
||||
forkInNewTab: "新しいタブにフォーク",
|
||||
forkInNewWorkspace: "新しいワークスペースにフォーク",
|
||||
forkUnavailable: "これを使用するにはホストを更新してください。",
|
||||
forkMissingWorkspace: "このエージェントはワークスペース内にありません。",
|
||||
forkFailed: "チャットのフォークに失敗しました",
|
||||
openFile: "ファイルを開く",
|
||||
copied: "コピーしました",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -247,6 +247,12 @@ export const ru: TranslationResources = {
|
||||
copyCode: "Скопировать код",
|
||||
copyTurn: "Копировать ход",
|
||||
copyMessage: "Копировать сообщение",
|
||||
forkMenu: "Форкнуть сообщение",
|
||||
forkInNewTab: "Форкнуть в новой вкладке",
|
||||
forkInNewWorkspace: "Форкнуть в новом рабочем пространстве",
|
||||
forkUnavailable: "Обновите хост, чтобы использовать это.",
|
||||
forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.",
|
||||
forkFailed: "Не удалось форкнуть чат",
|
||||
openFile: "Открыть файл",
|
||||
copied: "Скопировано",
|
||||
},
|
||||
|
||||
@@ -245,6 +245,12 @@ export const zhCN: TranslationResources = {
|
||||
copyCode: "复制代码",
|
||||
copyTurn: "复制回合",
|
||||
copyMessage: "复制消息",
|
||||
forkMenu: "分叉消息",
|
||||
forkInNewTab: "分叉到新标签页",
|
||||
forkInNewWorkspace: "分叉到新工作区",
|
||||
forkUnavailable: "请更新主机以使用此功能。",
|
||||
forkMissingWorkspace: "此 Agent 不在工作区中。",
|
||||
forkFailed: "分叉聊天失败",
|
||||
openFile: "打开文件",
|
||||
copied: "已复制",
|
||||
},
|
||||
|
||||
@@ -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<AgentLookupState>({ 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}
|
||||
|
||||
49
packages/app/src/screens/new-workspace-fork-context.test.ts
Normal file
49
packages/app/src/screens/new-workspace-fork-context.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
getWorkspaceNamingAttachments,
|
||||
remapDraftCwdToWorkspace,
|
||||
} from "./new-workspace-fork-context";
|
||||
|
||||
describe("remapDraftCwdToWorkspace", () => {
|
||||
it("preserves a Windows subdirectory when source path casing differs", () => {
|
||||
expect(
|
||||
remapDraftCwdToWorkspace({
|
||||
cwd: "c:\\Repo\\packages\\app",
|
||||
sourceDirectory: "C:\\Repo",
|
||||
workspaceDirectory: "D:\\Worktrees\\fork",
|
||||
}),
|
||||
).toBe("D:\\Worktrees\\fork\\packages\\app");
|
||||
});
|
||||
|
||||
it("falls back to the workspace root when the cwd is outside the source directory", () => {
|
||||
expect(
|
||||
remapDraftCwdToWorkspace({
|
||||
cwd: "/other/repo/packages/app",
|
||||
sourceDirectory: "/repo",
|
||||
workspaceDirectory: "/worktrees/fork",
|
||||
}),
|
||||
).toBe("/worktrees/fork");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceNamingAttachments", () => {
|
||||
it("removes full chat history from workspace naming context", () => {
|
||||
const chatHistory = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Long prior conversation",
|
||||
} satisfies AgentAttachment;
|
||||
const prContext = {
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 1788,
|
||||
title: "Fork assistant turns into new drafts",
|
||||
url: "https://github.com/getpaseo/paseo/pull/1788",
|
||||
} satisfies AgentAttachment;
|
||||
|
||||
expect(getWorkspaceNamingAttachments([chatHistory, prContext])).toEqual([prContext]);
|
||||
});
|
||||
});
|
||||
50
packages/app/src/screens/new-workspace-fork-context.ts
Normal file
50
packages/app/src/screens/new-workspace-fork-context.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
function isLikelyWindowsPath(path: string): boolean {
|
||||
return /^[a-zA-Z]:\//.test(path);
|
||||
}
|
||||
|
||||
function isChatHistoryTextAttachment(attachment: AgentAttachment): boolean {
|
||||
return attachment.type === "text" && attachment.contextKind === "chat_history";
|
||||
}
|
||||
|
||||
export function getWorkspaceNamingAttachments(
|
||||
attachments: readonly AgentAttachment[],
|
||||
): AgentAttachment[] {
|
||||
return attachments.filter((attachment) => !isChatHistoryTextAttachment(attachment));
|
||||
}
|
||||
|
||||
export function remapDraftCwdToWorkspace(input: {
|
||||
cwd: string;
|
||||
sourceDirectory?: string | null;
|
||||
workspaceDirectory: string;
|
||||
}): string {
|
||||
const cwd = input.cwd.trim();
|
||||
const sourceDirectory = input.sourceDirectory?.trim();
|
||||
const workspaceDirectory = input.workspaceDirectory.trim();
|
||||
if (!cwd || !sourceDirectory) {
|
||||
return workspaceDirectory;
|
||||
}
|
||||
const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedSource = sourceDirectory.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const compareCaseInsensitively =
|
||||
isLikelyWindowsPath(normalizedCwd) || isLikelyWindowsPath(normalizedSource);
|
||||
const comparableCwd = compareCaseInsensitively ? normalizedCwd.toLowerCase() : normalizedCwd;
|
||||
const comparableSource = compareCaseInsensitively
|
||||
? normalizedSource.toLowerCase()
|
||||
: normalizedSource;
|
||||
if (comparableCwd === comparableSource) {
|
||||
return workspaceDirectory;
|
||||
}
|
||||
const relativePath = comparableCwd.startsWith(`${comparableSource}/`)
|
||||
? normalizedCwd.slice(normalizedSource.length + 1)
|
||||
: "";
|
||||
if (!relativePath) {
|
||||
return workspaceDirectory;
|
||||
}
|
||||
const separator =
|
||||
workspaceDirectory.includes("\\") && !workspaceDirectory.includes("/") ? "\\" : "/";
|
||||
return [workspaceDirectory.replace(/[\\/]+$/, ""), ...relativePath.split("/")]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
@@ -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<ReturnType<typeof useAgentInputDraft>["composerState"]>;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
}
|
||||
|
||||
type NewWorkspaceComposerState = NonNullable<
|
||||
ReturnType<typeof useAgentInputDraft>["composerState"]
|
||||
>;
|
||||
|
||||
interface WorkspaceDraftSubmissionConfig {
|
||||
cwd: string;
|
||||
provider: AgentProvider;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
async function createAndMergeWorkspace(input: {
|
||||
@@ -820,6 +890,7 @@ async function createMultiplicityWorkspace(input: {
|
||||
interface CreateChatAgentInput {
|
||||
payload: MessagePayload;
|
||||
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
|
||||
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
|
||||
ensureWorkspace: (input: {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
@@ -828,12 +899,67 @@ interface CreateChatAgentInput {
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
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<void> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
|
||||
const { text, attachments, cwd } = payload;
|
||||
@@ -845,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
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<typeof useAgentInputDraft>[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<UserComposerAttach
|
||||
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
|
||||
}
|
||||
|
||||
function usePendingWorkspaceDraftSetup(
|
||||
draftId: string | undefined,
|
||||
): PendingWorkspaceDraftSetup | null {
|
||||
const normalizedDraftId = draftId?.trim() ?? "";
|
||||
return useWorkspaceDraftSubmissionStore((state) => {
|
||||
if (!normalizedDraftId) {
|
||||
return null;
|
||||
}
|
||||
return state.setupByDraftId[normalizedDraftId] ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
draftId: string;
|
||||
workspaceDirectory: string;
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
}): WorkspaceDraftSubmissionConfig {
|
||||
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
|
||||
if (initialSetup) {
|
||||
return {
|
||||
cwd: initialSetup.cwd,
|
||||
provider: initialSetup.provider,
|
||||
modeId: initialSetup.modeId,
|
||||
model: initialSetup.model,
|
||||
thinkingOptionId: initialSetup.thinkingOptionId,
|
||||
featureValues: initialSetup.featureValues,
|
||||
target: { kind: "draft", draftId, setup: initialSetup },
|
||||
};
|
||||
}
|
||||
return {
|
||||
cwd: workspaceDirectory,
|
||||
provider,
|
||||
modeId: composerState.selectedMode || null,
|
||||
model: composerState.effectiveModelId || null,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: composerState.featureValues,
|
||||
target: { kind: "draft", draftId },
|
||||
};
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
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<View>(null);
|
||||
const isolationPickerAnchorRef = useRef<View>(null);
|
||||
const hostPickerAnchorRef = useRef<View | null>(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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, PendingCreateAttempt>;
|
||||
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
|
||||
|
||||
@@ -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<string, PendingWorkspaceDraftSubmission>;
|
||||
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
|
||||
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<WorkspaceDraftSubmissionState>(
|
||||
(set, get) => ({
|
||||
pendingByDraftId: {},
|
||||
setupByDraftId: {},
|
||||
setPending: (submission) =>
|
||||
set((state) => ({
|
||||
pendingByDraftId: {
|
||||
@@ -50,6 +68,25 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
[submission.draftId]: submission,
|
||||
},
|
||||
})),
|
||||
setDraftSetup: ({ draftId, setup, sourceDirectory }) => {
|
||||
const normalizedDraftId = normalizeDraftId(draftId);
|
||||
if (!normalizedDraftId) return;
|
||||
set((state) => ({
|
||||
setupByDraftId: {
|
||||
...state.setupByDraftId,
|
||||
[normalizedDraftId]: { setup, sourceDirectory: sourceDirectory ?? null },
|
||||
},
|
||||
}));
|
||||
},
|
||||
clearDraftSetup: ({ draftId }) => {
|
||||
const normalizedDraftId = normalizeDraftId(draftId);
|
||||
if (!normalizedDraftId) return;
|
||||
set((state) => {
|
||||
if (!state.setupByDraftId[normalizedDraftId]) return state;
|
||||
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
|
||||
return { setupByDraftId };
|
||||
});
|
||||
},
|
||||
consumePending: (input) => {
|
||||
const pending = get().pendingByDraftId[input.draftId];
|
||||
if (!matchesPendingSubmission(pending, input)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof AgentRefreshedStatusPayloadSchema>;
|
||||
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
|
||||
@@ -2405,6 +2411,41 @@ export class DaemonClient {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async buildAgentForkContext(
|
||||
agentId: string,
|
||||
options: AgentForkContextOptions = {},
|
||||
): Promise<AgentForkContextPayload> {
|
||||
const resolvedRequestId = this.createRequestId(options.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "agent.fork_context.request",
|
||||
agentId,
|
||||
requestId: resolvedRequestId,
|
||||
...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}),
|
||||
});
|
||||
|
||||
const payload = await this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "agent.fork_context.response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Interaction
|
||||
// ============================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof FetchAgentResponseMessage
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
typeof FetchAgentTimelineResponseMessageSchema
|
||||
>;
|
||||
export type AgentForkContextResponseMessage = z.infer<typeof AgentForkContextResponseMessageSchema>;
|
||||
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
|
||||
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
||||
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
|
||||
@@ -4410,6 +4440,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
|
||||
>;
|
||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
||||
export type DictationStreamStartMessage = z.infer<typeof DictationStreamStartMessageSchema>;
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AgentAttachment, { type: "text" }>;
|
||||
|
||||
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<AgentTimelineItem, { type: "tool_call" }>,
|
||||
options?: ActivityCuratorOptions,
|
||||
): ActivityEntry {
|
||||
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
error: item.error,
|
||||
detail: item.detail,
|
||||
metadata: item.metadata,
|
||||
});
|
||||
const displayName = display.displayName;
|
||||
const summary = formatToolSummary(display.summary);
|
||||
if (
|
||||
(options?.includeExternalToolInput ?? true) &&
|
||||
isLikelyExternalToolName(item.name) &&
|
||||
inputJson
|
||||
) {
|
||||
return activityEntry(`[${displayName}] ${inputJson}`);
|
||||
}
|
||||
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
|
||||
}
|
||||
|
||||
function curateProjectedActivityEntries(
|
||||
items: readonly AgentTimelineItem[],
|
||||
options?: ActivityCuratorOptions,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AgentRunResult>((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");
|
||||
}
|
||||
|
||||
@@ -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<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "fetch_agent_timeline_request":
|
||||
return this.handleFetchAgentTimelineRequest(msg);
|
||||
case "agent.fork_context.request":
|
||||
return this.handleAgentForkContextRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
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<SessionInboundMessage, { type: "agent.fork_context.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const snapshot = await ensureAgentLoaded(msg.agentId, {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
const rows = this.agentManager.fetchTimeline(msg.agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
}).rows;
|
||||
const forkContext = buildAgentForkContextAttachment({
|
||||
rows,
|
||||
boundaryMessageId: msg.boundaryMessageId,
|
||||
agentTitle: agentPayload.title,
|
||||
cwd: snapshot.cwd,
|
||||
});
|
||||
|
||||
this.emit({
|
||||
type: "agent.fork_context.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
attachment: forkContext.attachment,
|
||||
itemCount: forkContext.itemCount,
|
||||
boundaryMessageId: forkContext.boundaryMessageId,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId: msg.agentId },
|
||||
"Failed to handle agent.fork_context.request",
|
||||
);
|
||||
this.emit({
|
||||
type: "agent.fork_context.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
attachment: null,
|
||||
itemCount: 0,
|
||||
boundaryMessageId: msg.boundaryMessageId ?? null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSendAgentMessageRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user