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 {
|
import {
|
||||||
expectScrollStaysFixed,
|
expectScrollStaysFixed,
|
||||||
readScrollMetrics,
|
readScrollMetrics,
|
||||||
|
scrollAgentChatToBottom,
|
||||||
scrollChatAwayFromBottom,
|
scrollChatAwayFromBottom,
|
||||||
waitForScrollableChat,
|
waitForScrollableChat,
|
||||||
} from "./helpers/agent-bottom-anchor";
|
} from "./helpers/agent-bottom-anchor";
|
||||||
@@ -18,6 +19,8 @@ import { clickNewChat } from "./helpers/launcher";
|
|||||||
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
|
|
||||||
|
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
|
||||||
|
|
||||||
test.describe("Agent stream UI", () => {
|
test.describe("Agent stream UI", () => {
|
||||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
@@ -54,7 +57,10 @@ test.describe("Agent stream UI", () => {
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await awaitAssistantMessage(page);
|
await awaitAssistantMessage(page);
|
||||||
await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 30_000 });
|
await waitForScrollableChat(page, {
|
||||||
|
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
const baseline = await scrollChatAwayFromBottom(page, {
|
const baseline = await scrollChatAwayFromBottom(page, {
|
||||||
deltaY: -900,
|
deltaY: -900,
|
||||||
minDistanceFromBottom: 300,
|
minDistanceFromBottom: 300,
|
||||||
@@ -100,7 +106,10 @@ test.describe("Agent stream UI", () => {
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await awaitAssistantMessage(page);
|
await awaitAssistantMessage(page);
|
||||||
await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 45_000 });
|
await waitForScrollableChat(page, {
|
||||||
|
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||||
|
timeout: 45_000,
|
||||||
|
});
|
||||||
const baseline = await scrollChatAwayFromBottom(page, {
|
const baseline = await scrollChatAwayFromBottom(page, {
|
||||||
deltaY: -900,
|
deltaY: -900,
|
||||||
minDistanceFromBottom: 300,
|
minDistanceFromBottom: 300,
|
||||||
@@ -122,6 +131,7 @@ test.describe("Agent stream UI", () => {
|
|||||||
await awaitAssistantMessage(page);
|
await awaitAssistantMessage(page);
|
||||||
await expectInlineWorkingIndicator(page);
|
await expectInlineWorkingIndicator(page);
|
||||||
await expectAgentIdle(page, 30_000);
|
await expectAgentIdle(page, 30_000);
|
||||||
|
await scrollAgentChatToBottom(page);
|
||||||
await expectTurnCopyButton(page);
|
await expectTurnCopyButton(page);
|
||||||
} finally {
|
} finally {
|
||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
|
|||||||
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);
|
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||||
|
const chatScroll = getVisibleChatScroll(page);
|
||||||
|
await chatScroll.evaluate((root: Element) => {
|
||||||
|
const scrollElement = root as HTMLElement;
|
||||||
|
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
chatScroll.evaluate((root: Element) => {
|
||||||
|
const scrollElement = root as HTMLElement;
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForContentGrowth(
|
export async function waitForContentGrowth(
|
||||||
page: Page,
|
page: Page,
|
||||||
previousContentHeight: number,
|
previousContentHeight: number,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|
||||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||||
import { getServerId } from "./server-id";
|
import { getServerId } from "./server-id";
|
||||||
|
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
export interface MockAgentWorkspace {
|
export interface MockAgentWorkspace {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
||||||
`agent:${agentId}`,
|
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||||
|
|||||||
@@ -289,4 +289,67 @@ describe("layoutStream", () => {
|
|||||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["web", "android"] as const)(
|
||||||
|
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
|
||||||
|
(platform) => {
|
||||||
|
const assistant = assistantMessage("a1", 2);
|
||||||
|
const tool = toolCall("tool-1", 3);
|
||||||
|
const layout = layoutFor({
|
||||||
|
platform,
|
||||||
|
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
|
||||||
|
timingIds: [assistant.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||||
|
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
|
||||||
|
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["web", "android"] as const)(
|
||||||
|
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
|
||||||
|
(platform) => {
|
||||||
|
const firstAssistant = assistantMessage("a1", 2);
|
||||||
|
const firstTool = toolCall("tool-1", 3);
|
||||||
|
const latestAssistant = assistantMessage("a2", 4);
|
||||||
|
const latestTool = toolCall("tool-2", 5);
|
||||||
|
const layout = layoutFor({
|
||||||
|
platform,
|
||||||
|
tail: [
|
||||||
|
userMessage("u1", 1),
|
||||||
|
firstAssistant,
|
||||||
|
firstTool,
|
||||||
|
latestAssistant,
|
||||||
|
latestTool,
|
||||||
|
userMessage("u2", 6),
|
||||||
|
],
|
||||||
|
timingIds: [firstAssistant.id, latestAssistant.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||||
|
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||||
|
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
|
||||||
|
latestAssistant.id,
|
||||||
|
);
|
||||||
|
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["web", "android"] as const)(
|
||||||
|
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
|
||||||
|
(platform) => {
|
||||||
|
const assistant = assistantMessage("a1", 2);
|
||||||
|
const tool = toolCall("tool-1", 3);
|
||||||
|
const layout = layoutFor({
|
||||||
|
platform,
|
||||||
|
tail: [userMessage("u1", 1), assistant, tool],
|
||||||
|
timingIds: [assistant.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||||
|
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||||
|
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,18 +68,48 @@ function createTurnFooterHost(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLatestAssistantIndexInTurn(input: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
startIndex: number;
|
||||||
|
}): number | null {
|
||||||
|
for (
|
||||||
|
let index = input.startIndex;
|
||||||
|
index >= 0 && index < input.items.length;
|
||||||
|
index = input.strategy.getNeighborIndex(index, "above")
|
||||||
|
) {
|
||||||
|
const item = input.items[index];
|
||||||
|
if (!item || item.kind === "user_message") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (item.kind === "assistant_message") {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||||
if (input.agentStatus === "running") {
|
if (input.agentStatus === "running") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||||
if (startIndex === null) {
|
if (latestIndex === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = footerItems[startIndex];
|
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: footerItems,
|
||||||
|
startIndex: latestIndex,
|
||||||
|
});
|
||||||
|
if (assistantIndex === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = footerItems[assistantIndex];
|
||||||
if (!item || item.kind !== "assistant_message") {
|
if (!item || item.kind !== "assistant_message") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -87,26 +117,79 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
|||||||
return createTurnFooterHost({
|
return createTurnFooterHost({
|
||||||
item,
|
item,
|
||||||
items: footerItems,
|
items: footerItems,
|
||||||
index: startIndex,
|
index: assistantIndex,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByAssistantId: input.timingByAssistantId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findTurnEndIndexInSegment(input: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
startIndex: number;
|
||||||
|
}): number {
|
||||||
|
let endIndex = input.startIndex;
|
||||||
|
for (
|
||||||
|
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
|
||||||
|
index >= 0 && index < input.items.length;
|
||||||
|
index = input.strategy.getNeighborIndex(index, "below")
|
||||||
|
) {
|
||||||
|
const item = input.items[index];
|
||||||
|
if (!item || item.kind === "user_message") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
endIndex = index;
|
||||||
|
}
|
||||||
|
return endIndex;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldRenderCompletedFooter(input: {
|
function shouldRenderCompletedFooter(input: {
|
||||||
|
strategy: StreamStrategy;
|
||||||
|
items: StreamItem[];
|
||||||
|
index: number;
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
belowItem: StreamItem | null;
|
belowItem: StreamItem | null;
|
||||||
agentStatus: string;
|
agentStatus: string;
|
||||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
if (
|
||||||
input.item.kind === "assistant_message" &&
|
input.item.kind !== "assistant_message" ||
|
||||||
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
|
input.auxiliaryTurnFooter?.itemId === input.item.id
|
||||||
(input.belowItem?.kind === "user_message" ||
|
) {
|
||||||
(input.belowItem === null && input.agentStatus !== "running"))
|
return false;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.belowItem?.kind === "user_message" ||
|
||||||
|
(input.belowItem === null && input.agentStatus !== "running")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isToolSequenceItem(input.belowItem)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
|
||||||
|
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnEndIndex = findTurnEndIndexInSegment({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: input.items,
|
||||||
|
startIndex: input.index,
|
||||||
|
});
|
||||||
|
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: input.items,
|
||||||
|
startIndex: turnEndIndex,
|
||||||
|
});
|
||||||
|
return assistantIndex === input.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToolSequenceItem(item: StreamItem | null): boolean {
|
function isToolSequenceItem(
|
||||||
|
item: StreamItem | null,
|
||||||
|
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
|
||||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +258,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
|||||||
belowItem,
|
belowItem,
|
||||||
});
|
});
|
||||||
const completedFooter = shouldRenderCompletedFooter({
|
const completedFooter = shouldRenderCompletedFooter({
|
||||||
|
strategy: input.strategy,
|
||||||
|
items: input.items,
|
||||||
|
index,
|
||||||
item,
|
item,
|
||||||
belowItem,
|
belowItem,
|
||||||
agentStatus: input.agentStatus,
|
agentStatus: input.agentStatus,
|
||||||
|
|||||||
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,
|
collectAssistantTurnContentForStreamRenderStrategy,
|
||||||
type StreamStrategy,
|
type StreamStrategy,
|
||||||
} from "./strategy";
|
} from "./strategy";
|
||||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||||
|
import {
|
||||||
|
AssistantTurnFooter,
|
||||||
|
LiveElapsed,
|
||||||
|
STREAM_METADATA_FONT_SIZE,
|
||||||
|
type AssistantForkTarget,
|
||||||
|
} from "@/components/message";
|
||||||
import type { TurnFooterHost } from "./layout";
|
import type { TurnFooterHost } from "./layout";
|
||||||
import { SyncedLoader } from "@/components/synced-loader";
|
import { SyncedLoader } from "@/components/synced-loader";
|
||||||
|
|
||||||
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type TurnContentStrategy = StreamStrategy;
|
export type TurnContentStrategy = StreamStrategy;
|
||||||
|
export type AssistantTurnForkHandler = (input: {
|
||||||
|
target: AssistantForkTarget;
|
||||||
|
boundaryMessageId?: string;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
|
||||||
export const TurnFooter = memo(function TurnFooter({
|
export const TurnFooter = memo(function TurnFooter({
|
||||||
isRunning,
|
isRunning,
|
||||||
inFlightTurnStartedAt,
|
inFlightTurnStartedAt,
|
||||||
host,
|
host,
|
||||||
strategy,
|
strategy,
|
||||||
|
onForkAssistantTurn,
|
||||||
}: {
|
}: {
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
inFlightTurnStartedAt: Date | null;
|
inFlightTurnStartedAt: Date | null;
|
||||||
host: TurnFooterHost | null;
|
host: TurnFooterHost | null;
|
||||||
strategy: TurnContentStrategy;
|
strategy: TurnContentStrategy;
|
||||||
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||||
}) {
|
}) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
|
|||||||
items={host.items}
|
items={host.items}
|
||||||
timing={host.timing}
|
timing={host.timing}
|
||||||
startIndex={host.startIndex}
|
startIndex={host.startIndex}
|
||||||
|
onForkAssistantTurn={onForkAssistantTurn}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
|||||||
items,
|
items,
|
||||||
timing,
|
timing,
|
||||||
startIndex,
|
startIndex,
|
||||||
|
onForkAssistantTurn,
|
||||||
}: {
|
}: {
|
||||||
strategy: TurnContentStrategy;
|
strategy: TurnContentStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
timing?: TurnTiming;
|
timing?: TurnTiming;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TurnFooterRow>
|
<TurnFooterRow>
|
||||||
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
|||||||
items={items}
|
items={items}
|
||||||
timing={timing}
|
timing={timing}
|
||||||
startIndex={startIndex}
|
startIndex={startIndex}
|
||||||
|
onForkAssistantTurn={onForkAssistantTurn}
|
||||||
/>
|
/>
|
||||||
</TurnFooterRow>
|
</TurnFooterRow>
|
||||||
);
|
);
|
||||||
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
|
|||||||
items,
|
items,
|
||||||
timing,
|
timing,
|
||||||
startIndex,
|
startIndex,
|
||||||
|
onForkAssistantTurn,
|
||||||
}: {
|
}: {
|
||||||
strategy: TurnContentStrategy;
|
strategy: TurnContentStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
timing?: TurnTiming;
|
timing?: TurnTiming;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||||
}) {
|
}) {
|
||||||
const getContent = useCallback(
|
const getContent = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
|
|||||||
}),
|
}),
|
||||||
[strategy, items, startIndex],
|
[strategy, items, startIndex],
|
||||||
);
|
);
|
||||||
|
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
|
||||||
|
items,
|
||||||
|
startIndex,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.turnFooterSlot}>
|
<View style={stylesheet.turnFooterSlot}>
|
||||||
<AssistantTurnFooter
|
<AssistantTurnFooter
|
||||||
getContent={getContent}
|
getContent={getContent}
|
||||||
completedAt={timing?.completedAt}
|
completedAt={timing?.completedAt}
|
||||||
durationMs={timing?.durationMs}
|
durationMs={timing?.durationMs}
|
||||||
|
forkBoundaryMessageId={boundaryMessageId}
|
||||||
|
onFork={onForkAssistantTurn}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import React, {
|
|||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
|||||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
import {
|
||||||
|
CompletedTurnFooterRow,
|
||||||
|
TurnFooter,
|
||||||
|
type AssistantTurnForkHandler,
|
||||||
|
type TurnContentStrategy,
|
||||||
|
} from "./turn-footer";
|
||||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||||
import {
|
import {
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
@@ -76,11 +82,21 @@ import {
|
|||||||
type WorkspaceFileOpenRequest,
|
type WorkspaceFileOpenRequest,
|
||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||||
import { MountedTabActiveContext } from "@/components/split-container";
|
import { MountedTabActiveContext } from "@/components/split-container";
|
||||||
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
|
import {
|
||||||
|
buildDraftWorkspaceAttachmentScopeKey,
|
||||||
|
useWorkspaceAttachmentsStore,
|
||||||
|
} from "@/attachments/workspace-attachments-store";
|
||||||
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
|
|
||||||
function renderLiveAuxiliaryNode(input: {
|
function renderLiveAuxiliaryNode(input: {
|
||||||
pendingPermissions: ReactNode;
|
pendingPermissions: ReactNode;
|
||||||
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
|
|||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
layoutItem: StreamLayoutItem;
|
layoutItem: StreamLayoutItem;
|
||||||
strategy: TurnContentStrategy;
|
strategy: TurnContentStrategy;
|
||||||
|
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
if (!input.content) {
|
if (!input.content) {
|
||||||
return null;
|
return null;
|
||||||
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
|
|||||||
items={footerHost.items}
|
items={footerHost.items}
|
||||||
timing={footerHost.timing}
|
timing={footerHost.timing}
|
||||||
startIndex={footerHost.startIndex}
|
startIndex={footerHost.startIndex}
|
||||||
|
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
const content = (
|
const content = (
|
||||||
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
|||||||
|
|
||||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||||
|
|
||||||
|
function buildChatHistoryAttachment(input: {
|
||||||
|
draftId: string;
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||||
|
missingAttachmentMessage: string;
|
||||||
|
}): WorkspaceComposerAttachment {
|
||||||
|
if (!input.payload.attachment) {
|
||||||
|
throw new Error(input.missingAttachmentMessage);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "chat_history",
|
||||||
|
id: `chat_history:${input.draftId}`,
|
||||||
|
attachment: input.payload.attachment,
|
||||||
|
source: {
|
||||||
|
serverId: input.serverId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
boundaryMessageId: input.payload.boundaryMessageId,
|
||||||
|
itemCount: input.payload.itemCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
|
||||||
|
if (!agent.provider) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureValues: Record<string, unknown> = {};
|
||||||
|
for (const feature of agent.features ?? []) {
|
||||||
|
featureValues[feature.id] = feature.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: agent.provider,
|
||||||
|
cwd: agent.cwd,
|
||||||
|
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||||
|
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||||
|
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||||
|
featureValues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForkDraftTabTarget(
|
||||||
|
setup: WorkspaceDraftTabSetup | undefined,
|
||||||
|
draftId: string,
|
||||||
|
): WorkspaceTabTarget {
|
||||||
|
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||||
|
}
|
||||||
|
|
||||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||||
function AgentStreamView(
|
function AgentStreamView(
|
||||||
{
|
{
|
||||||
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const streamHead = useSessionStore((state) =>
|
const streamHead = useSessionStore((state) =>
|
||||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||||
);
|
);
|
||||||
|
const supportsAgentForkContext = useSessionStore(
|
||||||
|
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||||
|
);
|
||||||
|
|
||||||
const workspaceRoot = agent.cwd?.trim() || "";
|
const workspaceRoot = agent.cwd?.trim() || "";
|
||||||
const { requestDirectoryListing } = useFileExplorerActions({
|
const { requestDirectoryListing } = useFileExplorerActions({
|
||||||
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||||
|
async ({ target, boundaryMessageId }) => {
|
||||||
|
try {
|
||||||
|
if (!supportsAgentForkContext) {
|
||||||
|
toast?.error(t("message.actions.forkUnavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
|
}
|
||||||
|
const draftSetup = buildForkDraftSetup(agent);
|
||||||
|
const prepareForkDraft = async () => {
|
||||||
|
const draftId = generateDraftId();
|
||||||
|
const payload = await client.buildAgentForkContext(
|
||||||
|
agentId,
|
||||||
|
boundaryMessageId ? { boundaryMessageId } : {},
|
||||||
|
);
|
||||||
|
const attachment = buildChatHistoryAttachment({
|
||||||
|
draftId,
|
||||||
|
serverId: resolvedServerId,
|
||||||
|
agentId,
|
||||||
|
payload,
|
||||||
|
missingAttachmentMessage: t("message.actions.forkFailed"),
|
||||||
|
});
|
||||||
|
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||||
|
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
||||||
|
attachments: [attachment],
|
||||||
|
});
|
||||||
|
return draftId;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target === "tab") {
|
||||||
|
const workspaceId = agent.workspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||||
|
}
|
||||||
|
const draftId = await prepareForkDraft();
|
||||||
|
navigateToPreparedWorkspaceTab({
|
||||||
|
serverId: resolvedServerId,
|
||||||
|
workspaceId,
|
||||||
|
target: buildForkDraftTabTarget(draftSetup, draftId),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftId = await prepareForkDraft();
|
||||||
|
const sourceDirectory =
|
||||||
|
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||||
|
if (draftSetup) {
|
||||||
|
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||||
|
draftId,
|
||||||
|
setup: draftSetup,
|
||||||
|
sourceDirectory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.push(
|
||||||
|
buildNewWorkspaceRoute({
|
||||||
|
serverId: resolvedServerId,
|
||||||
|
sourceDirectory,
|
||||||
|
displayName: agent.projectPlacement?.projectName,
|
||||||
|
projectId: agent.projectPlacement?.projectKey,
|
||||||
|
draftId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||||
// cell-window renders on every 48ms flush from background agents.
|
// cell-window renders on every 48ms flush from background agents.
|
||||||
// When isActive flips back to true, the context change triggers a re-render and
|
// When isActive flips back to true, the context change triggers a re-render and
|
||||||
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
content,
|
content,
|
||||||
layoutItem,
|
layoutItem,
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
|
onForkAssistantTurn: handleForkAssistantTurn,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[renderStreamItemContent, streamRenderStrategy],
|
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingPermissionItems = useMemo(
|
const pendingPermissionItems = useMemo(
|
||||||
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||||
host={bottomTurnFooterHost}
|
host={bottomTurnFooterHost}
|
||||||
strategy={streamRenderStrategy}
|
strategy={streamRenderStrategy}
|
||||||
|
onForkAssistantTurn={handleForkAssistantTurn}
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
[
|
[
|
||||||
|
handleForkAssistantTurn,
|
||||||
showRunningTurnFooter,
|
showRunningTurnFooter,
|
||||||
baseRenderModel.turnTiming.runningStartedAt,
|
baseRenderModel.turnTiming.runningStartedAt,
|
||||||
bottomTurnFooterHost,
|
bottomTurnFooterHost,
|
||||||
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
|
|||||||
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectAgentProjectPlacementDiffs(
|
||||||
|
left: AgentScreenAgent["projectPlacement"],
|
||||||
|
right: AgentScreenAgent["projectPlacement"],
|
||||||
|
): string[] {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
|
||||||
|
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||||
|
}
|
||||||
|
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
|
||||||
|
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||||
|
}
|
||||||
|
if (left?.projectName !== right?.projectName) {
|
||||||
|
reasons.push("agent.projectPlacement.projectName");
|
||||||
|
}
|
||||||
|
if (left?.projectKey !== right?.projectKey) {
|
||||||
|
reasons.push("agent.projectPlacement.projectKey");
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (left.provider !== right.provider) reasons.push("agent.provider");
|
||||||
|
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
|
||||||
|
if (left.model !== right.model) reasons.push("agent.model");
|
||||||
|
if (left.thinkingOptionId !== right.thinkingOptionId) {
|
||||||
|
reasons.push("agent.thinkingOptionId");
|
||||||
|
}
|
||||||
|
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
|
||||||
|
reasons.push("agent.runtimeInfo.modeId");
|
||||||
|
}
|
||||||
|
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
|
||||||
|
reasons.push("agent.runtimeInfo.model");
|
||||||
|
}
|
||||||
|
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||||
|
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||||
|
}
|
||||||
|
if (left.features !== right.features) reasons.push("agent.features");
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
|
||||||
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
||||||
if (left.id !== right.id) reasons.push("agent.id");
|
if (left.id !== right.id) reasons.push("agent.id");
|
||||||
|
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
|
||||||
if (left.status !== right.status) reasons.push("agent.status");
|
if (left.status !== right.status) reasons.push("agent.status");
|
||||||
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
||||||
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
||||||
reasons.push("agent.capabilities");
|
reasons.push("agent.capabilities");
|
||||||
}
|
}
|
||||||
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
||||||
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
|
reasons.push(...collectAgentSetupDiffs(left, right));
|
||||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
|
||||||
}
|
|
||||||
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
|
|
||||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
|
||||||
}
|
|
||||||
return reasons;
|
return reasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ export default function NewWorkspaceRoute() {
|
|||||||
dir?: string;
|
dir?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
draftId?: string;
|
||||||
}>();
|
}>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
||||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||||
|
const draftId = typeof params.draftId === "string" ? params.draftId : undefined;
|
||||||
const screenKey = JSON.stringify([
|
const screenKey = JSON.stringify([
|
||||||
serverId,
|
serverId,
|
||||||
sourceDirectory ?? null,
|
sourceDirectory ?? null,
|
||||||
displayName ?? null,
|
displayName ?? null,
|
||||||
projectId ?? null,
|
projectId ?? null,
|
||||||
|
draftId ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +31,7 @@ export default function NewWorkspaceRoute() {
|
|||||||
sourceDirectory={sourceDirectory}
|
sourceDirectory={sourceDirectory}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
draftId={draftId}
|
||||||
/>
|
/>
|
||||||
</HostRouteBootstrapBoundary>
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
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_review" } & PullRequestContextAttachmentFields)
|
||||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||||
|
|
||||||
|
export interface ChatHistoryContextAttachment {
|
||||||
|
kind: "chat_history";
|
||||||
|
id: string;
|
||||||
|
attachment: Extract<AgentAttachment, { type: "text" }>;
|
||||||
|
source: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
boundaryMessageId?: string | null;
|
||||||
|
itemCount?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type UserComposerAttachment =
|
export type UserComposerAttachment =
|
||||||
| { kind: "image"; metadata: AttachmentMetadata }
|
| { kind: "image"; metadata: AttachmentMetadata }
|
||||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||||
@@ -75,6 +87,7 @@ export type WorkspaceComposerAttachment =
|
|||||||
attachment: BrowserElementAttachment;
|
attachment: BrowserElementAttachment;
|
||||||
}
|
}
|
||||||
| PullRequestContextAttachment
|
| PullRequestContextAttachment
|
||||||
|
| ChatHistoryContextAttachment
|
||||||
| {
|
| {
|
||||||
kind: "review";
|
kind: "review";
|
||||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function isWorkspaceAttachment(
|
|||||||
return (
|
return (
|
||||||
attachment?.kind === "review" ||
|
attachment?.kind === "review" ||
|
||||||
attachment?.kind === "browser_element" ||
|
attachment?.kind === "browser_element" ||
|
||||||
|
attachment?.kind === "chat_history" ||
|
||||||
isPullRequestContextAttachment(attachment)
|
isPullRequestContextAttachment(attachment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,7 @@ export function userAttachmentsOnly(
|
|||||||
(attachment): attachment is UserComposerAttachment =>
|
(attachment): attachment is UserComposerAttachment =>
|
||||||
attachment.kind !== "review" &&
|
attachment.kind !== "review" &&
|
||||||
attachment.kind !== "browser_element" &&
|
attachment.kind !== "browser_element" &&
|
||||||
|
attachment.kind !== "chat_history" &&
|
||||||
!isPullRequestContextAttachment(attachment),
|
!isPullRequestContextAttachment(attachment),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment(
|
|||||||
text: attachment.text,
|
text: attachment.text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (attachment.kind === "chat_history") {
|
||||||
|
return attachment.attachment;
|
||||||
|
}
|
||||||
return attachment.kind === "review" ? attachment.attachment : null;
|
return attachment.kind === "review" ? attachment.attachment : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { WorkspaceComposerAttachment } from "./types";
|
import type { WorkspaceComposerAttachment } from "./types";
|
||||||
import {
|
import {
|
||||||
appendWorkspaceAttachment,
|
appendWorkspaceAttachment,
|
||||||
|
buildDraftWorkspaceAttachmentScopeKey,
|
||||||
buildWorkspaceAttachmentScopeKey,
|
buildWorkspaceAttachmentScopeKey,
|
||||||
|
collectWorkspaceAttachmentsForScopes,
|
||||||
resetWorkspaceAttachmentsStore,
|
resetWorkspaceAttachmentsStore,
|
||||||
useWorkspaceAttachmentsStore,
|
useWorkspaceAttachmentsStore,
|
||||||
} from "./workspace-attachments-store";
|
} from "./workspace-attachments-store";
|
||||||
@@ -57,6 +59,24 @@ function contextAttachment(id: string): WorkspaceComposerAttachment {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment {
|
||||||
|
return {
|
||||||
|
kind: "chat_history",
|
||||||
|
id,
|
||||||
|
attachment: {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "chat_history",
|
||||||
|
title: "Chat history",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
serverId: "local",
|
||||||
|
agentId: "agent-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("workspace attachments store", () => {
|
describe("workspace attachments store", () => {
|
||||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -76,6 +96,12 @@ describe("workspace attachments store", () => {
|
|||||||
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
|
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes draft attachments by draft id", () => {
|
||||||
|
expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe(
|
||||||
|
"workspace-attachments:draft=draft-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("publishes and clears attachments for a workspace scope", () => {
|
it("publishes and clears attachments for a workspace scope", () => {
|
||||||
resetWorkspaceAttachmentsStore();
|
resetWorkspaceAttachmentsStore();
|
||||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||||
@@ -116,6 +142,13 @@ describe("workspace attachments store", () => {
|
|||||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dedupes repeated chat history attachments by id", () => {
|
||||||
|
const original = chatHistoryAttachment("chat_history:draft-1", "Original chat.");
|
||||||
|
const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat.");
|
||||||
|
|
||||||
|
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||||
|
});
|
||||||
|
|
||||||
it("adds a workspace attachment against the current scope state", () => {
|
it("adds a workspace attachment against the current scope state", () => {
|
||||||
resetWorkspaceAttachmentsStore();
|
resetWorkspaceAttachmentsStore();
|
||||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||||
@@ -138,4 +171,25 @@ describe("workspace attachments store", () => {
|
|||||||
context,
|
context,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("collects attachments across requested scopes in scope order", () => {
|
||||||
|
const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
|
||||||
|
const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({
|
||||||
|
serverId: "local",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
});
|
||||||
|
const draftContext = chatHistoryAttachment("chat_history:draft-1");
|
||||||
|
const workspaceContext = contextAttachment("comment-1");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
collectWorkspaceAttachmentsForScopes({
|
||||||
|
attachmentsByScope: {
|
||||||
|
[workspaceScopeKey]: [workspaceContext],
|
||||||
|
[draftScopeKey]: [draftContext],
|
||||||
|
},
|
||||||
|
scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey],
|
||||||
|
}),
|
||||||
|
).toEqual([draftContext, workspaceContext]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
import type { WorkspaceComposerAttachment } from "./types";
|
import type { WorkspaceComposerAttachment } from "./types";
|
||||||
|
|
||||||
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
|
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
|
||||||
|
|
||||||
export interface WorkspaceAttachmentScopeInput {
|
export interface WorkspaceAttachmentScopeInput {
|
||||||
|
kind?: "workspace";
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DraftWorkspaceAttachmentScopeInput {
|
||||||
|
kind: "draft";
|
||||||
|
draftId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceAttachmentScope =
|
||||||
|
| WorkspaceAttachmentScopeInput
|
||||||
|
| DraftWorkspaceAttachmentScopeInput;
|
||||||
|
|
||||||
interface WorkspaceAttachmentsStoreState {
|
interface WorkspaceAttachmentsStoreState {
|
||||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||||
}
|
}
|
||||||
@@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
|
||||||
|
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
function areWorkspaceAttachmentsEqual(
|
function areWorkspaceAttachmentsEqual(
|
||||||
left: readonly WorkspaceComposerAttachment[],
|
left: readonly WorkspaceComposerAttachment[],
|
||||||
right: readonly WorkspaceComposerAttachment[],
|
right: readonly WorkspaceComposerAttachment[],
|
||||||
@@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||||
if (
|
const isContextAttachment =
|
||||||
attachment.kind !== "github.pull_request_comment" &&
|
attachment.kind === "chat_history" ||
|
||||||
attachment.kind !== "github.pull_request_review" &&
|
attachment.kind === "github.pull_request_comment" ||
|
||||||
attachment.kind !== "github.pull_request_check"
|
attachment.kind === "github.pull_request_review" ||
|
||||||
) {
|
attachment.kind === "github.pull_request_check";
|
||||||
|
if (!isContextAttachment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string {
|
||||||
const { serverId, workspaceId, cwd } = input;
|
const isDraftScope = input.kind === "draft";
|
||||||
|
const draftId = isDraftScope ? input.draftId : "";
|
||||||
|
const serverId = isDraftScope ? "" : input.serverId;
|
||||||
|
const workspaceId = isDraftScope ? undefined : input.workspaceId;
|
||||||
|
const cwd = isDraftScope ? "" : input.cwd;
|
||||||
|
return useMemo(() => {
|
||||||
|
if (isDraftScope) {
|
||||||
|
return buildDraftWorkspaceAttachmentScopeKey(draftId);
|
||||||
|
}
|
||||||
|
return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
|
||||||
|
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
|
||||||
|
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
() => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""),
|
||||||
[serverId, workspaceId, cwd],
|
[normalizedDraftId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collectWorkspaceAttachmentsForScopes(input: {
|
||||||
|
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||||
|
scopeKeys: readonly string[];
|
||||||
|
}): readonly WorkspaceComposerAttachment[] {
|
||||||
|
const attachments: WorkspaceComposerAttachment[] = [];
|
||||||
|
for (const scopeKey of input.scopeKeys) {
|
||||||
|
const normalizedScopeKey = scopeKey.trim();
|
||||||
|
if (!normalizedScopeKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attachments.push(
|
||||||
|
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceAttachmentsForScopes(
|
||||||
|
scopeKeys: readonly string[] | undefined,
|
||||||
|
): readonly WorkspaceComposerAttachment[] {
|
||||||
|
const normalizedScopeKeys = useMemo(
|
||||||
|
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
|
||||||
|
[scopeKeys],
|
||||||
|
);
|
||||||
|
const attachmentsByScope = useWorkspaceAttachmentsStore(
|
||||||
|
useShallow((state) => state.attachmentsByScope),
|
||||||
|
);
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
collectWorkspaceAttachmentsForScopes({
|
||||||
|
attachmentsByScope,
|
||||||
|
scopeKeys: normalizedScopeKeys,
|
||||||
|
}),
|
||||||
|
[attachmentsByScope, normalizedScopeKeys],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resetWorkspaceAttachmentsStore(): void {
|
export function resetWorkspaceAttachmentsStore(): void {
|
||||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
||||||
}
|
}
|
||||||
|
|||||||
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 MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
CircleDot,
|
|
||||||
Info,
|
Info,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -42,15 +41,13 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Copy,
|
Copy,
|
||||||
GitPullRequest,
|
|
||||||
MessageSquareCode,
|
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
MicVocal,
|
MicVocal,
|
||||||
FileSymlink,
|
FileSymlink,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -89,6 +86,7 @@ import {
|
|||||||
getFileNameFromPath,
|
getFileNameFromPath,
|
||||||
parseImageDataUrl,
|
parseImageDataUrl,
|
||||||
} from "@/attachments/utils";
|
} from "@/attachments/utils";
|
||||||
|
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
@@ -104,7 +102,6 @@ import {
|
|||||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
|
||||||
import {
|
import {
|
||||||
AttachmentFrame,
|
AttachmentFrame,
|
||||||
AttachmentLabel,
|
AttachmentLabel,
|
||||||
@@ -116,7 +113,9 @@ import { isWeb, isNative } from "@/constants/platform";
|
|||||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||||
|
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||||
|
export type { AssistantForkTarget };
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -170,10 +169,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
|
|||||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
|
||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
|
||||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
@@ -402,19 +397,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const attachmentReviewIcon = (
|
|
||||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const attachmentGithubPrIcon = (
|
|
||||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const attachmentGithubIssueIcon = (
|
|
||||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
const attachmentFileIcon = (
|
|
||||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
|
||||||
);
|
|
||||||
|
|
||||||
interface UserMessageImagePillProps {
|
interface UserMessageImagePillProps {
|
||||||
image: UserMessageImageAttachment;
|
image: UserMessageImageAttachment;
|
||||||
onOpen: (image: UserMessageImageAttachment) => void;
|
onOpen: (image: UserMessageImageAttachment) => void;
|
||||||
@@ -432,55 +414,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserMessageAttachmentContent {
|
|
||||||
icon: ReactNode;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserMessageAttachmentContent(
|
|
||||||
attachment: AgentAttachment,
|
|
||||||
t: ReturnType<typeof useTranslation>["t"],
|
|
||||||
): UserMessageAttachmentContent {
|
|
||||||
switch (attachment.type) {
|
|
||||||
case "review": {
|
|
||||||
const count = attachment.comments.length;
|
|
||||||
return {
|
|
||||||
icon: attachmentReviewIcon,
|
|
||||||
title: t("message.attachments.review"),
|
|
||||||
subtitle:
|
|
||||||
count === 1
|
|
||||||
? t("message.attachments.commentsOne")
|
|
||||||
: t("message.attachments.commentsMany", { count }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "github_pr":
|
|
||||||
return {
|
|
||||||
icon: attachmentGithubPrIcon,
|
|
||||||
title: attachment.title,
|
|
||||||
subtitle: `PR #${attachment.number}`,
|
|
||||||
};
|
|
||||||
case "github_issue":
|
|
||||||
return {
|
|
||||||
icon: attachmentGithubIssueIcon,
|
|
||||||
title: attachment.title,
|
|
||||||
subtitle: `Issue #${attachment.number}`,
|
|
||||||
};
|
|
||||||
case "text":
|
|
||||||
return {
|
|
||||||
icon: attachmentFileIcon,
|
|
||||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
|
||||||
subtitle: t("message.attachments.text"),
|
|
||||||
};
|
|
||||||
case "uploaded_file":
|
|
||||||
return {
|
|
||||||
icon: attachmentFileIcon,
|
|
||||||
title: attachment.fileName,
|
|
||||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UserMessage = memo(function UserMessage({
|
export const UserMessage = memo(function UserMessage({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
{hasAttachments ? (
|
{hasAttachments ? (
|
||||||
<View style={attachmentPreviewContainerStyle}>
|
<View style={attachmentPreviewContainerStyle}>
|
||||||
{attachments.map((attachment, index) => {
|
{attachments.map((attachment, index) => {
|
||||||
const content = getUserMessageAttachmentContent(attachment, t);
|
const content = getAgentAttachmentPillContent(attachment, t);
|
||||||
return (
|
return (
|
||||||
<AttachmentFrame
|
<AttachmentFrame
|
||||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||||
@@ -628,6 +561,11 @@ interface AssistantTurnFooterProps {
|
|||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
forkBoundaryMessageId?: string;
|
||||||
|
onFork?: (input: {
|
||||||
|
target: AssistantForkTarget;
|
||||||
|
boundaryMessageId?: string;
|
||||||
|
}) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||||
@@ -672,6 +610,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
getContent,
|
getContent,
|
||||||
completedAt,
|
completedAt,
|
||||||
durationMs,
|
durationMs,
|
||||||
|
forkBoundaryMessageId,
|
||||||
|
onFork,
|
||||||
}: AssistantTurnFooterProps) {
|
}: AssistantTurnFooterProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [pressedReveal, setPressedReveal] = useState(false);
|
const [pressedReveal, setPressedReveal] = useState(false);
|
||||||
@@ -711,6 +651,13 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
revealTimerRef.current = null;
|
revealTimerRef.current = null;
|
||||||
}, TIMESTAMP_REVEAL_MS);
|
}, TIMESTAMP_REVEAL_MS);
|
||||||
}, [canSwap]);
|
}, [canSwap]);
|
||||||
|
const handleFork = useCallback(
|
||||||
|
(target: AssistantForkTarget) => {
|
||||||
|
return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId });
|
||||||
|
},
|
||||||
|
[forkBoundaryMessageId, onFork],
|
||||||
|
);
|
||||||
|
const canFork = Boolean(onFork && forkBoundaryMessageId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={assistantTurnFooterStylesheet.container}>
|
<View style={assistantTurnFooterStylesheet.container}>
|
||||||
@@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
getContent={getContent}
|
getContent={getContent}
|
||||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||||
/>
|
/>
|
||||||
|
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||||
{durationLabel ? (
|
{durationLabel ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
|
|||||||
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 { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
|
||||||
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
|
||||||
import type {
|
import type {
|
||||||
ComposerAttachment,
|
ComposerAttachment,
|
||||||
UserComposerAttachment,
|
UserComposerAttachment,
|
||||||
WorkspaceComposerAttachment,
|
WorkspaceComposerAttachment,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
|
import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||||
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
|
||||||
import {
|
import {
|
||||||
isWorkspaceAttachment,
|
isWorkspaceAttachment,
|
||||||
isPullRequestContextAttachment,
|
isPullRequestContextAttachment,
|
||||||
userAttachmentsOnly,
|
userAttachmentsOnly,
|
||||||
workspaceAttachmentToSubmitAttachment,
|
workspaceAttachmentToSubmitAttachment,
|
||||||
} from "@/attachments/workspace-attachment-utils";
|
} from "@/attachments/workspace-attachment-utils";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import {
|
||||||
|
getAttachmentKey,
|
||||||
|
removeSentContextAttachments,
|
||||||
|
removeWorkspaceAttachmentsMatching,
|
||||||
|
} from "./workspace-cleanup";
|
||||||
import { useClearReviewDraft } from "@/review/store";
|
import { useClearReviewDraft } from "@/review/store";
|
||||||
|
|
||||||
type TranslationFn = ReturnType<typeof useTranslation>["t"];
|
|
||||||
|
|
||||||
interface WorkspaceAttachmentBindingInput {
|
interface WorkspaceAttachmentBindingInput {
|
||||||
normalAttachments: UserComposerAttachment[];
|
normalAttachments: UserComposerAttachment[];
|
||||||
workspaceAttachments: readonly WorkspaceComposerAttachment[];
|
workspaceAttachments: readonly WorkspaceComposerAttachment[];
|
||||||
@@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding {
|
|||||||
resetSuppression: () => void;
|
resetSuppression: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
|
||||||
if (attachment.kind === "browser_element") {
|
|
||||||
return JSON.stringify({
|
|
||||||
type: "browser_element",
|
|
||||||
url: attachment.attachment.url,
|
|
||||||
selector: attachment.attachment.selector,
|
|
||||||
tag: attachment.attachment.tag,
|
|
||||||
text: attachment.attachment.text,
|
|
||||||
html: attachment.attachment.outerHTML,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
|
||||||
return JSON.stringify({
|
|
||||||
kind: attachment.kind,
|
|
||||||
id: attachment.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return JSON.stringify({
|
|
||||||
type: "review",
|
|
||||||
cwd: attachment.attachment.cwd,
|
|
||||||
mode: attachment.attachment.mode,
|
|
||||||
baseRef: attachment.attachment.baseRef ?? null,
|
|
||||||
reviewDraftKey: attachment.reviewDraftKey,
|
|
||||||
comments: attachment.attachment.comments.map((comment) => ({
|
|
||||||
filePath: comment.filePath,
|
|
||||||
side: comment.side,
|
|
||||||
lineNumber: comment.lineNumber,
|
|
||||||
body: comment.body,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
|
|
||||||
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
|
|
||||||
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
|
|
||||||
const nextAttachments = attachments.filter(
|
|
||||||
(attachment) => getAttachmentKey(attachment) !== selectedKey,
|
|
||||||
);
|
|
||||||
if (nextAttachments.length !== attachments.length) {
|
|
||||||
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
|
|
||||||
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
|
|
||||||
for (const key of sentContextKeys) {
|
|
||||||
removeWorkspaceAttachmentsMatching(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
|
|
||||||
if (attachment.kind === "github.pull_request_check") {
|
|
||||||
return "Check logs";
|
|
||||||
}
|
|
||||||
if (attachment.kind === "github.pull_request_comment") {
|
|
||||||
return "Comment";
|
|
||||||
}
|
|
||||||
return "Review";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PillContent {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
|
|
||||||
if (attachment.kind === "browser_element") {
|
|
||||||
return {
|
|
||||||
title: attachment.attachment.tag,
|
|
||||||
subtitle: t("composer.attachments.element"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
|
||||||
return {
|
|
||||||
title: attachment.title,
|
|
||||||
subtitle: getContextSourceLabel(attachment),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
title: t("message.attachments.review"),
|
|
||||||
subtitle:
|
|
||||||
attachment.commentCount === 1
|
|
||||||
? t("message.attachments.commentsOne")
|
|
||||||
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpenAccessibilityLabel(
|
function getOpenAccessibilityLabel(
|
||||||
attachment: WorkspaceComposerAttachment,
|
attachment: WorkspaceComposerAttachment,
|
||||||
t: TranslationFn,
|
t: ReturnType<typeof useTranslation>["t"],
|
||||||
): string {
|
): string {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "browser_element") {
|
||||||
return t("composer.attachments.openBrowserElement");
|
return t("composer.attachments.openBrowserElement");
|
||||||
@@ -148,12 +60,15 @@ function getOpenAccessibilityLabel(
|
|||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
return "Open context attachment";
|
return "Open context attachment";
|
||||||
}
|
}
|
||||||
|
if (attachment.kind === "chat_history") {
|
||||||
|
return "Open chat history attachment";
|
||||||
|
}
|
||||||
return t("composer.attachments.openReview");
|
return t("composer.attachments.openReview");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRemoveAccessibilityLabel(
|
function getRemoveAccessibilityLabel(
|
||||||
attachment: WorkspaceComposerAttachment,
|
attachment: WorkspaceComposerAttachment,
|
||||||
t: TranslationFn,
|
t: ReturnType<typeof useTranslation>["t"],
|
||||||
): string {
|
): string {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "browser_element") {
|
||||||
return t("composer.attachments.removeBrowserElement");
|
return t("composer.attachments.removeBrowserElement");
|
||||||
@@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel(
|
|||||||
if (isPullRequestContextAttachment(attachment)) {
|
if (isPullRequestContextAttachment(attachment)) {
|
||||||
return "Remove context attachment";
|
return "Remove context attachment";
|
||||||
}
|
}
|
||||||
|
if (attachment.kind === "chat_history") {
|
||||||
|
return "Remove chat history attachment";
|
||||||
|
}
|
||||||
return t("composer.attachments.removeReview");
|
return t("composer.attachments.removeReview");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement {
|
function getPillTestID(attachment: WorkspaceComposerAttachment): string {
|
||||||
if (attachment.kind === "browser_element") {
|
if (attachment.kind === "chat_history") {
|
||||||
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
return "composer-chat-history-attachment-pill";
|
||||||
}
|
}
|
||||||
if (isPullRequestContextAttachment(attachment)) {
|
return "composer-review-attachment-pill";
|
||||||
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
|
||||||
}
|
|
||||||
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||||
@@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({
|
|||||||
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
||||||
const selected = current[index];
|
const selected = current[index];
|
||||||
if (isWorkspaceAttachment(selected)) {
|
if (isWorkspaceAttachment(selected)) {
|
||||||
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
|
if (
|
||||||
|
selected.kind === "browser_element" ||
|
||||||
|
selected.kind === "chat_history" ||
|
||||||
|
isPullRequestContextAttachment(selected)
|
||||||
|
) {
|
||||||
const selectedKey = getAttachmentKey(selected);
|
const selectedKey = getAttachmentKey(selected);
|
||||||
removeWorkspaceAttachmentsMatching(selectedKey);
|
removeWorkspaceAttachmentsMatching(selectedKey);
|
||||||
return true;
|
return true;
|
||||||
@@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: WorkspaceAttachmentPillProps) {
|
}: WorkspaceAttachmentPillProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const content = getPillContent(attachment, t);
|
const content = getWorkspaceAttachmentPillContent(attachment, t);
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
onOpen(attachment);
|
onOpen(attachment);
|
||||||
}, [onOpen, attachment]);
|
}, [onOpen, attachment]);
|
||||||
@@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({
|
|||||||
}, [onRemove, index]);
|
}, [onRemove, index]);
|
||||||
return (
|
return (
|
||||||
<AttachmentPill
|
<AttachmentPill
|
||||||
testID="composer-review-attachment-pill"
|
testID={getPillTestID(attachment)}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
|
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
|
||||||
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<AttachmentLabel
|
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
|
||||||
icon={renderPillIcon(attachment)}
|
|
||||||
title={content.title}
|
|
||||||
subtitle={content.subtitle}
|
|
||||||
/>
|
|
||||||
</AttachmentPill>
|
</AttachmentPill>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = {
|
|||||||
userAttachmentsOnly,
|
userAttachmentsOnly,
|
||||||
useBinding: useWorkspaceAttachmentBinding,
|
useBinding: useWorkspaceAttachmentBinding,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
|
||||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
|
||||||
const ThemedFileText = withUnistyles(FileText);
|
|
||||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
|
|||||||
@@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => {
|
|||||||
});
|
});
|
||||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
||||||
|
const attachment = {
|
||||||
|
kind: "chat_history",
|
||||||
|
id: "chat-history-1",
|
||||||
|
attachment: {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "chat_history",
|
||||||
|
title: "Chat history",
|
||||||
|
text: "Previous conversation",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
serverId: "server-1",
|
||||||
|
agentId: "agent-source",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
const createRequest = vi.fn(async () => ({
|
||||||
|
agentId: "agent-1",
|
||||||
|
result: { id: "agent-1" },
|
||||||
|
}));
|
||||||
|
const onCreateSuccess = vi.fn();
|
||||||
|
const validateBeforeSubmit = vi.fn(() => null);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDraftAgentCreateFlow({
|
||||||
|
draftId: "draft-1",
|
||||||
|
getPendingServerId: () => "server-1",
|
||||||
|
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
|
||||||
|
createRequest,
|
||||||
|
onCreateSuccess,
|
||||||
|
validateBeforeSubmit,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleCreateFromInput({
|
||||||
|
text: " ",
|
||||||
|
attachments: [attachment],
|
||||||
|
cwd: "/repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateBeforeSubmit).toHaveBeenCalledWith({
|
||||||
|
text: "",
|
||||||
|
attachments: [attachment],
|
||||||
|
cwd: "/repo",
|
||||||
|
});
|
||||||
|
expect(createRequest).toHaveBeenCalledWith({
|
||||||
|
attempt: expect.objectContaining({
|
||||||
|
text: "",
|
||||||
|
attachments: [attachment.attachment],
|
||||||
|
}),
|
||||||
|
text: "",
|
||||||
|
attachments: [attachment.attachment],
|
||||||
|
cwd: "/repo",
|
||||||
|
});
|
||||||
|
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
const images = wirePayload.images;
|
const images = wirePayload.images;
|
||||||
|
|
||||||
const trimmedPrompt = text.trim();
|
const trimmedPrompt = text.trim();
|
||||||
if (!trimmedPrompt && !allowEmptyText) {
|
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
|
||||||
|
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
|
||||||
const error = new Error(t("composer.errors.initialPromptRequired"));
|
const error = new Error(t("composer.errors.initialPromptRequired"));
|
||||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldAllowEmptyDraftText(input: {
|
||||||
|
allowsEmptyAutoSubmit: boolean;
|
||||||
|
attachments: readonly unknown[];
|
||||||
|
}): boolean {
|
||||||
|
return input.allowsEmptyAutoSubmit || input.attachments.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateDraftSubmission(input: {
|
export function validateDraftSubmission(input: {
|
||||||
text: string;
|
text: string;
|
||||||
allowsEmptyAutoSubmit: boolean;
|
allowsEmptyAutoSubmit: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { validateDraftSubmission } from "./workspace-tab-core";
|
import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core";
|
||||||
|
|
||||||
const baseComposerState = {
|
const baseComposerState = {
|
||||||
providerDefinitions: [{ id: "codewhale" }],
|
providerDefinitions: [{ id: "codewhale" }],
|
||||||
@@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => {
|
|||||||
).toBe("No model is available for the selected provider");
|
).toBe("No model is available for the selected provider");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("workspace draft empty text readiness", () => {
|
||||||
|
test("allows attachment-only retries after a fork draft create fails", () => {
|
||||||
|
expect(
|
||||||
|
shouldAllowEmptyDraftText({
|
||||||
|
allowsEmptyAutoSubmit: false,
|
||||||
|
attachments: [{ kind: "chat_history" }],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still rejects empty drafts with no auto-submit and no attachments", () => {
|
||||||
|
expect(
|
||||||
|
shouldAllowEmptyDraftText({
|
||||||
|
allowsEmptyAutoSubmit: false,
|
||||||
|
attachments: [],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
|||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core";
|
import {
|
||||||
|
shouldAllowEmptyDraftText,
|
||||||
|
validateDraftSubmission,
|
||||||
|
} from "@/composer/draft/workspace-tab-core";
|
||||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import {
|
import {
|
||||||
useWorkspaceAttachments,
|
useDraftWorkspaceAttachmentScopeKey,
|
||||||
useWorkspaceAttachmentScopeKey,
|
useWorkspaceAttachmentScopeKey,
|
||||||
|
useWorkspaceAttachmentsStore,
|
||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
@@ -118,6 +122,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
text: string;
|
text: string;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
attachments?: unknown;
|
attachments?: unknown;
|
||||||
|
cwd: string;
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
workspaceDirectory: string | null;
|
workspaceDirectory: string | null;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
@@ -138,6 +143,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
text,
|
text,
|
||||||
images,
|
images,
|
||||||
attachments,
|
attachments,
|
||||||
|
cwd,
|
||||||
client,
|
client,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -162,7 +168,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
});
|
});
|
||||||
const config = buildWorkspaceDraftAgentConfig({
|
const config = buildWorkspaceDraftAgentConfig({
|
||||||
provider,
|
provider,
|
||||||
cwd: workspaceDirectory,
|
cwd,
|
||||||
...modeIdOverride,
|
...modeIdOverride,
|
||||||
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||||
thinkingOptionId:
|
thinkingOptionId:
|
||||||
@@ -400,7 +406,14 @@ export function WorkspaceDraftAgentTab({
|
|||||||
cwd: composerState.workingDir,
|
cwd: composerState.workingDir,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
||||||
|
const attachmentScopeKeys = useMemo(
|
||||||
|
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
|
||||||
|
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
|
||||||
|
);
|
||||||
|
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||||
|
(state) => state.clearWorkspaceAttachments,
|
||||||
|
);
|
||||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
const handleOpenWorkspaceAttachment = useCallback(
|
const handleOpenWorkspaceAttachment = useCallback(
|
||||||
@@ -437,15 +450,20 @@ export function WorkspaceDraftAgentTab({
|
|||||||
getPendingServerId: () => serverId,
|
getPendingServerId: () => serverId,
|
||||||
initialAttempt: initialCreateAttempt,
|
initialAttempt: initialCreateAttempt,
|
||||||
allowEmptyText: allowsEmptyAutoSubmit,
|
allowEmptyText: allowsEmptyAutoSubmit,
|
||||||
validateBeforeSubmit: ({ text }) =>
|
validateBeforeSubmit: ({ text, attachments }) => {
|
||||||
validateDraftSubmission({
|
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||||
text,
|
|
||||||
allowsEmptyAutoSubmit,
|
allowsEmptyAutoSubmit,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
return validateDraftSubmission({
|
||||||
|
text,
|
||||||
|
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||||
composerState,
|
composerState,
|
||||||
autoSubmitConfig,
|
autoSubmitConfig,
|
||||||
workspaceDirectory: draftWorkingDirectory,
|
workspaceDirectory: draftWorkingDirectory,
|
||||||
hasClient: Boolean(client),
|
hasClient: Boolean(client),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onBeforeSubmit: async () => {
|
onBeforeSubmit: async () => {
|
||||||
await composerState.persistFormPreferences();
|
await composerState.persistFormPreferences();
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
@@ -463,12 +481,13 @@ export function WorkspaceDraftAgentTab({
|
|||||||
composerState,
|
composerState,
|
||||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||||
}),
|
}),
|
||||||
createRequest: async ({ attempt, text, images, attachments }) =>
|
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
|
||||||
submitDraftCreateRequest({
|
submitDraftCreateRequest({
|
||||||
attempt,
|
attempt,
|
||||||
text,
|
text,
|
||||||
images,
|
images,
|
||||||
attachments,
|
attachments,
|
||||||
|
cwd,
|
||||||
client,
|
client,
|
||||||
workspaceDirectory: draftWorkingDirectory,
|
workspaceDirectory: draftWorkingDirectory,
|
||||||
workspaceId: workspaceFields?.id ?? null,
|
workspaceId: workspaceFields?.id ?? null,
|
||||||
@@ -479,6 +498,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}),
|
}),
|
||||||
onCreateSuccess: ({ result }) => {
|
onCreateSuccess: ({ result }) => {
|
||||||
clearDraftInput("sent");
|
clearDraftInput("sent");
|
||||||
|
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||||
|
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||||
onCreated(result);
|
onCreated(result);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -690,7 +711,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
value={draftInput.text}
|
value={draftInput.text}
|
||||||
onChangeText={draftInput.setText}
|
onChangeText={draftInput.setText}
|
||||||
attachments={draftInput.attachments}
|
attachments={draftInput.attachments}
|
||||||
workspaceAttachments={workspaceAttachments}
|
attachmentScopeKeys={attachmentScopeKeys}
|
||||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||||
onChangeAttachments={draftInput.setAttachments}
|
onChangeAttachments={draftInput.setAttachments}
|
||||||
cwd={composerState.workingDir}
|
cwd={composerState.workingDir}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import type {
|
|||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import type { PickedFile } from "@/attachments/picked-file";
|
import type { PickedFile } from "@/attachments/picked-file";
|
||||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||||
|
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||||
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
||||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
@@ -121,6 +122,8 @@ type AttachmentListUpdater =
|
|||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||||
|
|
||||||
|
const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
const noopCallback = () => {};
|
const noopCallback = () => {};
|
||||||
|
|
||||||
@@ -759,7 +762,7 @@ interface ComposerProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
attachments: UserComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
|
attachmentScopeKeys?: readonly string[];
|
||||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -971,7 +974,7 @@ export function Composer({
|
|||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
attachments,
|
attachments,
|
||||||
workspaceAttachments = [],
|
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||||
onOpenWorkspaceAttachment,
|
onOpenWorkspaceAttachment,
|
||||||
onChangeAttachments,
|
onChangeAttachments,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -1026,6 +1029,7 @@ export function Composer({
|
|||||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
||||||
const userInput = value;
|
const userInput = value;
|
||||||
const setUserInput = onChangeText;
|
const setUserInput = onChangeText;
|
||||||
|
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
|
||||||
const {
|
const {
|
||||||
selectedAttachments,
|
selectedAttachments,
|
||||||
buildOutgoingAttachments,
|
buildOutgoingAttachments,
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
import type {
|
||||||
|
AgentCapabilityFlags,
|
||||||
|
AgentFeature,
|
||||||
|
AgentProvider,
|
||||||
|
} from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
export interface AgentScreenAgent {
|
export interface AgentScreenAgent {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
provider?: AgentProvider;
|
||||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||||
cwd: string;
|
cwd: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
capabilities?: AgentCapabilityFlags;
|
capabilities?: AgentCapabilityFlags;
|
||||||
|
currentModeId?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
|
runtimeInfo?: {
|
||||||
|
model?: string | null;
|
||||||
|
modeId?: string | null;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
|
} | null;
|
||||||
|
features?: readonly AgentFeature[];
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
projectPlacement?: {
|
projectPlacement?: {
|
||||||
|
projectKey?: string;
|
||||||
|
projectName?: string;
|
||||||
checkout?: {
|
checkout?: {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
isGit?: boolean;
|
isGit?: boolean;
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ export const ar: TranslationResources = {
|
|||||||
copyCode: "نسخ الرمز",
|
copyCode: "نسخ الرمز",
|
||||||
copyTurn: "نسخ بدوره",
|
copyTurn: "نسخ بدوره",
|
||||||
copyMessage: "انسخ الرسالة",
|
copyMessage: "انسخ الرسالة",
|
||||||
|
forkMenu: "تفريع الرسالة",
|
||||||
|
forkInNewTab: "تفريع في تبويب جديد",
|
||||||
|
forkInNewWorkspace: "تفريع في مساحة عمل جديدة",
|
||||||
|
forkUnavailable: "حدّث المضيف لاستخدام هذا.",
|
||||||
|
forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.",
|
||||||
|
forkFailed: "فشل تفريع المحادثة",
|
||||||
openFile: "افتح الملف",
|
openFile: "افتح الملف",
|
||||||
copied: "منقول",
|
copied: "منقول",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -244,6 +244,12 @@ export const en = {
|
|||||||
copyCode: "Copy code",
|
copyCode: "Copy code",
|
||||||
copyTurn: "Copy turn",
|
copyTurn: "Copy turn",
|
||||||
copyMessage: "Copy message",
|
copyMessage: "Copy message",
|
||||||
|
forkMenu: "Fork message",
|
||||||
|
forkInNewTab: "Fork in a new tab",
|
||||||
|
forkInNewWorkspace: "Fork in a new workspace",
|
||||||
|
forkUnavailable: "Update the host to use this.",
|
||||||
|
forkMissingWorkspace: "This agent is not in a workspace.",
|
||||||
|
forkFailed: "Failed to fork chat",
|
||||||
openFile: "Open file",
|
openFile: "Open file",
|
||||||
copied: "Copied",
|
copied: "Copied",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ export const es: TranslationResources = {
|
|||||||
copyCode: "Copiar código",
|
copyCode: "Copiar código",
|
||||||
copyTurn: "Copiar turno",
|
copyTurn: "Copiar turno",
|
||||||
copyMessage: "Copiar mensaje",
|
copyMessage: "Copiar mensaje",
|
||||||
|
forkMenu: "Bifurcar mensaje",
|
||||||
|
forkInNewTab: "Bifurcar en una pestaña nueva",
|
||||||
|
forkInNewWorkspace: "Bifurcar en un espacio de trabajo nuevo",
|
||||||
|
forkUnavailable: "Actualiza el host para usar esto.",
|
||||||
|
forkMissingWorkspace: "Este agente no está en un espacio de trabajo.",
|
||||||
|
forkFailed: "No se pudo bifurcar el chat",
|
||||||
openFile: "Abrir archivo",
|
openFile: "Abrir archivo",
|
||||||
copied: "Copiado",
|
copied: "Copiado",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ export const fr: TranslationResources = {
|
|||||||
copyCode: "Copier le code",
|
copyCode: "Copier le code",
|
||||||
copyTurn: "Copier le tour",
|
copyTurn: "Copier le tour",
|
||||||
copyMessage: "Copier le message",
|
copyMessage: "Copier le message",
|
||||||
|
forkMenu: "Dupliquer le message",
|
||||||
|
forkInNewTab: "Dupliquer dans un nouvel onglet",
|
||||||
|
forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail",
|
||||||
|
forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.",
|
||||||
|
forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.",
|
||||||
|
forkFailed: "Impossible de dupliquer le chat",
|
||||||
openFile: "Ouvrir le fichier",
|
openFile: "Ouvrir le fichier",
|
||||||
copied: "Copié",
|
copied: "Copié",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ export const ja: TranslationResources = {
|
|||||||
copyCode: "コードをコピー",
|
copyCode: "コードをコピー",
|
||||||
copyTurn: "ターンをコピー",
|
copyTurn: "ターンをコピー",
|
||||||
copyMessage: "メッセージをコピー",
|
copyMessage: "メッセージをコピー",
|
||||||
|
forkMenu: "メッセージをフォーク",
|
||||||
|
forkInNewTab: "新しいタブにフォーク",
|
||||||
|
forkInNewWorkspace: "新しいワークスペースにフォーク",
|
||||||
|
forkUnavailable: "これを使用するにはホストを更新してください。",
|
||||||
|
forkMissingWorkspace: "このエージェントはワークスペース内にありません。",
|
||||||
|
forkFailed: "チャットのフォークに失敗しました",
|
||||||
openFile: "ファイルを開く",
|
openFile: "ファイルを開く",
|
||||||
copied: "コピーしました",
|
copied: "コピーしました",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -248,6 +248,12 @@ export const ptBR: TranslationResources = {
|
|||||||
copyCode: "Copiar código",
|
copyCode: "Copiar código",
|
||||||
copyTurn: "Copiar turno",
|
copyTurn: "Copiar turno",
|
||||||
copyMessage: "Copiar mensagem",
|
copyMessage: "Copiar mensagem",
|
||||||
|
forkMenu: "Bifurcar mensagem",
|
||||||
|
forkInNewTab: "Bifurcar em uma nova aba",
|
||||||
|
forkInNewWorkspace: "Bifurcar em um novo workspace",
|
||||||
|
forkUnavailable: "Atualize o host para usar isto.",
|
||||||
|
forkMissingWorkspace: "Este agente não está em um workspace.",
|
||||||
|
forkFailed: "Falha ao bifurcar o chat",
|
||||||
openFile: "Abrir arquivo",
|
openFile: "Abrir arquivo",
|
||||||
copied: "Copiado",
|
copied: "Copiado",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -247,6 +247,12 @@ export const ru: TranslationResources = {
|
|||||||
copyCode: "Скопировать код",
|
copyCode: "Скопировать код",
|
||||||
copyTurn: "Копировать ход",
|
copyTurn: "Копировать ход",
|
||||||
copyMessage: "Копировать сообщение",
|
copyMessage: "Копировать сообщение",
|
||||||
|
forkMenu: "Форкнуть сообщение",
|
||||||
|
forkInNewTab: "Форкнуть в новой вкладке",
|
||||||
|
forkInNewWorkspace: "Форкнуть в новом рабочем пространстве",
|
||||||
|
forkUnavailable: "Обновите хост, чтобы использовать это.",
|
||||||
|
forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.",
|
||||||
|
forkFailed: "Не удалось форкнуть чат",
|
||||||
openFile: "Открыть файл",
|
openFile: "Открыть файл",
|
||||||
copied: "Скопировано",
|
copied: "Скопировано",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ export const zhCN: TranslationResources = {
|
|||||||
copyCode: "复制代码",
|
copyCode: "复制代码",
|
||||||
copyTurn: "复制回合",
|
copyTurn: "复制回合",
|
||||||
copyMessage: "复制消息",
|
copyMessage: "复制消息",
|
||||||
|
forkMenu: "分叉消息",
|
||||||
|
forkInNewTab: "分叉到新标签页",
|
||||||
|
forkInNewWorkspace: "分叉到新工作区",
|
||||||
|
forkUnavailable: "请更新主机以使用此功能。",
|
||||||
|
forkMissingWorkspace: "此 Agent 不在工作区中。",
|
||||||
|
forkFailed: "分叉聊天失败",
|
||||||
openFile: "打开文件",
|
openFile: "打开文件",
|
||||||
copied: "已复制",
|
copied: "已复制",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,10 +24,7 @@ import {
|
|||||||
type ToastState,
|
type ToastState,
|
||||||
} from "@/components/toast-host";
|
} from "@/components/toast-host";
|
||||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import {
|
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
useWorkspaceAttachments,
|
|
||||||
useWorkspaceAttachmentScopeKey,
|
|
||||||
} from "@/attachments/workspace-attachments-store";
|
|
||||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||||
@@ -86,10 +83,16 @@ import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-co
|
|||||||
interface ChatAgentStateShape {
|
interface ChatAgentStateShape {
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
|
provider?: Agent["provider"];
|
||||||
status: Agent["status"] | null;
|
status: Agent["status"] | null;
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
capabilities?: Agent["capabilities"];
|
capabilities?: Agent["capabilities"];
|
||||||
|
currentModeId?: Agent["currentModeId"];
|
||||||
|
model?: Agent["model"];
|
||||||
|
thinkingOptionId?: Agent["thinkingOptionId"];
|
||||||
|
runtimeInfo?: Agent["runtimeInfo"];
|
||||||
|
features?: Agent["features"];
|
||||||
lastError?: Agent["lastError"] | null;
|
lastError?: Agent["lastError"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,10 +133,16 @@ function selectChatAgentState(
|
|||||||
return {
|
return {
|
||||||
serverId: agent.serverId,
|
serverId: agent.serverId,
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
|
provider: agent.provider,
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
workspaceId: agent.workspaceId,
|
workspaceId: agent.workspaceId,
|
||||||
capabilities: agent.capabilities,
|
capabilities: agent.capabilities,
|
||||||
|
currentModeId: agent.currentModeId,
|
||||||
|
model: agent.model,
|
||||||
|
thinkingOptionId: agent.thinkingOptionId,
|
||||||
|
runtimeInfo: agent.runtimeInfo,
|
||||||
|
features: agent.features,
|
||||||
lastError: agent.lastError ?? null,
|
lastError: agent.lastError ?? null,
|
||||||
archivedAt: agent.archivedAt ?? null,
|
archivedAt: agent.archivedAt ?? null,
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
@@ -151,10 +160,16 @@ function buildChatAgentFromState(
|
|||||||
return {
|
return {
|
||||||
serverId: state.serverId,
|
serverId: state.serverId,
|
||||||
id: state.id,
|
id: state.id,
|
||||||
|
provider: state.provider,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
workspaceId: state.workspaceId,
|
workspaceId: state.workspaceId,
|
||||||
capabilities: state.capabilities,
|
capabilities: state.capabilities,
|
||||||
|
currentModeId: state.currentModeId,
|
||||||
|
model: state.model,
|
||||||
|
thinkingOptionId: state.thinkingOptionId,
|
||||||
|
runtimeInfo: state.runtimeInfo,
|
||||||
|
features: state.features,
|
||||||
lastError: state.lastError ?? null,
|
lastError: state.lastError ?? null,
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
};
|
};
|
||||||
@@ -546,18 +561,7 @@ function AgentPanelBody({
|
|||||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||||
const agent = resolveChatAgentFromSession(state, serverId, agentId);
|
|
||||||
return {
|
|
||||||
serverId: agent?.serverId ?? null,
|
|
||||||
id: agent?.id ?? null,
|
|
||||||
status: agent?.status ?? null,
|
|
||||||
cwd: agent?.cwd ?? null,
|
|
||||||
workspaceId: agent?.workspaceId,
|
|
||||||
lastError: agent?.lastError ?? null,
|
|
||||||
archivedAt: agent?.archivedAt ?? null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||||
const lookupAttemptTokenRef = useRef(0);
|
const lookupAttemptTokenRef = useRef(0);
|
||||||
@@ -644,9 +648,16 @@ function AgentPanelBody({
|
|||||||
? {
|
? {
|
||||||
serverId: agentState.serverId,
|
serverId: agentState.serverId,
|
||||||
id: agentState.id,
|
id: agentState.id,
|
||||||
|
provider: agentState.provider,
|
||||||
status: agentState.status,
|
status: agentState.status,
|
||||||
cwd: agentState.cwd,
|
cwd: agentState.cwd,
|
||||||
workspaceId: agentState.workspaceId,
|
workspaceId: agentState.workspaceId,
|
||||||
|
capabilities: agentState.capabilities,
|
||||||
|
currentModeId: agentState.currentModeId,
|
||||||
|
model: agentState.model,
|
||||||
|
thinkingOptionId: agentState.thinkingOptionId,
|
||||||
|
runtimeInfo: agentState.runtimeInfo,
|
||||||
|
features: agentState.features,
|
||||||
lastError: agentState.lastError ?? null,
|
lastError: agentState.lastError ?? null,
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
}
|
}
|
||||||
@@ -1378,7 +1389,10 @@ function ActiveAgentComposer({
|
|||||||
cwd,
|
cwd,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
const attachmentScopeKeys = useMemo(
|
||||||
|
() => [workspaceAttachmentScopeKey],
|
||||||
|
[workspaceAttachmentScopeKey],
|
||||||
|
);
|
||||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
const handleOpenWorkspaceAttachment = useCallback(
|
const handleOpenWorkspaceAttachment = useCallback(
|
||||||
@@ -1479,7 +1493,7 @@ function ActiveAgentComposer({
|
|||||||
value={agentInputDraft.text}
|
value={agentInputDraft.text}
|
||||||
onChangeText={agentInputDraft.setText}
|
onChangeText={agentInputDraft.setText}
|
||||||
attachments={agentInputDraft.attachments}
|
attachments={agentInputDraft.attachments}
|
||||||
workspaceAttachments={workspaceAttachments}
|
attachmentScopeKeys={attachmentScopeKeys}
|
||||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||||
onChangeAttachments={agentInputDraft.setAttachments}
|
onChangeAttachments={agentInputDraft.setAttachments}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
|
|||||||
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 { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import {
|
||||||
|
useWorkspaceDraftSubmissionStore,
|
||||||
|
type PendingWorkspaceDraftSetup,
|
||||||
|
} from "@/stores/workspace-draft-submission-store";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import { generateMessageId } from "@/types/stream";
|
import { generateMessageId } from "@/types/stream";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||||
@@ -57,11 +61,17 @@ import {
|
|||||||
} from "@/projects/host-projects";
|
} from "@/projects/host-projects";
|
||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
import type { MessagePayload } from "@/composer/types";
|
import type { MessagePayload } from "@/composer/types";
|
||||||
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||||
|
import {
|
||||||
|
getWorkspaceNamingAttachments,
|
||||||
|
remapDraftCwdToWorkspace,
|
||||||
|
} from "./new-workspace-fork-context";
|
||||||
import {
|
import {
|
||||||
pickerItemToCheckoutRequest,
|
pickerItemToCheckoutRequest,
|
||||||
type PickerCheckoutRequest,
|
type PickerCheckoutRequest,
|
||||||
@@ -82,6 +92,37 @@ function resolveCheckoutRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useIsNewWorkspaceDraftHandoffActive(input: {
|
||||||
|
draftId: string | undefined;
|
||||||
|
selectedServerId: string;
|
||||||
|
}): boolean {
|
||||||
|
const normalizedDraftId = input.draftId?.trim() ?? "";
|
||||||
|
return useCreateFlowStore((state) =>
|
||||||
|
isActiveCreateFlowForDraft({
|
||||||
|
draftId: normalizedDraftId,
|
||||||
|
serverId: input.selectedServerId,
|
||||||
|
pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVisibleDraftContextScopeKeys(input: {
|
||||||
|
isDraftHandoffActive: boolean;
|
||||||
|
draftContextScopeKey: string;
|
||||||
|
}): readonly string[] {
|
||||||
|
if (input.isDraftHandoffActive || !input.draftContextScopeKey) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [input.draftContextScopeKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewWorkspacePending(input: {
|
||||||
|
pendingAction: "chat" | "empty" | null;
|
||||||
|
isDraftHandoffActive: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return input.pendingAction !== null || input.isDraftHandoffActive;
|
||||||
|
}
|
||||||
|
|
||||||
function buildFirstAgentContext(input: {
|
function buildFirstAgentContext(input: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
attachments: AgentAttachment[];
|
attachments: AgentAttachment[];
|
||||||
@@ -102,6 +143,7 @@ interface NewWorkspaceScreenProps {
|
|||||||
sourceDirectory?: string;
|
sourceDirectory?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
draftId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerOptionData {
|
interface PickerOptionData {
|
||||||
@@ -714,6 +756,18 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
|
|||||||
return [styles.content, styles.contentCentered];
|
return [styles.content, styles.contentCentered];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildNewWorkspaceDraftKey(input: {
|
||||||
|
selectedServerId: string;
|
||||||
|
selectedSourceDirectory: string | null;
|
||||||
|
draftId?: string;
|
||||||
|
}): string {
|
||||||
|
const explicitDraftId = input.draftId?.trim();
|
||||||
|
if (explicitDraftId) {
|
||||||
|
return `new-workspace:draft:${explicitDraftId}`;
|
||||||
|
}
|
||||||
|
return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
|
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
|
||||||
if (!selection) return null;
|
if (!selection) return null;
|
||||||
return selection.item;
|
return selection.item;
|
||||||
@@ -733,12 +787,28 @@ function normalizeBranchDetails(
|
|||||||
interface SubmitDraftInput {
|
interface SubmitDraftInput {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
draftKey: string;
|
draftKey: string;
|
||||||
|
draftId?: string;
|
||||||
|
initialSetup?: WorkspaceDraftTabSetup;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
text: string;
|
text: string;
|
||||||
attachments: ComposerAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
composerState: NonNullable<ReturnType<typeof useAgentInputDraft>["composerState"]>;
|
composerState: NewWorkspaceComposerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewWorkspaceComposerState = NonNullable<
|
||||||
|
ReturnType<typeof useAgentInputDraft>["composerState"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface WorkspaceDraftSubmissionConfig {
|
||||||
|
cwd: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
modeId: string | null;
|
||||||
|
model: string | null;
|
||||||
|
thinkingOptionId: string | null;
|
||||||
|
featureValues: Record<string, unknown> | undefined;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndMergeWorkspace(input: {
|
async function createAndMergeWorkspace(input: {
|
||||||
@@ -820,6 +890,7 @@ async function createMultiplicityWorkspace(input: {
|
|||||||
interface CreateChatAgentInput {
|
interface CreateChatAgentInput {
|
||||||
payload: MessagePayload;
|
payload: MessagePayload;
|
||||||
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
|
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
|
||||||
|
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
|
||||||
ensureWorkspace: (input: {
|
ensureWorkspace: (input: {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -828,12 +899,67 @@ interface CreateChatAgentInput {
|
|||||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
draftKey: string;
|
draftKey: string;
|
||||||
|
draftId?: string;
|
||||||
labels: {
|
labels: {
|
||||||
composerStateRequired: string;
|
composerStateRequired: string;
|
||||||
selectModel: string;
|
selectModel: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceDraftSetupFromComposer(input: {
|
||||||
|
cwd: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
composerState: NewWorkspaceComposerState;
|
||||||
|
}): WorkspaceDraftTabSetup {
|
||||||
|
return {
|
||||||
|
provider: input.provider,
|
||||||
|
cwd: input.cwd,
|
||||||
|
modeId: input.composerState.selectedMode || null,
|
||||||
|
model: input.composerState.effectiveModelId || null,
|
||||||
|
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
|
||||||
|
featureValues: input.composerState.featureValues ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceDraftSetupForCreatedWorkspace(input: {
|
||||||
|
forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
composerState: NewWorkspaceComposerState;
|
||||||
|
}): WorkspaceDraftTabSetup | undefined {
|
||||||
|
if (!input.forkDraftSetup) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return buildWorkspaceDraftSetupFromComposer({
|
||||||
|
cwd: remapDraftCwdToWorkspace({
|
||||||
|
cwd: input.forkDraftSetup.setup.cwd,
|
||||||
|
sourceDirectory: input.forkDraftSetup.sourceDirectory,
|
||||||
|
workspaceDirectory: input.workspaceDirectory,
|
||||||
|
}),
|
||||||
|
provider: input.provider,
|
||||||
|
composerState: input.composerState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildComposerInitialValues(input: {
|
||||||
|
workingDir: string | undefined;
|
||||||
|
initialSetup?: WorkspaceDraftTabSetup | null;
|
||||||
|
}): CreateAgentInitialValues | undefined {
|
||||||
|
if (input.initialSetup) {
|
||||||
|
return {
|
||||||
|
workingDir: input.workingDir ?? input.initialSetup.cwd,
|
||||||
|
provider: input.initialSetup.provider,
|
||||||
|
modeId: input.initialSetup.modeId,
|
||||||
|
model: input.initialSetup.model,
|
||||||
|
thinkingOptionId: input.initialSetup.thinkingOptionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.workingDir) {
|
||||||
|
return { workingDir: input.workingDir };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||||
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
|
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
|
||||||
const { text, attachments, cwd } = payload;
|
const { text, attachments, cwd } = payload;
|
||||||
@@ -845,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
|||||||
throw new Error(input.labels.selectModel);
|
throw new Error(input.labels.selectModel);
|
||||||
}
|
}
|
||||||
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
|
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
|
||||||
|
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
|
||||||
const ensuredWorkspace = await ensureWorkspace({
|
const ensuredWorkspace = await ensureWorkspace({
|
||||||
cwd,
|
cwd,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
attachments: reviewAttachments,
|
attachments: workspaceNamingAttachments,
|
||||||
withInitialAgent: true,
|
withInitialAgent: true,
|
||||||
});
|
});
|
||||||
|
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
|
||||||
|
forkDraftSetup: input.forkDraftSetup,
|
||||||
|
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||||
|
provider,
|
||||||
|
composerState,
|
||||||
|
});
|
||||||
submitWorkspaceDraft({
|
submitWorkspaceDraft({
|
||||||
serverId,
|
serverId,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
draftId: input.draftId,
|
||||||
|
initialSetup,
|
||||||
workspaceId: ensuredWorkspace.id,
|
workspaceId: ensuredWorkspace.id,
|
||||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||||
text,
|
text,
|
||||||
@@ -868,12 +1003,14 @@ function buildComposerConfig(input: {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
workspaceDirectory: string | null;
|
workspaceDirectory: string | null;
|
||||||
sourceDirectory: string | null;
|
sourceDirectory: string | null;
|
||||||
|
initialSetup?: WorkspaceDraftTabSetup | null;
|
||||||
}): Parameters<typeof useAgentInputDraft>[0]["composer"] {
|
}): Parameters<typeof useAgentInputDraft>[0]["composer"] {
|
||||||
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
|
const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input;
|
||||||
const workingDir = workspaceDirectory || sourceDirectory || undefined;
|
const workingDir = workspaceDirectory || sourceDirectory || undefined;
|
||||||
return {
|
return {
|
||||||
initialServerId: serverId || null,
|
initialServerId: serverId || null,
|
||||||
initialValues: workingDir ? { workingDir } : undefined,
|
initialValues: buildComposerInitialValues({ workingDir, initialSetup }),
|
||||||
|
initialFeatureValues: initialSetup?.featureValues,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||||
lockedWorkingDir: workingDir,
|
lockedWorkingDir: workingDir,
|
||||||
@@ -921,21 +1058,72 @@ function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttach
|
|||||||
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
|
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePendingWorkspaceDraftSetup(
|
||||||
|
draftId: string | undefined,
|
||||||
|
): PendingWorkspaceDraftSetup | null {
|
||||||
|
const normalizedDraftId = draftId?.trim() ?? "";
|
||||||
|
return useWorkspaceDraftSubmissionStore((state) => {
|
||||||
|
if (!normalizedDraftId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.setupByDraftId[normalizedDraftId] ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||||
|
draftId: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
composerState: NewWorkspaceComposerState;
|
||||||
|
initialSetup?: WorkspaceDraftTabSetup;
|
||||||
|
}): WorkspaceDraftSubmissionConfig {
|
||||||
|
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
|
||||||
|
if (initialSetup) {
|
||||||
|
return {
|
||||||
|
cwd: initialSetup.cwd,
|
||||||
|
provider: initialSetup.provider,
|
||||||
|
modeId: initialSetup.modeId,
|
||||||
|
model: initialSetup.model,
|
||||||
|
thinkingOptionId: initialSetup.thinkingOptionId,
|
||||||
|
featureValues: initialSetup.featureValues,
|
||||||
|
target: { kind: "draft", draftId, setup: initialSetup },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
provider,
|
||||||
|
modeId: composerState.selectedMode || null,
|
||||||
|
model: composerState.effectiveModelId || null,
|
||||||
|
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||||
|
featureValues: composerState.featureValues,
|
||||||
|
target: { kind: "draft", draftId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||||
const {
|
const {
|
||||||
serverId,
|
serverId,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
draftId: draftIdInput,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
text,
|
text,
|
||||||
attachments,
|
attachments,
|
||||||
provider,
|
provider,
|
||||||
composerState,
|
composerState,
|
||||||
|
initialSetup,
|
||||||
} = input;
|
} = input;
|
||||||
const draftId = generateDraftId();
|
const draftId = draftIdInput?.trim() || generateDraftId();
|
||||||
const clientMessageId = generateMessageId();
|
const clientMessageId = generateMessageId();
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||||
|
const submission = resolveWorkspaceDraftSubmissionConfig({
|
||||||
|
draftId,
|
||||||
|
workspaceDirectory,
|
||||||
|
provider,
|
||||||
|
composerState,
|
||||||
|
initialSetup,
|
||||||
|
});
|
||||||
useCreateFlowStore.getState().setPending({
|
useCreateFlowStore.getState().setPending({
|
||||||
serverId,
|
serverId,
|
||||||
draftId,
|
draftId,
|
||||||
@@ -953,23 +1141,21 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
draftId,
|
draftId,
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
attachments,
|
attachments,
|
||||||
cwd: workspaceDirectory,
|
cwd: submission.cwd,
|
||||||
provider,
|
provider: submission.provider,
|
||||||
clientMessageId,
|
clientMessageId,
|
||||||
timestamp,
|
timestamp,
|
||||||
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}),
|
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
...(submission.model ? { model: submission.model } : {}),
|
||||||
...(composerState.effectiveThinkingOptionId
|
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
|
||||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
||||||
: {}),
|
|
||||||
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
|
|
||||||
allowEmptyText: true,
|
allowEmptyText: true,
|
||||||
});
|
});
|
||||||
navigateToPreparedWorkspaceTab({
|
navigateToPreparedWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
currentPathname: "/new",
|
currentPathname: "/new",
|
||||||
target: { kind: "draft", draftId },
|
target: submission.target,
|
||||||
});
|
});
|
||||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||||
}
|
}
|
||||||
@@ -1360,6 +1546,7 @@ export function NewWorkspaceScreen({
|
|||||||
sourceDirectory: sourceDirectoryProp,
|
sourceDirectory: sourceDirectoryProp,
|
||||||
projectId,
|
projectId,
|
||||||
displayName: displayNameProp,
|
displayName: displayNameProp,
|
||||||
|
draftId,
|
||||||
}: NewWorkspaceScreenProps) {
|
}: NewWorkspaceScreenProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -1396,6 +1583,7 @@ export function NewWorkspaceScreen({
|
|||||||
const projectPickerAnchorRef = useRef<View>(null);
|
const projectPickerAnchorRef = useRef<View>(null);
|
||||||
const isolationPickerAnchorRef = useRef<View>(null);
|
const isolationPickerAnchorRef = useRef<View>(null);
|
||||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||||
|
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const trimmed = pickerSearchQuery.trim();
|
const trimmed = pickerSearchQuery.trim();
|
||||||
@@ -1404,7 +1592,7 @@ export function NewWorkspaceScreen({
|
|||||||
}, [pickerSearchQuery]);
|
}, [pickerSearchQuery]);
|
||||||
|
|
||||||
const workspace = createdWorkspace;
|
const workspace = createdWorkspace;
|
||||||
const isPending = pendingAction !== null;
|
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
||||||
const client = useHostRuntimeClient(selectedServerId);
|
const client = useHostRuntimeClient(selectedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(selectedServerId);
|
const isConnected = useHostRuntimeIsConnected(selectedServerId);
|
||||||
const {
|
const {
|
||||||
@@ -1441,7 +1629,17 @@ export function NewWorkspaceScreen({
|
|||||||
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
|
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
|
||||||
projects: projectIconTargets,
|
projects: projectIconTargets,
|
||||||
});
|
});
|
||||||
const draftKey = `new-workspace:${selectedServerId}:${selectedSourceDirectory ?? "choose-project"}`;
|
const draftKey = buildNewWorkspaceDraftKey({
|
||||||
|
selectedServerId,
|
||||||
|
selectedSourceDirectory,
|
||||||
|
draftId,
|
||||||
|
});
|
||||||
|
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
|
||||||
|
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
||||||
|
const visibleDraftContextScopeKeys = useMemo(
|
||||||
|
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
|
||||||
|
[draftContextScopeKey, isDraftHandoffActive],
|
||||||
|
);
|
||||||
const chatDraft = useAgentInputDraft({
|
const chatDraft = useAgentInputDraft({
|
||||||
draftKey,
|
draftKey,
|
||||||
composer: buildComposerConfig({
|
composer: buildComposerConfig({
|
||||||
@@ -1449,6 +1647,7 @@ export function NewWorkspaceScreen({
|
|||||||
isConnected,
|
isConnected,
|
||||||
workspaceDirectory: workspace?.workspaceDirectory ?? null,
|
workspaceDirectory: workspace?.workspaceDirectory ?? null,
|
||||||
sourceDirectory: selectedSourceDirectory,
|
sourceDirectory: selectedSourceDirectory,
|
||||||
|
initialSetup: forkDraftSetup?.setup,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const composerState = chatDraft.composerState;
|
const composerState = chatDraft.composerState;
|
||||||
@@ -1793,9 +1992,11 @@ export function NewWorkspaceScreen({
|
|||||||
await runCreateChatAgent({
|
await runCreateChatAgent({
|
||||||
payload,
|
payload,
|
||||||
composerState,
|
composerState,
|
||||||
|
forkDraftSetup,
|
||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
draftId,
|
||||||
labels: {
|
labels: {
|
||||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||||
selectModel: t("newWorkspace.errors.selectModel"),
|
selectModel: t("newWorkspace.errors.selectModel"),
|
||||||
@@ -1808,7 +2009,7 @@ export function NewWorkspaceScreen({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerState, draftKey, ensureWorkspace, selectedServerId, t, toast],
|
[composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPickerOption = useCallback(
|
const renderPickerOption = useCallback(
|
||||||
@@ -1987,12 +2188,13 @@ export function NewWorkspaceScreen({
|
|||||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||||
submitButtonTestID="workspace-create-submit"
|
submitButtonTestID="workspace-create-submit"
|
||||||
submitIcon="return"
|
submitIcon="return"
|
||||||
isSubmitLoading={pendingAction !== null}
|
isSubmitLoading={isPending}
|
||||||
submitBehavior="preserve-and-lock"
|
submitBehavior="preserve-and-lock"
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
value={chatDraft.text}
|
value={chatDraft.text}
|
||||||
onChangeText={chatDraft.setText}
|
onChangeText={chatDraft.setText}
|
||||||
attachments={chatDraft.attachments}
|
attachments={chatDraft.attachments}
|
||||||
|
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||||
onChangeAttachments={chatDraft.setAttachments}
|
onChangeAttachments={chatDraft.setAttachments}
|
||||||
cwd={selectedSourceDirectory ?? ""}
|
cwd={selectedSourceDirectory ?? ""}
|
||||||
clearDraft={handleClearDraft}
|
clearDraft={handleClearDraft}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { useCreateFlowStore } from "./create-flow-store";
|
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "./create-flow-store";
|
||||||
|
|
||||||
describe("create-flow-store", () => {
|
describe("create-flow-store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -88,4 +88,40 @@ describe("create-flow-store", () => {
|
|||||||
|
|
||||||
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
|
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches only active pending create flows for a draft and server", () => {
|
||||||
|
useCreateFlowStore.getState().setPending({
|
||||||
|
draftId: "draft-1",
|
||||||
|
serverId: "server-1",
|
||||||
|
agentId: null,
|
||||||
|
clientMessageId: "msg-1",
|
||||||
|
text: "hello",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
const pending = useCreateFlowStore.getState().pendingByDraftId["draft-1"];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isActiveCreateFlowForDraft({
|
||||||
|
pending,
|
||||||
|
serverId: "server-1",
|
||||||
|
draftId: " draft-1 ",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isActiveCreateFlowForDraft({
|
||||||
|
pending,
|
||||||
|
serverId: "server-2",
|
||||||
|
draftId: "draft-1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "sent" });
|
||||||
|
expect(
|
||||||
|
isActiveCreateFlowForDraft({
|
||||||
|
pending: useCreateFlowStore.getState().pendingByDraftId["draft-1"],
|
||||||
|
serverId: "server-1",
|
||||||
|
draftId: "draft-1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ export interface PendingCreateAttempt {
|
|||||||
attachments?: AgentAttachment[];
|
attachments?: AgentAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isActiveCreateFlowForDraft(input: {
|
||||||
|
pending: PendingCreateAttempt | null | undefined;
|
||||||
|
serverId: string;
|
||||||
|
draftId: string | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const draftId = input.draftId?.trim();
|
||||||
|
return Boolean(
|
||||||
|
draftId &&
|
||||||
|
input.pending?.draftId === draftId &&
|
||||||
|
input.pending.serverId === input.serverId &&
|
||||||
|
input.pending.lifecycle === "active",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateFlowState {
|
interface CreateFlowState {
|
||||||
pendingByDraftId: Record<string, PendingCreateAttempt>;
|
pendingByDraftId: Record<string, PendingCreateAttempt>;
|
||||||
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
|
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
|
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
export interface PendingWorkspaceDraftSubmission {
|
export interface PendingWorkspaceDraftSubmission {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -19,9 +20,21 @@ export interface PendingWorkspaceDraftSubmission {
|
|||||||
allowEmptyText?: boolean;
|
allowEmptyText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingWorkspaceDraftSetup {
|
||||||
|
setup: WorkspaceDraftTabSetup;
|
||||||
|
sourceDirectory?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkspaceDraftSubmissionState {
|
interface WorkspaceDraftSubmissionState {
|
||||||
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
|
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
|
||||||
|
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
|
||||||
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
|
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
|
||||||
|
setDraftSetup: (input: {
|
||||||
|
draftId: string;
|
||||||
|
setup: WorkspaceDraftTabSetup;
|
||||||
|
sourceDirectory?: string | null;
|
||||||
|
}) => void;
|
||||||
|
clearDraftSetup: (input: { draftId: string }) => void;
|
||||||
consumePending: (input: {
|
consumePending: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -40,9 +53,14 @@ function matchesPendingSubmission(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDraftId(draftId: string): string {
|
||||||
|
return draftId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>(
|
export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
pendingByDraftId: {},
|
pendingByDraftId: {},
|
||||||
|
setupByDraftId: {},
|
||||||
setPending: (submission) =>
|
setPending: (submission) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
pendingByDraftId: {
|
pendingByDraftId: {
|
||||||
@@ -50,6 +68,25 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
|||||||
[submission.draftId]: submission,
|
[submission.draftId]: submission,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
setDraftSetup: ({ draftId, setup, sourceDirectory }) => {
|
||||||
|
const normalizedDraftId = normalizeDraftId(draftId);
|
||||||
|
if (!normalizedDraftId) return;
|
||||||
|
set((state) => ({
|
||||||
|
setupByDraftId: {
|
||||||
|
...state.setupByDraftId,
|
||||||
|
[normalizedDraftId]: { setup, sourceDirectory: sourceDirectory ?? null },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clearDraftSetup: ({ draftId }) => {
|
||||||
|
const normalizedDraftId = normalizeDraftId(draftId);
|
||||||
|
if (!normalizedDraftId) return;
|
||||||
|
set((state) => {
|
||||||
|
if (!state.setupByDraftId[normalizedDraftId]) return state;
|
||||||
|
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
|
||||||
|
return { setupByDraftId };
|
||||||
|
});
|
||||||
|
},
|
||||||
consumePending: (input) => {
|
consumePending: (input) => {
|
||||||
const pending = get().pendingByDraftId[input.draftId];
|
const pending = get().pendingByDraftId[input.draftId];
|
||||||
if (!matchesPendingSubmission(pending, input)) {
|
if (!matchesPendingSubmission(pending, input)) {
|
||||||
|
|||||||
@@ -986,6 +986,7 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
|
|||||||
groupId: blockGroupId,
|
groupId: blockGroupId,
|
||||||
blockIndex: firstBlockIndex + offset,
|
blockIndex: firstBlockIndex + offset,
|
||||||
}),
|
}),
|
||||||
|
...(activeItem.messageId ? { messageId: activeItem.messageId } : {}),
|
||||||
blockGroupId,
|
blockGroupId,
|
||||||
blockIndex: firstBlockIndex + offset,
|
blockIndex: firstBlockIndex + offset,
|
||||||
text: block,
|
text: block,
|
||||||
|
|||||||
@@ -238,6 +238,16 @@ describe("global routes", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
|
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buildNewWorkspaceRoute carries a draft context id", () => {
|
||||||
|
expect(
|
||||||
|
buildNewWorkspaceRoute({
|
||||||
|
serverId: "local",
|
||||||
|
sourceDirectory: "/repo/project",
|
||||||
|
draftId: "draft-1",
|
||||||
|
}),
|
||||||
|
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&draftId=draft-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("host settings section slugs", () => {
|
describe("host settings section slugs", () => {
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ interface NewWorkspaceRouteOptions {
|
|||||||
sourceDirectory?: string;
|
sourceDirectory?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
draftId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
|
function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
|
||||||
@@ -441,6 +442,9 @@ function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
|
|||||||
if (options.projectId) {
|
if (options.projectId) {
|
||||||
params.set("projectId", options.projectId);
|
params.set("projectId", options.projectId);
|
||||||
}
|
}
|
||||||
|
if (options.draftId) {
|
||||||
|
params.set("draftId", options.draftId);
|
||||||
|
}
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
FileExplorerResponse,
|
FileExplorerResponse,
|
||||||
FetchAgentTimelineResponseMessage,
|
FetchAgentTimelineResponseMessage,
|
||||||
|
AgentForkContextResponseMessage,
|
||||||
GitSetupOptions,
|
GitSetupOptions,
|
||||||
CheckoutStatusResponse,
|
CheckoutStatusResponse,
|
||||||
CheckoutCommitResponse,
|
CheckoutCommitResponse,
|
||||||
@@ -456,6 +457,7 @@ type ScheduleUpdatePayload = Extract<
|
|||||||
{ type: "schedule/update/response" }
|
{ type: "schedule/update/response" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
||||||
|
export type AgentForkContextPayload = AgentForkContextResponseMessage["payload"];
|
||||||
|
|
||||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||||
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
|
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
|
||||||
@@ -502,6 +504,10 @@ function normalizeListCommandsOptions(
|
|||||||
}
|
}
|
||||||
return { agentId: input, ...legacyOptions };
|
return { agentId: input, ...legacyOptions };
|
||||||
}
|
}
|
||||||
|
export interface AgentForkContextOptions {
|
||||||
|
boundaryMessageId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
|
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>;
|
||||||
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
|
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>;
|
||||||
@@ -2405,6 +2411,41 @@ export class DaemonClient {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async buildAgentForkContext(
|
||||||
|
agentId: string,
|
||||||
|
options: AgentForkContextOptions = {},
|
||||||
|
): Promise<AgentForkContextPayload> {
|
||||||
|
const resolvedRequestId = this.createRequestId(options.requestId);
|
||||||
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
type: "agent.fork_context.request",
|
||||||
|
agentId,
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await this.sendRequest({
|
||||||
|
requestId: resolvedRequestId,
|
||||||
|
message,
|
||||||
|
timeout: 15000,
|
||||||
|
options: { skipQueue: true },
|
||||||
|
select: (msg) => {
|
||||||
|
if (msg.type !== "agent.fork_context.response") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.payload.requestId !== resolvedRequestId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msg.payload;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent Interaction
|
// Agent Interaction
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -226,6 +226,47 @@ describe("shared messages attachments", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps known text attachment context kinds and ignores future ones", () => {
|
||||||
|
const parsed = SendAgentMessageRequestSchema.parse({
|
||||||
|
type: "send_agent_message_request",
|
||||||
|
requestId: "req-text-context",
|
||||||
|
agentId: "agent-1",
|
||||||
|
text: "Continue",
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "chat_history",
|
||||||
|
title: "Chat history",
|
||||||
|
text: "Earlier context",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "future_context",
|
||||||
|
title: "Future context",
|
||||||
|
text: "Future client hint",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.attachments).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "chat_history",
|
||||||
|
title: "Chat history",
|
||||||
|
text: "Earlier context",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: "Future context",
|
||||||
|
text: "Future client hint",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
|
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
|
||||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||||
type: "create_paseo_worktree_request",
|
type: "create_paseo_worktree_request",
|
||||||
|
|||||||
@@ -843,12 +843,18 @@ export const GitHubIssueAttachmentSchema = z.object({
|
|||||||
body: z.string().nullable().optional(),
|
body: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TextAttachmentSchema = z.object({
|
export const TextAttachmentSchema = z
|
||||||
type: z.literal("text"),
|
.object({
|
||||||
mimeType: z.literal("text/plain"),
|
type: z.literal("text"),
|
||||||
title: z.string().nullable().optional(),
|
mimeType: z.literal("text/plain"),
|
||||||
text: z.string(),
|
contextKind: z.string().optional(),
|
||||||
});
|
title: z.string().nullable().optional(),
|
||||||
|
text: z.string(),
|
||||||
|
})
|
||||||
|
.transform(({ contextKind, ...attachment }) => ({
|
||||||
|
...attachment,
|
||||||
|
...(contextKind === "chat_history" ? { contextKind } : {}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const ReviewAttachmentContextLineSchema = z.object({
|
export const ReviewAttachmentContextLineSchema = z.object({
|
||||||
oldLineNumber: z.number().int().positive().nullable(),
|
oldLineNumber: z.number().int().positive().nullable(),
|
||||||
@@ -1274,6 +1280,13 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
|
|||||||
projection: z.enum(["projected", "canonical"]).optional(),
|
projection: z.enum(["projected", "canonical"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AgentForkContextRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("agent.fork_context.request"),
|
||||||
|
agentId: z.string(),
|
||||||
|
boundaryMessageId: z.string().optional(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const SetAgentModeRequestMessageSchema = z.object({
|
export const SetAgentModeRequestMessageSchema = z.object({
|
||||||
type: z.literal("set_agent_mode_request"),
|
type: z.literal("set_agent_mode_request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
@@ -2060,6 +2073,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
RestartServerRequestMessageSchema,
|
RestartServerRequestMessageSchema,
|
||||||
DaemonUpdateRequestMessageSchema,
|
DaemonUpdateRequestMessageSchema,
|
||||||
FetchAgentTimelineRequestMessageSchema,
|
FetchAgentTimelineRequestMessageSchema,
|
||||||
|
AgentForkContextRequestMessageSchema,
|
||||||
SetAgentModeRequestMessageSchema,
|
SetAgentModeRequestMessageSchema,
|
||||||
SetAgentModelRequestMessageSchema,
|
SetAgentModelRequestMessageSchema,
|
||||||
SetAgentThinkingRequestMessageSchema,
|
SetAgentThinkingRequestMessageSchema,
|
||||||
@@ -2335,6 +2349,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
daemonDiagnostics: z.boolean().optional(),
|
daemonDiagnostics: z.boolean().optional(),
|
||||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||||
daemonSelfUpdate: z.boolean().optional(),
|
daemonSelfUpdate: z.boolean().optional(),
|
||||||
|
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||||
|
agentForkContext: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -2926,6 +2942,18 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AgentForkContextResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("agent.fork_context.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
agentId: z.string(),
|
||||||
|
attachment: TextAttachmentSchema.nullable(),
|
||||||
|
itemCount: z.number().int().nonnegative(),
|
||||||
|
boundaryMessageId: z.string().nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const CancelAgentResponseMessageSchema = z.object({
|
export const CancelAgentResponseMessageSchema = z.object({
|
||||||
type: z.literal("cancel_agent_response"),
|
type: z.literal("cancel_agent_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -4158,6 +4186,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ArchiveWorkspaceResponseMessageSchema,
|
ArchiveWorkspaceResponseMessageSchema,
|
||||||
FetchAgentResponseMessageSchema,
|
FetchAgentResponseMessageSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
|
AgentForkContextResponseMessageSchema,
|
||||||
CancelAgentResponseMessageSchema,
|
CancelAgentResponseMessageSchema,
|
||||||
ClearAgentAttentionResponseMessageSchema,
|
ClearAgentAttentionResponseMessageSchema,
|
||||||
WorkspaceCreateResponseSchema,
|
WorkspaceCreateResponseSchema,
|
||||||
@@ -4320,6 +4349,7 @@ export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessage
|
|||||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||||
typeof FetchAgentTimelineResponseMessageSchema
|
typeof FetchAgentTimelineResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
export type AgentForkContextResponseMessage = z.infer<typeof AgentForkContextResponseMessageSchema>;
|
||||||
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
|
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
|
||||||
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
||||||
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
|
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
|
||||||
@@ -4410,6 +4440,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
|
|||||||
>;
|
>;
|
||||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||||
|
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
||||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
||||||
export type DictationStreamStartMessage = z.infer<typeof DictationStreamStartMessageSchema>;
|
export type DictationStreamStartMessage = z.infer<typeof DictationStreamStartMessageSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
|
||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
|
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||||
|
|
||||||
function toolCallItem(params: {
|
function toolCallItem(params: {
|
||||||
callId: string;
|
callId: string;
|
||||||
@@ -29,6 +30,14 @@ function toolCallItem(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
|
||||||
|
return {
|
||||||
|
seq,
|
||||||
|
timestamp: `2026-06-28T00:00:${String(seq).padStart(2, "0")}.000Z`,
|
||||||
|
item,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("curateAgentActivity", () => {
|
describe("curateAgentActivity", () => {
|
||||||
it("renders user/assistant/reasoning entries", () => {
|
it("renders user/assistant/reasoning entries", () => {
|
||||||
const timeline: AgentTimelineItem[] = [
|
const timeline: AgentTimelineItem[] = [
|
||||||
@@ -217,4 +226,140 @@ second line'`,
|
|||||||
it("returns a default message when timeline is empty", () => {
|
it("returns a default message when timeline is empty", () => {
|
||||||
expect(curateAgentActivity([])).toBe("No activity to display.");
|
expect(curateAgentActivity([])).toBe("No activity to display.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds fork context from user messages, assistant messages, and tool summaries", () => {
|
||||||
|
const result = buildAgentForkContextAttachment({
|
||||||
|
agentTitle: "Source Agent",
|
||||||
|
cwd: "/repo",
|
||||||
|
boundaryMessageId: "assistant-1",
|
||||||
|
rows: [
|
||||||
|
row(1, { type: "user_message", text: "Ship the thing", messageId: "user-1" }),
|
||||||
|
row(2, { type: "reasoning", text: "private chain of thought" }),
|
||||||
|
row(
|
||||||
|
3,
|
||||||
|
toolCallItem({
|
||||||
|
callId: "read-1",
|
||||||
|
name: "read_file",
|
||||||
|
detail: {
|
||||||
|
type: "read",
|
||||||
|
filePath: "src/index.ts",
|
||||||
|
content: "console.log('hi')",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
4,
|
||||||
|
toolCallItem({
|
||||||
|
callId: "external-1",
|
||||||
|
name: "paseo__create_agent",
|
||||||
|
input: { initialPrompt: "do not include raw external tool input" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
row(5, {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "Done.",
|
||||||
|
messageId: "assistant-1",
|
||||||
|
}),
|
||||||
|
row(6, {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "Later answer.",
|
||||||
|
messageId: "assistant-2",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.boundaryMessageId).toBe("assistant-1");
|
||||||
|
expect(result.attachment).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "chat_history",
|
||||||
|
title: "Chat history",
|
||||||
|
});
|
||||||
|
expect(result.attachment.text).toContain("Source agent: Source Agent");
|
||||||
|
expect(result.attachment.text).toContain("Source directory: /repo");
|
||||||
|
expect(result.attachment.text).toContain("[User] Ship the thing");
|
||||||
|
expect(result.attachment.text).toContain("[Read] src/index.ts");
|
||||||
|
expect(result.attachment.text).toContain("[paseo__create_agent]");
|
||||||
|
expect(result.attachment.text).toContain("[Assistant] Done.");
|
||||||
|
expect(result.attachment.text).not.toContain("private chain of thought");
|
||||||
|
expect(result.attachment.text).not.toContain("do not include raw external tool input");
|
||||||
|
expect(result.attachment.text).not.toContain("Later answer.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cap fork context to the generic recent activity limit", () => {
|
||||||
|
const messageRows = Array.from({ length: 25 }, (_, index) =>
|
||||||
|
row(index + 1, {
|
||||||
|
type: "user_message",
|
||||||
|
text: `Message ${index + 1}`,
|
||||||
|
messageId: `user-${index + 1}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = buildAgentForkContextAttachment({
|
||||||
|
boundaryMessageId: "assistant-1",
|
||||||
|
rows: [
|
||||||
|
...messageRows,
|
||||||
|
row(26, {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "Done.",
|
||||||
|
messageId: "assistant-1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.itemCount).toBe(26);
|
||||||
|
expect(result.attachment.text).toContain("[User] Message 1");
|
||||||
|
expect(result.attachment.text).toContain("[User] Message 25");
|
||||||
|
expect(result.attachment.text).toContain("[Assistant] Done.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects the fork boundary before collapsing later tool updates", () => {
|
||||||
|
const result = buildAgentForkContextAttachment({
|
||||||
|
boundaryMessageId: "assistant-1",
|
||||||
|
rows: [
|
||||||
|
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
|
||||||
|
row(
|
||||||
|
2,
|
||||||
|
toolCallItem({
|
||||||
|
callId: "terminal-1",
|
||||||
|
name: "terminal",
|
||||||
|
status: "running",
|
||||||
|
detail: {
|
||||||
|
type: "plain_text",
|
||||||
|
label: "before boundary",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
row(3, {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "Partial result.",
|
||||||
|
messageId: "assistant-1",
|
||||||
|
}),
|
||||||
|
row(
|
||||||
|
4,
|
||||||
|
toolCallItem({
|
||||||
|
callId: "terminal-1",
|
||||||
|
name: "terminal",
|
||||||
|
status: "completed",
|
||||||
|
detail: {
|
||||||
|
type: "plain_text",
|
||||||
|
label: "after boundary",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.attachment.text).toContain("[Terminal] before boundary");
|
||||||
|
expect(result.attachment.text).toContain("[Assistant] Partial result.");
|
||||||
|
expect(result.attachment.text).not.toContain("after boundary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing assistant boundaries instead of silently using the wrong context", () => {
|
||||||
|
expect(() =>
|
||||||
|
buildAgentForkContextAttachment({
|
||||||
|
boundaryMessageId: "missing",
|
||||||
|
rows: [row(1, { type: "assistant_message", text: "Done.", messageId: "assistant-1" })],
|
||||||
|
}),
|
||||||
|
).toThrow("Selected assistant message is no longer available.");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
|
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||||
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
|
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||||
import { projectTimelineRows } from "./timeline-projection.js";
|
import { projectTimelineRows } from "./timeline-projection.js";
|
||||||
@@ -10,12 +12,16 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
|
|||||||
interface ActivityCuratorOptions {
|
interface ActivityCuratorOptions {
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
labelAssistantMessages?: boolean;
|
labelAssistantMessages?: boolean;
|
||||||
|
includeKinds?: readonly AgentTimelineItem["type"][];
|
||||||
|
includeExternalToolInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>;
|
||||||
|
|
||||||
function appendText(buffer: string, text: string): string {
|
function appendText(buffer: string, text: string): string {
|
||||||
const normalized = text.trim();
|
const normalized = text.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -95,24 +101,56 @@ function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineI
|
|||||||
return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item);
|
return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function curateAgentActivityEntries(
|
function shouldIncludeItem(item: AgentTimelineItem, options?: ActivityCuratorOptions): boolean {
|
||||||
timeline: AgentTimelineItem[],
|
if (!options?.includeKinds) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return options.includeKinds.includes(item.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolCallEntry(
|
||||||
|
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
|
||||||
|
options?: ActivityCuratorOptions,
|
||||||
|
): ActivityEntry {
|
||||||
|
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
|
||||||
|
const display = buildToolCallDisplayModel({
|
||||||
|
name: item.name,
|
||||||
|
status: item.status,
|
||||||
|
error: item.error,
|
||||||
|
detail: item.detail,
|
||||||
|
metadata: item.metadata,
|
||||||
|
});
|
||||||
|
const displayName = display.displayName;
|
||||||
|
const summary = formatToolSummary(display.summary);
|
||||||
|
if (
|
||||||
|
(options?.includeExternalToolInput ?? true) &&
|
||||||
|
isLikelyExternalToolName(item.name) &&
|
||||||
|
inputJson
|
||||||
|
) {
|
||||||
|
return activityEntry(`[${displayName}] ${inputJson}`);
|
||||||
|
}
|
||||||
|
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function curateProjectedActivityEntries(
|
||||||
|
items: readonly AgentTimelineItem[],
|
||||||
options?: ActivityCuratorOptions,
|
options?: ActivityCuratorOptions,
|
||||||
): ActivityEntry[] {
|
): ActivityEntry[] {
|
||||||
if (timeline.length === 0) {
|
if (items.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapsed = projectForCuration(timeline);
|
|
||||||
|
|
||||||
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||||
const recentItems =
|
const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items;
|
||||||
maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed;
|
|
||||||
|
|
||||||
const entries: ActivityEntry[] = [];
|
const entries: ActivityEntry[] = [];
|
||||||
const buffers = { message: "", thought: "" };
|
const buffers = { message: "", thought: "" };
|
||||||
|
|
||||||
for (const item of recentItems) {
|
for (const item of recentItems) {
|
||||||
|
if (!shouldIncludeItem(item, options)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "user_message":
|
case "user_message":
|
||||||
flushBuffers(entries, buffers, options);
|
flushBuffers(entries, buffers, options);
|
||||||
@@ -126,25 +164,7 @@ function curateAgentActivityEntries(
|
|||||||
break;
|
break;
|
||||||
case "tool_call": {
|
case "tool_call": {
|
||||||
flushBuffers(entries, buffers, options);
|
flushBuffers(entries, buffers, options);
|
||||||
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
|
entries.push(formatToolCallEntry(item, options));
|
||||||
const display = buildToolCallDisplayModel({
|
|
||||||
name: item.name,
|
|
||||||
status: item.status,
|
|
||||||
error: item.error,
|
|
||||||
detail: item.detail,
|
|
||||||
metadata: item.metadata,
|
|
||||||
});
|
|
||||||
const displayName = display.displayName;
|
|
||||||
const summary = formatToolSummary(display.summary);
|
|
||||||
if (isLikelyExternalToolName(item.name) && inputJson) {
|
|
||||||
entries.push(activityEntry(`[${displayName}] ${inputJson}`));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (summary) {
|
|
||||||
entries.push(activityEntry(`[${displayName}] ${summary}`));
|
|
||||||
} else {
|
|
||||||
entries.push(activityEntry(`[${displayName}]`));
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "todo":
|
case "todo":
|
||||||
@@ -172,6 +192,14 @@ function curateAgentActivityEntries(
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function curateAgentActivityEntries(
|
||||||
|
timeline: AgentTimelineItem[],
|
||||||
|
options?: ActivityCuratorOptions,
|
||||||
|
): ActivityEntry[] {
|
||||||
|
const collapsed = projectForCuration(timeline);
|
||||||
|
return curateProjectedActivityEntries(collapsed, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert normalized agent timeline items into a concise text summary.
|
* Convert normalized agent timeline items into a concise text summary.
|
||||||
*/
|
*/
|
||||||
@@ -184,3 +212,90 @@ export function curateAgentActivity(
|
|||||||
? entries.map((entry) => entry.text).join("\n")
|
? entries.map((entry) => entry.text).join("\n")
|
||||||
: "No activity to display.";
|
: "No activity to display.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectForkContextRows(input: {
|
||||||
|
rows: readonly AgentTimelineRow[];
|
||||||
|
boundaryMessageId?: string | null;
|
||||||
|
}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } {
|
||||||
|
const boundaryMessageId = input.boundaryMessageId?.trim() || null;
|
||||||
|
if (!boundaryMessageId) {
|
||||||
|
const projected = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||||
|
return {
|
||||||
|
items: projected.map((entry) => entry.item),
|
||||||
|
boundaryMessageId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundaryIndex = input.rows.findLastIndex(
|
||||||
|
(row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId,
|
||||||
|
);
|
||||||
|
if (boundaryIndex < 0) {
|
||||||
|
throw new Error("Selected assistant message is no longer available.");
|
||||||
|
}
|
||||||
|
const selectedRows = input.rows.slice(0, boundaryIndex + 1);
|
||||||
|
const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: projected.map((entry) => entry.item),
|
||||||
|
boundaryMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimContextMetadata(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForkContextText(input: {
|
||||||
|
body: string;
|
||||||
|
agentTitle?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
}): string {
|
||||||
|
const header = ["Chat history from a previous Paseo agent."];
|
||||||
|
const agentTitle = trimContextMetadata(input.agentTitle);
|
||||||
|
const cwd = trimContextMetadata(input.cwd);
|
||||||
|
if (agentTitle) {
|
||||||
|
header.push(`Source agent: ${agentTitle}`);
|
||||||
|
}
|
||||||
|
if (cwd) {
|
||||||
|
header.push(`Source directory: ${cwd}`);
|
||||||
|
}
|
||||||
|
return `${header.join("\n")}\n\n${input.body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentForkContextAttachment(input: {
|
||||||
|
rows: readonly AgentTimelineRow[];
|
||||||
|
boundaryMessageId?: string | null;
|
||||||
|
agentTitle?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } {
|
||||||
|
const selected = selectForkContextRows({
|
||||||
|
rows: input.rows,
|
||||||
|
boundaryMessageId: input.boundaryMessageId,
|
||||||
|
});
|
||||||
|
const entries = curateProjectedActivityEntries(selected.items, {
|
||||||
|
maxItems: 0,
|
||||||
|
labelAssistantMessages: true,
|
||||||
|
includeKinds: ["user_message", "assistant_message", "tool_call"],
|
||||||
|
includeExternalToolInput: false,
|
||||||
|
});
|
||||||
|
const body =
|
||||||
|
entries.length > 0
|
||||||
|
? entries.map((entry) => entry.text).join("\n")
|
||||||
|
: "No chat history to display.";
|
||||||
|
return {
|
||||||
|
attachment: {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
contextKind: "chat_history",
|
||||||
|
title: "Chat history",
|
||||||
|
text: buildForkContextText({
|
||||||
|
body,
|
||||||
|
agentTitle: input.agentTitle,
|
||||||
|
cwd: input.cwd,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
itemCount: selected.items.length,
|
||||||
|
boundaryMessageId: selected.boundaryMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const MODELS: AgentModelDefinition[] = [
|
|||||||
|
|
||||||
interface ActiveTurn {
|
interface ActiveTurn {
|
||||||
turnId: string;
|
turnId: string;
|
||||||
|
assistantMessageId: string;
|
||||||
prompt: AgentPromptInput;
|
prompt: AgentPromptInput;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
cycle: number;
|
cycle: number;
|
||||||
@@ -609,12 +610,14 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
|
|
||||||
const profile = resolveModelProfile(this.modelId);
|
const profile = resolveModelProfile(this.modelId);
|
||||||
const turnId = randomUUID();
|
const turnId = randomUUID();
|
||||||
|
const assistantMessageId = randomUUID();
|
||||||
let resolve!: (result: AgentRunResult) => void;
|
let resolve!: (result: AgentRunResult) => void;
|
||||||
const completed = new Promise<AgentRunResult>((promiseResolve) => {
|
const completed = new Promise<AgentRunResult>((promiseResolve) => {
|
||||||
resolve = promiseResolve;
|
resolve = promiseResolve;
|
||||||
});
|
});
|
||||||
const turn: ActiveTurn = {
|
const turn: ActiveTurn = {
|
||||||
turnId,
|
turnId,
|
||||||
|
assistantMessageId,
|
||||||
prompt,
|
prompt,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
cycle: 0,
|
cycle: 0,
|
||||||
@@ -860,6 +863,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.emitTimeline(turn.turnId, {
|
this.emitTimeline(turn.turnId, {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: finalText,
|
text: finalText,
|
||||||
|
messageId: turn.assistantMessageId,
|
||||||
});
|
});
|
||||||
this.activeTurn = null;
|
this.activeTurn = null;
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -874,6 +878,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
{
|
{
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: finalText,
|
text: finalText,
|
||||||
|
messageId: turn.assistantMessageId,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
canceled: false,
|
canceled: false,
|
||||||
@@ -989,6 +994,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
? {
|
? {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: `stress-update-${index}`,
|
text: `stress-update-${index}`,
|
||||||
|
messageId: turn.assistantMessageId,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
type: "todo",
|
type: "todo",
|
||||||
@@ -1067,6 +1073,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.emitTimeline(turn.turnId, {
|
this.emitTimeline(turn.turnId, {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: `data:image/png;base64,${payload}`,
|
text: `data:image/png;base64,${payload}`,
|
||||||
|
messageId: turn.assistantMessageId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,6 +1140,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.emitTimeline(turn.turnId, {
|
this.emitTimeline(turn.turnId, {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: event.text,
|
text: event.text,
|
||||||
|
messageId: turn.assistantMessageId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1186,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.emitTimeline(turn.turnId, {
|
this.emitTimeline(turn.turnId, {
|
||||||
type: "assistant_message",
|
type: "assistant_message",
|
||||||
text: "\n\n_(end of synthetic stream)_\n",
|
text: "\n\n_(end of synthetic stream)_\n",
|
||||||
|
messageId: turn.assistantMessageId,
|
||||||
});
|
});
|
||||||
this.finishTurnWithText(turn, "Synthetic load test complete");
|
this.finishTurnWithText(turn, "Synthetic load test complete");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ import {
|
|||||||
type TimelineProjectionEntry,
|
type TimelineProjectionEntry,
|
||||||
type TimelineProjectionMode,
|
type TimelineProjectionMode,
|
||||||
} from "./agent/timeline-projection.js";
|
} from "./agent/timeline-projection.js";
|
||||||
|
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
|
||||||
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
|
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
|
||||||
import {
|
import {
|
||||||
getAgentStreamEventTurnId,
|
getAgentStreamEventTurnId,
|
||||||
@@ -1371,6 +1372,7 @@ export class Session {
|
|||||||
this.dispatchVoiceAndControlMessage(msg) ??
|
this.dispatchVoiceAndControlMessage(msg) ??
|
||||||
this.dispatchAgentRewindMessage(msg) ??
|
this.dispatchAgentRewindMessage(msg) ??
|
||||||
this.dispatchAgentRelationshipMessage(msg) ??
|
this.dispatchAgentRelationshipMessage(msg) ??
|
||||||
|
this.dispatchAgentTimelineMessage(msg) ??
|
||||||
this.dispatchAgentLifecycleMessage(msg) ??
|
this.dispatchAgentLifecycleMessage(msg) ??
|
||||||
this.dispatchAgentConfigMessage(msg) ??
|
this.dispatchAgentConfigMessage(msg) ??
|
||||||
this.dispatchCheckoutMessage(msg) ??
|
this.dispatchCheckoutMessage(msg) ??
|
||||||
@@ -1450,6 +1452,17 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
|
switch (msg.type) {
|
||||||
|
case "fetch_agent_timeline_request":
|
||||||
|
return this.handleFetchAgentTimelineRequest(msg);
|
||||||
|
case "agent.fork_context.request":
|
||||||
|
return this.handleAgentForkContextRequest(msg);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "fetch_agents_request":
|
case "fetch_agents_request":
|
||||||
@@ -1484,8 +1497,6 @@ export class Session {
|
|||||||
return this.handleRefreshAgentRequest(msg);
|
return this.handleRefreshAgentRequest(msg);
|
||||||
case "cancel_agent_request":
|
case "cancel_agent_request":
|
||||||
return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
|
return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
|
||||||
case "fetch_agent_timeline_request":
|
|
||||||
return this.handleFetchAgentTimelineRequest(msg);
|
|
||||||
case "agent_permission_response":
|
case "agent_permission_response":
|
||||||
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
|
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
|
||||||
case "clear_agent_attention":
|
case "clear_agent_attention":
|
||||||
@@ -5355,6 +5366,57 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleAgentForkContextRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const snapshot = await ensureAgentLoaded(msg.agentId, {
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
agentStorage: this.agentStorage,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
|
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||||
|
const rows = this.agentManager.fetchTimeline(msg.agentId, {
|
||||||
|
direction: "tail",
|
||||||
|
limit: 0,
|
||||||
|
}).rows;
|
||||||
|
const forkContext = buildAgentForkContextAttachment({
|
||||||
|
rows,
|
||||||
|
boundaryMessageId: msg.boundaryMessageId,
|
||||||
|
agentTitle: agentPayload.title,
|
||||||
|
cwd: snapshot.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "agent.fork_context.response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
agentId: msg.agentId,
|
||||||
|
attachment: forkContext.attachment,
|
||||||
|
itemCount: forkContext.itemCount,
|
||||||
|
boundaryMessageId: forkContext.boundaryMessageId,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.error(
|
||||||
|
{ err: error, agentId: msg.agentId },
|
||||||
|
"Failed to handle agent.fork_context.request",
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "agent.fork_context.response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
agentId: msg.agentId,
|
||||||
|
attachment: null,
|
||||||
|
itemCount: 0,
|
||||||
|
boundaryMessageId: msg.boundaryMessageId ?? null,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleSendAgentMessageRequest(
|
private async handleSendAgentMessageRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1200,6 +1200,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
daemonDiagnostics: true,
|
daemonDiagnostics: true,
|
||||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||||
daemonSelfUpdate: true,
|
daemonSelfUpdate: true,
|
||||||
|
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||||
|
agentForkContext: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user