Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
waitForScrollableChat,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
@@ -18,6 +19,8 @@ import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -54,7 +57,10 @@ test.describe("Agent stream UI", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 30_000 });
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
@@ -100,7 +106,10 @@ test.describe("Agent stream UI", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await awaitAssistantMessage(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 900, timeout: 45_000 });
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 45_000,
|
||||
});
|
||||
const baseline = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
@@ -122,6 +131,7 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await scrollAgentChatToBottom(page);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
|
||||
import { awaitAssistantMessage } from "./helpers/agent-stream";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentOptions,
|
||||
type MockAgentWorkspace,
|
||||
} from "./helpers/mock-agent";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
|
||||
const test = base.extend<{
|
||||
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
|
||||
}>({
|
||||
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
|
||||
const sessions: MockAgentWorkspace[] = [];
|
||||
await provide(async (options) => {
|
||||
const session = await seedMockAgentWorkspace(options);
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||
},
|
||||
});
|
||||
|
||||
async function openAssistantForkMenu(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await scrollAgentChatToBottom(page);
|
||||
return page.getByTestId("assistant-fork-menu-trigger").count();
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectChatHistoryPill(page: Page): Promise<void> {
|
||||
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 30_000 });
|
||||
await expect(pill).toContainText("Chat history");
|
||||
}
|
||||
|
||||
test.describe("Assistant fork menu", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("forks an assistant turn into a new workspace draft tab", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-tab-",
|
||||
title: "Assistant fork tab",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-tab").click();
|
||||
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
|
||||
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: getServerId(),
|
||||
label: "localhost",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: "secondary-assistant-fork-host",
|
||||
label: "Secondary host",
|
||||
// The host does not need to be reachable; this pins that the draft-scoped
|
||||
// attachment survives changing the selected target host.
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-workspace-",
|
||||
title: "Assistant fork workspace",
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
|
||||
await openAssistantForkMenu(page);
|
||||
await page.getByTestId("assistant-fork-menu-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
|
||||
await expectChatHistoryPill(page);
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page
|
||||
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
|
||||
.click();
|
||||
await expectChatHistoryPill(page);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
|
||||
const chatScroll = getVisibleChatScroll(page);
|
||||
await chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
scrollElement.scrollTop = scrollElement.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
chatScroll.evaluate((root: Element) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
return Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
|
||||
@@ -88,11 +88,13 @@ export async function archiveAgentFromDaemon(
|
||||
|
||||
export async function fetchAgentArchivedAt(
|
||||
client: {
|
||||
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
|
||||
},
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
return result?.agent.archivedAt ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
@@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace(
|
||||
}
|
||||
|
||||
export function buildAgentRoute(workspaceId: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
|
||||
@@ -132,9 +132,9 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgent(
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
|
||||
@@ -289,4 +289,67 @@ describe("layoutStream", () => {
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps inline footer on an assistant turn with trailing tool rows before the next user on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"uses the latest assistant for an inline footer when a turn has multiple assistant blocks on %s",
|
||||
(platform) => {
|
||||
const firstAssistant = assistantMessage("a1", 2);
|
||||
const firstTool = toolCall("tool-1", 3);
|
||||
const latestAssistant = assistantMessage("a2", 4);
|
||||
const latestTool = toolCall("tool-2", 5);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [
|
||||
userMessage("u1", 1),
|
||||
firstAssistant,
|
||||
firstTool,
|
||||
latestAssistant,
|
||||
latestTool,
|
||||
userMessage("u2", 6),
|
||||
],
|
||||
timingIds: [firstAssistant.id, latestAssistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
|
||||
expect(findLayoutItem(layout, latestAssistant.id).completedFooter?.itemId).toBe(
|
||||
latestAssistant.id,
|
||||
);
|
||||
expect(footerOwners(layout)).toEqual([latestAssistant.id]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
|
||||
(platform) => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const tool = toolCall("tool-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), assistant, tool],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -68,18 +68,48 @@ function createTurnFooterHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantIndexInTurn(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): number | null {
|
||||
for (
|
||||
let index = input.startIndex;
|
||||
index >= 0 && index < input.items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "above")
|
||||
) {
|
||||
const item = input.items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const startIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (startIndex === null) {
|
||||
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
|
||||
if (latestIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[startIndex];
|
||||
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (assistantIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = footerItems[assistantIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
@@ -87,26 +117,79 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return createTurnFooterHost({
|
||||
item,
|
||||
items: footerItems,
|
||||
index: startIndex,
|
||||
index: assistantIndex,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
});
|
||||
}
|
||||
|
||||
function findTurnEndIndexInSegment(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): number {
|
||||
let endIndex = input.startIndex;
|
||||
for (
|
||||
let index = input.strategy.getNeighborIndex(input.startIndex, "below");
|
||||
index >= 0 && index < input.items.length;
|
||||
index = input.strategy.getNeighborIndex(index, "below")
|
||||
) {
|
||||
const item = input.items[index];
|
||||
if (!item || item.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
endIndex = index;
|
||||
}
|
||||
return endIndex;
|
||||
}
|
||||
|
||||
function shouldRenderCompletedFooter(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
agentStatus: string;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running"))
|
||||
);
|
||||
if (
|
||||
input.item.kind !== "assistant_message" ||
|
||||
input.auxiliaryTurnFooter?.itemId === input.item.id
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === null && input.agentStatus !== "running")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isToolSequenceItem(input.belowItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sameSegmentBelowItem = input.strategy.getNeighborItem(input.items, input.index, "below");
|
||||
if (sameSegmentBelowItem?.id !== input.belowItem.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnEndIndex = findTurnEndIndexInSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
});
|
||||
const assistantIndex = findLatestAssistantIndexInTurn({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: turnEndIndex,
|
||||
});
|
||||
return assistantIndex === input.index;
|
||||
}
|
||||
|
||||
function isToolSequenceItem(item: StreamItem | null): boolean {
|
||||
function isToolSequenceItem(
|
||||
item: StreamItem | null,
|
||||
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
|
||||
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
}
|
||||
|
||||
@@ -175,6 +258,9 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
|
||||
belowItem,
|
||||
});
|
||||
const completedFooter = shouldRenderCompletedFooter({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
agentStatus: input.agentStatus,
|
||||
|
||||
@@ -404,4 +404,211 @@ describe("createWebStreamStrategy", () => {
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "server:agent:initial-entry",
|
||||
};
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted,
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
|
||||
const scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", {
|
||||
configurable: true,
|
||||
value: top,
|
||||
});
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3)],
|
||||
},
|
||||
isAuthoritativeHistoryReady: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
contentRef.current = node;
|
||||
}, []);
|
||||
const [followOutput, setFollowOutputr] = useState(true);
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const setFollowOutput = (value: boolean) => {
|
||||
followOutputRef.current = value;
|
||||
setFollowOutputr(value);
|
||||
return value;
|
||||
};
|
||||
const followOutputRef = useRef(followOutput);
|
||||
const lastKnownScrollTopRef = useRef(0);
|
||||
const pendingUserScrollUpIntentRef = useRef(false);
|
||||
const isPointerScrollActiveRef = useRef(false);
|
||||
@@ -277,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const currentScrollTop = scrollContainer.scrollTop;
|
||||
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
|
||||
if (!followOutputRef.current && isAtBottom) {
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown) {
|
||||
setFollowOutput(true);
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (scrolledUp) {
|
||||
if (scrolledUp || !isAtBottom) {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
items: readonly StreamItem[];
|
||||
startIndex: number;
|
||||
}): string | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
// Forking without the selected assistant's durable message id would send the wrong slice.
|
||||
return item.messageId || undefined;
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
import {
|
||||
AssistantTurnFooter,
|
||||
LiveElapsed,
|
||||
STREAM_METADATA_FONT_SIZE,
|
||||
type AssistantForkTarget,
|
||||
} from "@/components/message";
|
||||
import type { TurnFooterHost } from "./layout";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
});
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
|
||||
items,
|
||||
startIndex,
|
||||
});
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
TurnFooter,
|
||||
type AssistantTurnForkHandler,
|
||||
type TurnContentStrategy,
|
||||
} from "./turn-footer";
|
||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
@@ -76,11 +82,21 @@ import {
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { MountedTabActiveContext } from "@/components/split-container";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
strategy: TurnContentStrategy;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
return null;
|
||||
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
function buildChatHistoryAttachment(input: {
|
||||
draftId: string;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
|
||||
missingAttachmentMessage: string;
|
||||
}): WorkspaceComposerAttachment {
|
||||
if (!input.payload.attachment) {
|
||||
throw new Error(input.missingAttachmentMessage);
|
||||
}
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: `chat_history:${input.draftId}`,
|
||||
attachment: input.payload.attachment,
|
||||
source: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
itemCount: input.payload.itemCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
|
||||
if (!agent.provider) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
for (const feature of agent.features ?? []) {
|
||||
featureValues[feature.id] = feature.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
function buildForkDraftTabTarget(
|
||||
setup: WorkspaceDraftTabSetup | undefined,
|
||||
draftId: string,
|
||||
): WorkspaceTabTarget {
|
||||
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
|
||||
}
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
return;
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
agentId,
|
||||
boundaryMessageId ? { boundaryMessageId } : {},
|
||||
);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
payload,
|
||||
missingAttachmentMessage: t("message.actions.forkFailed"),
|
||||
});
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
|
||||
attachments: [attachment],
|
||||
});
|
||||
return draftId;
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
const draftId = await prepareForkDraft();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: buildForkDraftTabTarget(draftSetup, draftId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
setup: draftSetup,
|
||||
sourceDirectory,
|
||||
});
|
||||
}
|
||||
router.push(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
|
||||
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
||||
}
|
||||
|
||||
function collectAgentProjectPlacementDiffs(
|
||||
left: AgentScreenAgent["projectPlacement"],
|
||||
right: AgentScreenAgent["projectPlacement"],
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
if (left?.projectName !== right?.projectName) {
|
||||
reasons.push("agent.projectPlacement.projectName");
|
||||
}
|
||||
if (left?.projectKey !== right?.projectKey) {
|
||||
reasons.push("agent.projectPlacement.projectKey");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.provider !== right.provider) reasons.push("agent.provider");
|
||||
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
|
||||
if (left.model !== right.model) reasons.push("agent.model");
|
||||
if (left.thinkingOptionId !== right.thinkingOptionId) {
|
||||
reasons.push("agent.thinkingOptionId");
|
||||
}
|
||||
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
|
||||
reasons.push("agent.runtimeInfo.modeId");
|
||||
}
|
||||
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
|
||||
reasons.push("agent.runtimeInfo.model");
|
||||
}
|
||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||
}
|
||||
if (left.features !== right.features) reasons.push("agent.features");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
||||
if (left.id !== right.id) reasons.push("agent.id");
|
||||
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
|
||||
if (left.status !== right.status) reasons.push("agent.status");
|
||||
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
||||
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
||||
reasons.push("agent.capabilities");
|
||||
}
|
||||
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
||||
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
reasons.push(...collectAgentSetupDiffs(left, right));
|
||||
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
|
||||
return reasons;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent(agentId)
|
||||
.fetchAgent({ agentId })
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
|
||||
@@ -8,16 +8,19 @@ export default function NewWorkspaceRoute() {
|
||||
dir?: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
draftId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
const draftId = typeof params.draftId === "string" ? params.draftId : undefined;
|
||||
const screenKey = JSON.stringify([
|
||||
serverId,
|
||||
sourceDirectory ?? null,
|
||||
displayName ?? null,
|
||||
projectId ?? null,
|
||||
draftId ?? null,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -28,6 +31,7 @@ export default function NewWorkspaceRoute() {
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
draftId={draftId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
MousePointer2,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export interface AttachmentPillContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getReviewSubtitle(count: number, t: TFunction): string {
|
||||
return count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count });
|
||||
}
|
||||
|
||||
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
}
|
||||
|
||||
function getTextAttachmentSubtitle(
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (attachment.contextKind === "chat_history") {
|
||||
return "Previous conversation";
|
||||
}
|
||||
return t("message.attachments.text");
|
||||
}
|
||||
|
||||
export function getAgentAttachmentPillContent(
|
||||
attachment: AgentAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
switch (attachment.type) {
|
||||
case "review":
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.comments.length, t),
|
||||
};
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment, t),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceAttachmentPillContent(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TFunction,
|
||||
): AttachmentPillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
icon: attachmentBrowserIcon,
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title,
|
||||
subtitle: getPullRequestContextSubtitle(attachment),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle: getReviewSubtitle(attachment.commentCount, t),
|
||||
};
|
||||
}
|
||||
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
|
||||
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
const attachmentBrowserIcon = (
|
||||
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
);
|
||||
@@ -63,6 +63,18 @@ export type PullRequestContextAttachment =
|
||||
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||
|
||||
export interface ChatHistoryContextAttachment {
|
||||
kind: "chat_history";
|
||||
id: string;
|
||||
attachment: Extract<AgentAttachment, { type: "text" }>;
|
||||
source: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
itemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
@@ -75,6 +87,7 @@ export type WorkspaceComposerAttachment =
|
||||
attachment: BrowserElementAttachment;
|
||||
}
|
||||
| PullRequestContextAttachment
|
||||
| ChatHistoryContextAttachment
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
|
||||
@@ -22,6 +22,7 @@ export function isWorkspaceAttachment(
|
||||
return (
|
||||
attachment?.kind === "review" ||
|
||||
attachment?.kind === "browser_element" ||
|
||||
attachment?.kind === "chat_history" ||
|
||||
isPullRequestContextAttachment(attachment)
|
||||
);
|
||||
}
|
||||
@@ -33,6 +34,7 @@ export function userAttachmentsOnly(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" &&
|
||||
attachment.kind !== "browser_element" &&
|
||||
attachment.kind !== "chat_history" &&
|
||||
!isPullRequestContextAttachment(attachment),
|
||||
);
|
||||
}
|
||||
@@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
text: attachment.text,
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return attachment.attachment;
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
import {
|
||||
appendWorkspaceAttachment,
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
collectWorkspaceAttachmentsForScopes,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "./workspace-attachments-store";
|
||||
@@ -57,6 +59,24 @@ function contextAttachment(id: string): WorkspaceComposerAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id,
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text,
|
||||
},
|
||||
source: {
|
||||
serverId: "local",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachments store", () => {
|
||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||
expect(
|
||||
@@ -76,6 +96,12 @@ describe("workspace attachments store", () => {
|
||||
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
|
||||
});
|
||||
|
||||
it("scopes draft attachments by draft id", () => {
|
||||
expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe(
|
||||
"workspace-attachments:draft=draft-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes and clears attachments for a workspace scope", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
@@ -116,6 +142,13 @@ describe("workspace attachments store", () => {
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("dedupes repeated chat history attachments by id", () => {
|
||||
const original = chatHistoryAttachment("chat_history:draft-1", "Original chat.");
|
||||
const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat.");
|
||||
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("adds a workspace attachment against the current scope state", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
@@ -138,4 +171,25 @@ describe("workspace attachments store", () => {
|
||||
context,
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects attachments across requested scopes in scope order", () => {
|
||||
const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
|
||||
const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const draftContext = chatHistoryAttachment("chat_history:draft-1");
|
||||
const workspaceContext = contextAttachment("comment-1");
|
||||
|
||||
expect(
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope: {
|
||||
[workspaceScopeKey]: [workspaceContext],
|
||||
[draftScopeKey]: [draftContext],
|
||||
},
|
||||
scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey],
|
||||
}),
|
||||
).toEqual([draftContext, workspaceContext]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useMemo } from "react";
|
||||
import { create } from "zustand";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
|
||||
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
|
||||
|
||||
export interface WorkspaceAttachmentScopeInput {
|
||||
kind?: "workspace";
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface DraftWorkspaceAttachmentScopeInput {
|
||||
kind: "draft";
|
||||
draftId: string;
|
||||
}
|
||||
|
||||
export type WorkspaceAttachmentScope =
|
||||
| WorkspaceAttachmentScopeInput
|
||||
| DraftWorkspaceAttachmentScopeInput;
|
||||
|
||||
interface WorkspaceAttachmentsStoreState {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
}
|
||||
@@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
|
||||
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
|
||||
}
|
||||
|
||||
function areWorkspaceAttachmentsEqual(
|
||||
left: readonly WorkspaceComposerAttachment[],
|
||||
right: readonly WorkspaceComposerAttachment[],
|
||||
@@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual(
|
||||
}
|
||||
|
||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||
if (
|
||||
attachment.kind !== "github.pull_request_comment" &&
|
||||
attachment.kind !== "github.pull_request_review" &&
|
||||
attachment.kind !== "github.pull_request_check"
|
||||
) {
|
||||
const isContextAttachment =
|
||||
attachment.kind === "chat_history" ||
|
||||
attachment.kind === "github.pull_request_comment" ||
|
||||
attachment.kind === "github.pull_request_review" ||
|
||||
attachment.kind === "github.pull_request_check";
|
||||
if (!isContextAttachment) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
@@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
||||
},
|
||||
}));
|
||||
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
|
||||
const { serverId, workspaceId, cwd } = input;
|
||||
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string {
|
||||
const isDraftScope = input.kind === "draft";
|
||||
const draftId = isDraftScope ? input.draftId : "";
|
||||
const serverId = isDraftScope ? "" : input.serverId;
|
||||
const workspaceId = isDraftScope ? undefined : input.workspaceId;
|
||||
const cwd = isDraftScope ? "" : input.cwd;
|
||||
return useMemo(() => {
|
||||
if (isDraftScope) {
|
||||
return buildDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
}
|
||||
return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
|
||||
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
|
||||
}
|
||||
|
||||
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
|
||||
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
|
||||
return useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
||||
[serverId, workspaceId, cwd],
|
||||
() => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""),
|
||||
[normalizedDraftId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom
|
||||
);
|
||||
}
|
||||
|
||||
export function collectWorkspaceAttachmentsForScopes(input: {
|
||||
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
|
||||
scopeKeys: readonly string[];
|
||||
}): readonly WorkspaceComposerAttachment[] {
|
||||
const attachments: WorkspaceComposerAttachment[] = [];
|
||||
for (const scopeKey of input.scopeKeys) {
|
||||
const normalizedScopeKey = scopeKey.trim();
|
||||
if (!normalizedScopeKey) {
|
||||
continue;
|
||||
}
|
||||
attachments.push(
|
||||
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
|
||||
);
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
export function useWorkspaceAttachmentsForScopes(
|
||||
scopeKeys: readonly string[] | undefined,
|
||||
): readonly WorkspaceComposerAttachment[] {
|
||||
const normalizedScopeKeys = useMemo(
|
||||
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
|
||||
[scopeKeys],
|
||||
);
|
||||
const attachmentsByScope = useWorkspaceAttachmentsStore(
|
||||
useShallow((state) => state.attachmentsByScope),
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
collectWorkspaceAttachmentsForScopes({
|
||||
attachmentsByScope,
|
||||
scopeKeys: normalizedScopeKeys,
|
||||
}),
|
||||
[attachmentsByScope, normalizedScopeKeys],
|
||||
);
|
||||
}
|
||||
|
||||
export function resetWorkspaceAttachmentsStore(): void {
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
@@ -451,9 +449,6 @@ export interface AdaptiveModalSheetProps {
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
desktopMaxWidth?: number;
|
||||
/** When provided, wraps the card content in a FileDropZone. */
|
||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
}
|
||||
@@ -467,8 +462,6 @@ export function AdaptiveModalSheet({
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
@@ -602,18 +595,7 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone
|
||||
onFilesDropped={onFilesDropped}
|
||||
onGenericFilesDropped={onGenericFilesDropped}
|
||||
>
|
||||
{cardInner}
|
||||
</FileDropZone>
|
||||
) : (
|
||||
cardInner
|
||||
)}
|
||||
</View>
|
||||
<View style={desktopCardStyle}>{cardInner}</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -1,106 +0,0 @@
|
||||
import { View, Text } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: React.ReactNode;
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: DroppedItem[]) => void;
|
||||
disabled?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({
|
||||
children,
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled = false,
|
||||
style,
|
||||
}: FileDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const overlayOpacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
overlayOpacity.value = withTiming(isDragging ? 1 : 0, { duration: 150 });
|
||||
}, [isDragging, overlayOpacity]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: overlayOpacity.value,
|
||||
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
const overlayStyle = useMemo(
|
||||
() => [styles.overlay, overlayAnimatedStyle],
|
||||
[overlayAnimatedStyle],
|
||||
);
|
||||
const containerStyle = useMemo(() => [styles.container, style], [style]);
|
||||
|
||||
// On non-web platforms, just render children
|
||||
if (!IS_WEB) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
// Cast ref for web - View renders as div on web
|
||||
ref={containerRef as unknown as React.RefObject<View>}
|
||||
style={containerStyle}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Drop overlay */}
|
||||
<Animated.View style={overlayStyle}>
|
||||
{/* Backdrop */}
|
||||
<View style={styles.backdrop} />
|
||||
{/* Content */}
|
||||
<View style={styles.overlayContent}>
|
||||
<Upload size={32} color={theme.colors.primary} />
|
||||
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
overlayContent: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
overlayText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import type { FileDropSink } from "./types";
|
||||
|
||||
export interface FileDropContextValue {
|
||||
/** Drag-active flag, driven on the UI thread so toggling it triggers no React render. */
|
||||
isDragging: SharedValue<boolean>;
|
||||
/** Active sink can't accept right now (e.g. composer submitting): hide backdrop and reject drops. */
|
||||
suppressed: SharedValue<boolean>;
|
||||
/** Whether a consumer is currently registered — no consumer (e.g. archived agent), no backdrop. */
|
||||
hasSink: SharedValue<boolean>;
|
||||
/** Register the active sink. Pass a getter so the zone always reads the latest handlers. */
|
||||
registerSink: (getSink: () => FileDropSink | null) => () => void;
|
||||
}
|
||||
|
||||
export const FileDropContext = createContext<FileDropContextValue | null>(null);
|
||||
|
||||
export function useFileDropContext(): FileDropContextValue | null {
|
||||
return useContext(FileDropContext);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { View, Text, StyleSheet as RNStyleSheet } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useFileDropContext } from "./context";
|
||||
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const primaryIconColorMapping = (theme: Theme) => ({ color: theme.colors.primary });
|
||||
|
||||
/**
|
||||
* Drop overlay rendered by FileDropZone. Reads `isDragging` on the UI thread so the dim
|
||||
* only ever repaints the backdrop — never the surrounding tree.
|
||||
*/
|
||||
export function FileDropBackdrop() {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useFileDropContext();
|
||||
const isDragging = ctx?.isDragging;
|
||||
const suppressed = ctx?.suppressed;
|
||||
const hasSink = ctx?.hasSink;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const active = isDragging?.value && hasSink?.value && !suppressed?.value;
|
||||
return { opacity: withTiming(active ? 1 : 0, { duration: 150 }) };
|
||||
});
|
||||
|
||||
const overlayStyle = useMemo(() => [positionStyles.overlay, animatedStyle], [animatedStyle]);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
// Animated.View keeps only plain-RN positioning; theme-dependent paint lives on the
|
||||
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
|
||||
// on theme change — see docs/unistyles.md).
|
||||
return (
|
||||
<Animated.View style={overlayStyle} pointerEvents="none">
|
||||
<View style={styles.backdrop} />
|
||||
<View style={styles.content}>
|
||||
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
|
||||
<Text style={styles.text}>{t("composer.attachments.dropFilesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
overlay: {
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
content: {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { FileDropContext, type FileDropContextValue } from "./context";
|
||||
import { FileDropBackdrop } from "./file-drop-backdrop";
|
||||
import { useDropListeners } from "./use-drop-listeners";
|
||||
import type { FileDropSink } from "./types";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: ReactNode;
|
||||
/** When true, no drops are accepted and the backdrop stays hidden. */
|
||||
disabled?: boolean;
|
||||
/** Styles the drop area (defaults to filling its parent). The backdrop fills this area. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a drag-and-drop area and renders its dim backdrop. Files are consumed by any
|
||||
* descendant calling `useFileDrop` — the drop area, the backdrop, and the consumer are
|
||||
* decoupled, so a consumer's layout can never collapse the backdrop.
|
||||
*/
|
||||
export function FileDropZone({ children, disabled = false, style }: FileDropZoneProps) {
|
||||
const isDragging = useSharedValue(false);
|
||||
const suppressed = useSharedValue(false);
|
||||
const hasSink = useSharedValue(false);
|
||||
const activeGetSink = useRef<(() => FileDropSink | null) | null>(null);
|
||||
|
||||
const registerSink = useCallback(
|
||||
(getSink: () => FileDropSink | null) => {
|
||||
activeGetSink.current = getSink;
|
||||
hasSink.value = true;
|
||||
return () => {
|
||||
if (activeGetSink.current === getSink) {
|
||||
activeGetSink.current = null;
|
||||
hasSink.value = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
[hasSink],
|
||||
);
|
||||
|
||||
const getSink = useCallback(() => activeGetSink.current?.() ?? null, []);
|
||||
|
||||
const ctx = useMemo<FileDropContextValue>(
|
||||
() => ({ isDragging, suppressed, hasSink, registerSink }),
|
||||
[isDragging, suppressed, hasSink, registerSink],
|
||||
);
|
||||
|
||||
const containerRef = useDropListeners({ isDragging, suppressed, hasSink, getSink, disabled });
|
||||
|
||||
const targetStyle = useMemo(() => [styles.target, style], [style]);
|
||||
|
||||
// On native there is no web drag-and-drop, so skip the listeners and the backdrop — but still
|
||||
// render the styled layout View (callers use FileDropZone as their container) and provide
|
||||
// context so useFileDrop no-ops safely.
|
||||
if (!isWeb) {
|
||||
return (
|
||||
<FileDropContext.Provider value={ctx}>
|
||||
<View style={targetStyle}>{children}</View>
|
||||
</FileDropContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FileDropContext.Provider value={ctx}>
|
||||
<View ref={containerRef as unknown as RefObject<View>} style={targetStyle}>
|
||||
{children}
|
||||
<FileDropBackdrop />
|
||||
</View>
|
||||
</FileDropContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// No default flex: the caller's `style` owns sizing (full-area surfaces pass flex:1; a dialog
|
||||
// passes a content-sized style). `position` anchors the absolutely-positioned backdrop.
|
||||
const styles = StyleSheet.create({
|
||||
target: {
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
file: File;
|
||||
}
|
||||
export interface DroppedPathItem {
|
||||
kind: "desktop-path";
|
||||
path: string;
|
||||
}
|
||||
export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
|
||||
/**
|
||||
* What a consumer (e.g. a composer) registers to receive files dropped onto the
|
||||
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
|
||||
* everything else arrives raw via `onGenericFiles`.
|
||||
*/
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
}
|
||||
+87
-98
@@ -1,4 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
|
||||
@@ -8,45 +10,13 @@ import {
|
||||
isRasterImagePath,
|
||||
} from "@/attachments/file-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
file: File;
|
||||
}
|
||||
export interface DroppedPathItem {
|
||||
kind: "desktop-path";
|
||||
path: string;
|
||||
}
|
||||
export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
|
||||
interface UseFileDropZoneOptions {
|
||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||
onGenericFilesDropped?: (items: DroppedItem[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseFileDropZoneReturn {
|
||||
isDragging: boolean;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||
|
||||
type DesktopDragDropPayload =
|
||||
| {
|
||||
type: "enter";
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: "over";
|
||||
}
|
||||
| {
|
||||
type: "drop";
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: "leave";
|
||||
};
|
||||
| { type: "enter"; paths: string[] }
|
||||
| { type: "over" }
|
||||
| { type: "drop"; paths: string[] }
|
||||
| { type: "leave" };
|
||||
|
||||
interface DesktopDragDropEvent {
|
||||
payload: DesktopDragDropPayload;
|
||||
@@ -65,37 +35,43 @@ async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileDropZone({
|
||||
onFilesDropped,
|
||||
onGenericFilesDropped,
|
||||
disabled = false,
|
||||
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
interface UseDropListenersOptions {
|
||||
isDragging: SharedValue<boolean>;
|
||||
/** Active sink can't accept right now: reject drops without showing acceptance. */
|
||||
suppressed: SharedValue<boolean>;
|
||||
/** Whether a consumer is mounted: with none, don't advertise or accept drops. */
|
||||
hasSink: SharedValue<boolean>;
|
||||
/** Stable getter for the currently registered sink. */
|
||||
getSink: () => FileDropSink | null;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches web/desktop drag-and-drop listeners to the returned element ref. Drag state is
|
||||
* written to a shared value (no React renders); dropped files are routed to the active sink.
|
||||
*/
|
||||
export function useDropListeners({
|
||||
isDragging,
|
||||
suppressed,
|
||||
hasSink,
|
||||
getSink,
|
||||
disabled,
|
||||
}: UseDropListenersOptions): RefObject<HTMLElement | null> {
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const dragCounterRef = useRef(0);
|
||||
const onFilesDroppedRef = useRef(onFilesDropped);
|
||||
const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
|
||||
const dragCounter = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
|
||||
// Keep callback refs up to date
|
||||
useEffect(() => {
|
||||
onFilesDroppedRef.current = onFilesDropped;
|
||||
}, [onFilesDropped]);
|
||||
|
||||
useEffect(() => {
|
||||
onGenericFilesDroppedRef.current = onGenericFilesDropped;
|
||||
}, [onGenericFilesDropped]);
|
||||
|
||||
// Reset drag state when disabled changes
|
||||
// Clear an in-progress drag when the zone becomes disabled.
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
isDragging.value = false;
|
||||
dragCounter.current = 0;
|
||||
}
|
||||
}, [disabled]);
|
||||
}, [disabled, isDragging]);
|
||||
|
||||
// Set up event listeners on web
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) return;
|
||||
if (!isWeb) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
@@ -108,13 +84,16 @@ export function useFileDropZone({
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanupFn()).catch((error) => {
|
||||
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
|
||||
console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
|
||||
console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop drag-drop (Tauri-style) is window-scoped, not element-scoped: with multiple zones
|
||||
// mounted, every zone would react to the same drop. Dormant today — current Electron does not
|
||||
// expose onDragDropEvent, so the element-scoped HTML5 DOM path below is what actually runs.
|
||||
async function setupDesktopDragDrop(): Promise<boolean> {
|
||||
const desktopHost = getDesktopHost();
|
||||
if (desktopHost === null) {
|
||||
@@ -130,29 +109,32 @@ export function useFileDropZone({
|
||||
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
|
||||
const payload = event.payload;
|
||||
if (payload.type === "leave") {
|
||||
setIsDragging(false);
|
||||
isDragging.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
if (!disabledRef.current) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop always ends the current drag operation.
|
||||
setIsDragging(false);
|
||||
isDragging.value = false;
|
||||
|
||||
if (disabled) return;
|
||||
if (disabledRef.current || suppressed.value) return;
|
||||
|
||||
const sink = getSink();
|
||||
if (!sink) return;
|
||||
|
||||
const items: DroppedPathItem[] = payload.paths.map((path) => ({
|
||||
kind: "desktop-path",
|
||||
path,
|
||||
}));
|
||||
|
||||
if (onGenericFilesDroppedRef.current && items.length > 0) {
|
||||
onGenericFilesDroppedRef.current(items);
|
||||
if (sink.onGenericFiles && items.length > 0) {
|
||||
sink.onGenericFiles(items);
|
||||
}
|
||||
|
||||
const imagePaths = payload.paths.filter(isRasterImagePath);
|
||||
@@ -165,11 +147,15 @@ export function useFileDropZone({
|
||||
if (attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
onFilesDroppedRef.current(attachments);
|
||||
// Use the sink captured at drop time, not a fresh getSink() — routing belongs to the
|
||||
// composer the user dropped on (matches the web path below). No post-persist busy
|
||||
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
|
||||
// would discard the image from the same drop.
|
||||
sink.onFiles(attachments);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[useFileDropZone] Failed to persist dropped files:", error);
|
||||
console.error("[useDropListeners] Failed to persist dropped files:", error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +167,7 @@ export function useFileDropZone({
|
||||
cleanup = unlisten;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
|
||||
console.warn("[useDropListeners] Failed to listen for desktop drag-drop:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -196,11 +182,11 @@ export function useFileDropZone({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
if (disabledRef.current) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
dragCounter.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
setIsDragging(true);
|
||||
isDragging.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,22 +194,22 @@ export function useFileDropZone({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
if (!e.dataTransfer) return;
|
||||
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
||||
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
||||
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (disabled) return;
|
||||
if (disabledRef.current) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDragging(false);
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
isDragging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,10 +217,13 @@ export function useFileDropZone({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(false);
|
||||
dragCounterRef.current = 0;
|
||||
isDragging.value = false;
|
||||
dragCounter.current = 0;
|
||||
|
||||
if (disabled) return;
|
||||
if (disabledRef.current || suppressed.value) return;
|
||||
|
||||
const sink = getSink();
|
||||
if (!sink) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||
@@ -242,8 +231,8 @@ export function useFileDropZone({
|
||||
file,
|
||||
}));
|
||||
|
||||
if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
|
||||
onGenericFilesDroppedRef.current(genericItems);
|
||||
if (sink.onGenericFiles && genericItems.length > 0) {
|
||||
sink.onGenericFiles(genericItems);
|
||||
}
|
||||
|
||||
const imageFiles = files.filter(isRasterImageFile);
|
||||
@@ -252,9 +241,12 @@ export function useFileDropZone({
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
|
||||
onFilesDroppedRef.current(attachments);
|
||||
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
|
||||
// and re-checking would discard the image from the same drop. The guard at drop start
|
||||
// already rejects drops that begin while busy.
|
||||
sink.onFiles(attachments);
|
||||
} catch (error) {
|
||||
console.error("[useFileDropZone] Failed to process dropped files:", error);
|
||||
console.error("[useDropListeners] Failed to process dropped files:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,10 +275,7 @@ export function useFileDropZone({
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, [disabled]);
|
||||
}, [isDragging, suppressed, hasSink, getSink]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
containerRef,
|
||||
};
|
||||
return containerRef;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFileDropContext } from "./context";
|
||||
import type { FileDropSink } from "./types";
|
||||
|
||||
interface UseFileDropOptions {
|
||||
/** When true, the zone hides the backdrop and rejects drops atomically (e.g. while submitting). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive files dropped onto the surrounding FileDropZone. The sink is read through
|
||||
* a ref, so passing a fresh object every render neither re-registers nor re-renders.
|
||||
* No-ops when rendered without a FileDropZone ancestor.
|
||||
*/
|
||||
export function useFileDrop(sink: FileDropSink, options?: UseFileDropOptions): void {
|
||||
const ctx = useFileDropContext();
|
||||
const sinkRef = useRef(sink);
|
||||
sinkRef.current = sink;
|
||||
const disabled = options?.disabled ?? false;
|
||||
|
||||
const registerSink = ctx?.registerSink;
|
||||
useEffect(() => {
|
||||
if (!registerSink) return;
|
||||
return registerSink(() => sinkRef.current);
|
||||
}, [registerSink]);
|
||||
|
||||
const suppressed = ctx?.suppressed;
|
||||
useEffect(() => {
|
||||
if (!suppressed) return;
|
||||
suppressed.value = disabled;
|
||||
return () => {
|
||||
suppressed.value = false;
|
||||
};
|
||||
}, [suppressed, disabled]);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
CircleDot,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@@ -42,15 +41,13 @@ import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Copy,
|
||||
GitPullRequest,
|
||||
MessageSquareCode,
|
||||
TriangleAlertIcon,
|
||||
Scissors,
|
||||
MicVocal,
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -89,6 +86,7 @@ import {
|
||||
getFileNameFromPath,
|
||||
parseImageDataUrl,
|
||||
} from "@/attachments/utils";
|
||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
@@ -104,7 +102,6 @@ import {
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import {
|
||||
AttachmentFrame,
|
||||
AttachmentLabel,
|
||||
@@ -116,7 +113,9 @@ import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
export type { AssistantForkTarget };
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
@@ -170,10 +169,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||
const ThemedAttachmentFileText = withUnistyles(FileText);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -402,19 +397,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const attachmentReviewIcon = (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubPrIcon = (
|
||||
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentGithubIssueIcon = (
|
||||
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const attachmentFileIcon = (
|
||||
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
interface UserMessageImagePillProps {
|
||||
image: UserMessageImageAttachment;
|
||||
onOpen: (image: UserMessageImageAttachment) => void;
|
||||
@@ -432,55 +414,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
|
||||
);
|
||||
}
|
||||
|
||||
interface UserMessageAttachmentContent {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getUserMessageAttachmentContent(
|
||||
attachment: AgentAttachment,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): UserMessageAttachmentContent {
|
||||
switch (attachment.type) {
|
||||
case "review": {
|
||||
const count = attachment.comments.length;
|
||||
return {
|
||||
icon: attachmentReviewIcon,
|
||||
title: t("message.attachments.review"),
|
||||
subtitle:
|
||||
count === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count }),
|
||||
};
|
||||
}
|
||||
case "github_pr":
|
||||
return {
|
||||
icon: attachmentGithubPrIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `PR #${attachment.number}`,
|
||||
};
|
||||
case "github_issue":
|
||||
return {
|
||||
icon: attachmentGithubIssueIcon,
|
||||
title: attachment.title,
|
||||
subtitle: `Issue #${attachment.number}`,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.title ?? t("message.attachments.textAttachment"),
|
||||
subtitle: t("message.attachments.text"),
|
||||
};
|
||||
case "uploaded_file":
|
||||
return {
|
||||
icon: attachmentFileIcon,
|
||||
title: attachment.fileName,
|
||||
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasAttachments ? (
|
||||
<View style={attachmentPreviewContainerStyle}>
|
||||
{attachments.map((attachment, index) => {
|
||||
const content = getUserMessageAttachmentContent(attachment, t);
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
return (
|
||||
<AttachmentFrame
|
||||
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
|
||||
@@ -628,6 +561,11 @@ interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
forkBoundaryMessageId?: string;
|
||||
onFork?: (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -672,6 +610,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
completedAt,
|
||||
durationMs,
|
||||
forkBoundaryMessageId,
|
||||
onFork,
|
||||
}: AssistantTurnFooterProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [pressedReveal, setPressedReveal] = useState(false);
|
||||
@@ -711,6 +651,13 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
revealTimerRef.current = null;
|
||||
}, TIMESTAMP_REVEAL_MS);
|
||||
}, [canSwap]);
|
||||
const handleFork = useCallback(
|
||||
(target: AssistantForkTarget) => {
|
||||
return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId });
|
||||
},
|
||||
[forkBoundaryMessageId, onFork],
|
||||
);
|
||||
const canFork = Boolean(onFork && forkBoundaryMessageId);
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
@@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{durationLabel ? (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { Composer } from "@/composer";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -420,7 +421,7 @@ export function WorkspaceSetupDialog() {
|
||||
testID="workspace-setup-dialog"
|
||||
desktopMaxWidth={640}
|
||||
>
|
||||
<View style={styles.section}>
|
||||
<FileDropZone style={styles.section}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
@@ -440,7 +441,7 @@ export function WorkspaceSetupDialog() {
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
footer={composerFooter}
|
||||
/>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isRasterImagePath,
|
||||
} from "@/attachments/file-types";
|
||||
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
|
||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
|
||||
interface DroppedAttachmentsRuntime {
|
||||
readDesktopFileBytes(path: string): Promise<Uint8Array>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import { removeSentContextAttachments } from "./workspace-cleanup";
|
||||
|
||||
function chatHistoryAttachment(): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "chat_history",
|
||||
id: "chat_history:draft-1",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Previous chat.",
|
||||
},
|
||||
source: {
|
||||
serverId: "local",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pullRequestContextAttachment(): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: "comment-1",
|
||||
title: "Comment",
|
||||
text: "Please check this.",
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementAttachment(): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Click me",
|
||||
outerHTML: '<button class="primary">Click me</button>',
|
||||
computedStyles: {},
|
||||
boundingRect: { x: 0, y: 0, width: 100, height: 40 },
|
||||
reactSource: null,
|
||||
parentChain: [],
|
||||
children: [],
|
||||
formatted: "button.primary\nClick me",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace composer attachment cleanup", () => {
|
||||
it("clears sent scoped context attachments from their stores", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
|
||||
const chatHistory = chatHistoryAttachment();
|
||||
const pullRequestContext = pullRequestContextAttachment();
|
||||
const browserElement = browserElementAttachment();
|
||||
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
|
||||
scopeKey,
|
||||
attachments: [chatHistory, pullRequestContext, browserElement],
|
||||
});
|
||||
|
||||
removeSentContextAttachments([chatHistory, pullRequestContext, browserElement]);
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
isPullRequestContextAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
getAttachmentKey,
|
||||
removeSentContextAttachments,
|
||||
removeWorkspaceAttachmentsMatching,
|
||||
} from "./workspace-cleanup";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>["t"];
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
normalAttachments: UserComposerAttachment[];
|
||||
workspaceAttachments: readonly WorkspaceComposerAttachment[];
|
||||
@@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding {
|
||||
resetSuppression: () => void;
|
||||
}
|
||||
|
||||
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return JSON.stringify({
|
||||
type: "browser_element",
|
||||
url: attachment.attachment.url,
|
||||
selector: attachment.attachment.selector,
|
||||
tag: attachment.attachment.tag,
|
||||
text: attachment.attachment.text,
|
||||
html: attachment.attachment.outerHTML,
|
||||
});
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return JSON.stringify({
|
||||
kind: attachment.kind,
|
||||
id: attachment.id,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
type: "review",
|
||||
cwd: attachment.attachment.cwd,
|
||||
mode: attachment.attachment.mode,
|
||||
baseRef: attachment.attachment.baseRef ?? null,
|
||||
reviewDraftKey: attachment.reviewDraftKey,
|
||||
comments: attachment.attachment.comments.map((comment) => ({
|
||||
filePath: comment.filePath,
|
||||
side: comment.side,
|
||||
lineNumber: comment.lineNumber,
|
||||
body: comment.body,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
|
||||
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
|
||||
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
|
||||
const nextAttachments = attachments.filter(
|
||||
(attachment) => getAttachmentKey(attachment) !== selectedKey,
|
||||
);
|
||||
if (nextAttachments.length !== attachments.length) {
|
||||
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
|
||||
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
|
||||
for (const key of sentContextKeys) {
|
||||
removeWorkspaceAttachmentsMatching(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
}
|
||||
|
||||
interface PillContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
title: attachment.attachment.tag,
|
||||
subtitle: t("composer.attachments.element"),
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
title: attachment.title,
|
||||
subtitle: getContextSourceLabel(attachment),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t("message.attachments.review"),
|
||||
subtitle:
|
||||
attachment.commentCount === 1
|
||||
? t("message.attachments.commentsOne")
|
||||
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenAccessibilityLabel(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TranslationFn,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return t("composer.attachments.openBrowserElement");
|
||||
@@ -148,12 +60,15 @@ function getOpenAccessibilityLabel(
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return "Open context attachment";
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return "Open chat history attachment";
|
||||
}
|
||||
return t("composer.attachments.openReview");
|
||||
}
|
||||
|
||||
function getRemoveAccessibilityLabel(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TranslationFn,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return t("composer.attachments.removeBrowserElement");
|
||||
@@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel(
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return "Remove context attachment";
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return "Remove chat history attachment";
|
||||
}
|
||||
return t("composer.attachments.removeReview");
|
||||
}
|
||||
|
||||
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
function getPillTestID(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "chat_history") {
|
||||
return "composer-chat-history-attachment-pill";
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
}
|
||||
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
return "composer-review-attachment-pill";
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
@@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({
|
||||
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
||||
const selected = current[index];
|
||||
if (isWorkspaceAttachment(selected)) {
|
||||
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
|
||||
if (
|
||||
selected.kind === "browser_element" ||
|
||||
selected.kind === "chat_history" ||
|
||||
isPullRequestContextAttachment(selected)
|
||||
) {
|
||||
const selectedKey = getAttachmentKey(selected);
|
||||
removeWorkspaceAttachmentsMatching(selectedKey);
|
||||
return true;
|
||||
@@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const content = getPillContent(attachment, t);
|
||||
const content = getWorkspaceAttachmentPillContent(attachment, t);
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
@@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({
|
||||
}, [onRemove, index]);
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID="composer-review-attachment-pill"
|
||||
testID={getPillTestID(attachment)}
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
|
||||
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={renderPillIcon(attachment)}
|
||||
title={content.title}
|
||||
subtitle={content.subtitle}
|
||||
/>
|
||||
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
@@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = {
|
||||
userAttachmentsOnly,
|
||||
useBinding: useWorkspaceAttachmentBinding,
|
||||
};
|
||||
|
||||
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
});
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
||||
const attachment = {
|
||||
kind: "chat_history",
|
||||
id: "chat-history-1",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Previous conversation",
|
||||
},
|
||||
source: {
|
||||
serverId: "server-1",
|
||||
agentId: "agent-source",
|
||||
},
|
||||
} as const;
|
||||
const createRequest = vi.fn(async () => ({
|
||||
agentId: "agent-1",
|
||||
result: { id: "agent-1" },
|
||||
}));
|
||||
const onCreateSuccess = vi.fn();
|
||||
const validateBeforeSubmit = vi.fn(() => null);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftAgentCreateFlow({
|
||||
draftId: "draft-1",
|
||||
getPendingServerId: () => "server-1",
|
||||
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
|
||||
createRequest,
|
||||
onCreateSuccess,
|
||||
validateBeforeSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateFromInput({
|
||||
text: " ",
|
||||
attachments: [attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
|
||||
expect(validateBeforeSubmit).toHaveBeenCalledWith({
|
||||
text: "",
|
||||
attachments: [attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
expect(createRequest).toHaveBeenCalledWith({
|
||||
attempt: expect.objectContaining({
|
||||
text: "",
|
||||
attachments: [attachment.attachment],
|
||||
}),
|
||||
text: "",
|
||||
attachments: [attachment.attachment],
|
||||
cwd: "/repo",
|
||||
});
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const images = wirePayload.images;
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
if (!trimmedPrompt && !allowEmptyText) {
|
||||
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
|
||||
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
|
||||
const error = new Error(t("composer.errors.initialPromptRequired"));
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export function shouldAllowEmptyDraftText(input: {
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
attachments: readonly unknown[];
|
||||
}): boolean {
|
||||
return input.allowsEmptyAutoSubmit || input.attachments.length > 0;
|
||||
}
|
||||
|
||||
export function validateDraftSubmission(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { validateDraftSubmission } from "./workspace-tab-core";
|
||||
import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core";
|
||||
|
||||
const baseComposerState = {
|
||||
providerDefinitions: [{ id: "codewhale" }],
|
||||
@@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => {
|
||||
).toBe("No model is available for the selected provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace draft empty text readiness", () => {
|
||||
test("allows attachment-only retries after a fork draft create fails", () => {
|
||||
expect(
|
||||
shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit: false,
|
||||
attachments: [{ kind: "chat_history" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("still rejects empty drafts with no auto-submit and no attachments", () => {
|
||||
expect(
|
||||
shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit: false,
|
||||
attachments: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { ComposerImportPill } from "@/composer/draft/import-pill";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
@@ -26,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core";
|
||||
import {
|
||||
shouldAllowEmptyDraftText,
|
||||
validateDraftSubmission,
|
||||
} from "@/composer/draft/workspace-tab-core";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
useWorkspaceAttachments,
|
||||
useDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import {
|
||||
@@ -117,6 +122,7 @@ async function submitDraftCreateRequest(input: {
|
||||
text: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: unknown;
|
||||
cwd: string;
|
||||
client: DaemonClient | null;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceId: string | null;
|
||||
@@ -137,6 +143,7 @@ async function submitDraftCreateRequest(input: {
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
workspaceId,
|
||||
@@ -161,7 +168,7 @@ async function submitDraftCreateRequest(input: {
|
||||
});
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
cwd: workspaceDirectory,
|
||||
cwd,
|
||||
...modeIdOverride,
|
||||
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||
thinkingOptionId:
|
||||
@@ -399,7 +406,14 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd: composerState.workingDir,
|
||||
workspaceId,
|
||||
});
|
||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||
const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
const attachmentScopeKeys = useMemo(
|
||||
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
|
||||
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
|
||||
);
|
||||
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.clearWorkspaceAttachments,
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
@@ -436,15 +450,20 @@ export function WorkspaceDraftAgentTab({
|
||||
getPendingServerId: () => serverId,
|
||||
initialAttempt: initialCreateAttempt,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text }) =>
|
||||
validateDraftSubmission({
|
||||
text,
|
||||
validateBeforeSubmit: ({ text, attachments }) => {
|
||||
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
|
||||
allowsEmptyAutoSubmit,
|
||||
attachments,
|
||||
});
|
||||
return validateDraftSubmission({
|
||||
text,
|
||||
allowsEmptyAutoSubmit: allowsEmptyDraftText,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
hasClient: Boolean(client),
|
||||
}),
|
||||
});
|
||||
},
|
||||
onBeforeSubmit: async () => {
|
||||
await composerState.persistFormPreferences();
|
||||
if (isWeb) {
|
||||
@@ -462,12 +481,13 @@ export function WorkspaceDraftAgentTab({
|
||||
composerState,
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments }) =>
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
|
||||
submitDraftCreateRequest({
|
||||
attempt,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
cwd,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
@@ -478,6 +498,8 @@ export function WorkspaceDraftAgentTab({
|
||||
}),
|
||||
onCreateSuccess: ({ result }) => {
|
||||
clearDraftInput("sent");
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
},
|
||||
});
|
||||
@@ -644,7 +666,7 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
@@ -689,7 +711,7 @@ export function WorkspaceDraftAgentTab({
|
||||
value={draftInput.text}
|
||||
onChangeText={draftInput.setText}
|
||||
attachments={draftInput.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
@@ -702,7 +724,7 @@ export function WorkspaceDraftAgentTab({
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -103,6 +103,7 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
|
||||
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
|
||||
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
|
||||
import { getFileTypeLabel } from "@/attachments/file-types";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
@@ -121,6 +122,8 @@ type AttachmentListUpdater =
|
||||
| UserComposerAttachment[]
|
||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||
|
||||
const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
|
||||
|
||||
function noop() {}
|
||||
const noopCallback = () => {};
|
||||
|
||||
@@ -759,7 +762,7 @@ interface ComposerProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
attachments: UserComposerAttachment[];
|
||||
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
|
||||
attachmentScopeKeys?: readonly string[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||
cwd: string;
|
||||
@@ -971,7 +974,7 @@ export function Composer({
|
||||
value,
|
||||
onChangeText,
|
||||
attachments,
|
||||
workspaceAttachments = [],
|
||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||
onOpenWorkspaceAttachment,
|
||||
onChangeAttachments,
|
||||
cwd,
|
||||
@@ -1026,6 +1029,7 @@ export function Composer({
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
|
||||
const {
|
||||
selectedAttachments,
|
||||
buildOutgoingAttachments,
|
||||
@@ -1881,6 +1885,15 @@ export function Composer({
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
||||
|
||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
||||
useFileDrop(
|
||||
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
|
||||
{ disabled: isSubmitBusy },
|
||||
);
|
||||
|
||||
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
|
||||
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
|
||||
const sendErrorNode = useMemo(
|
||||
@@ -1894,97 +1907,90 @@ export function Composer({
|
||||
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<FileDropZone
|
||||
onFilesDropped={addImages}
|
||||
onGenericFilesDropped={handleGenericFilesDropped}
|
||||
disabled={isSubmitBusy}
|
||||
style={styles.dropZone}
|
||||
>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</FileDropZone>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1994,10 +2000,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
},
|
||||
dropZone: {
|
||||
flex: 0,
|
||||
width: "100%",
|
||||
},
|
||||
borderSeparator: {
|
||||
height: theme.borderWidth[1],
|
||||
backgroundColor: theme.colors.border,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization";
|
||||
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
@@ -1210,6 +1211,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
error: payload.error,
|
||||
});
|
||||
if (followUp?.direction === "after") {
|
||||
refreshAgentInitializationTimeout({
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing: (id, initializing) => {
|
||||
if (initializing) {
|
||||
return;
|
||||
}
|
||||
clearAgentInitializingFlag(setInitializingAgents, serverId, id);
|
||||
},
|
||||
});
|
||||
requestCanonicalCatchUp(agentId, {
|
||||
epoch: followUp.cursor.epoch,
|
||||
endSeq: followUp.cursor.seq,
|
||||
|
||||
@@ -2,6 +2,14 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
export type DesktopDaemonStopReason =
|
||||
| "manual_ipc"
|
||||
| "settings"
|
||||
| "host_remove"
|
||||
| "quit"
|
||||
| "app_update"
|
||||
| "version_mismatch"
|
||||
| "restart";
|
||||
|
||||
export interface DesktopDaemonStatus {
|
||||
serverId: string;
|
||||
@@ -122,8 +130,10 @@ export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
|
||||
}
|
||||
|
||||
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon"));
|
||||
export async function stopDesktopDaemon(
|
||||
reason: DesktopDaemonStopReason = "manual_ipc",
|
||||
): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason }));
|
||||
}
|
||||
|
||||
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useBuiltInDaemonManagement(
|
||||
}),
|
||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||
startDaemon: startDesktopDaemon,
|
||||
stopDaemon: stopDesktopDaemon,
|
||||
stopDaemon: () => stopDesktopDaemon("settings"),
|
||||
});
|
||||
if (result.kind === "enabled") {
|
||||
const upsertResult = await upsertDesktopDaemonConnection(
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
type AgentCommandsClient,
|
||||
type DraftCommandConfig,
|
||||
fetchAgentCommands,
|
||||
} from "./use-agent-commands-query";
|
||||
|
||||
type ListCommands = DaemonClient["listCommands"];
|
||||
type ListCommands = AgentCommandsClient["listCommands"];
|
||||
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
||||
|
||||
interface ListCommandsCall {
|
||||
@@ -22,15 +21,8 @@ function createClient(response: ListCommandsResult): FakeAgentCommandsClient {
|
||||
const calls: ListCommandsCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
listCommands: (async (
|
||||
agentId: string,
|
||||
requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig },
|
||||
) => {
|
||||
const options =
|
||||
typeof requestIdOrOptions === "object" && requestIdOrOptions !== null
|
||||
? requestIdOrOptions
|
||||
: undefined;
|
||||
calls.push({ agentId, draftConfig: options?.draftConfig });
|
||||
listCommands: (async (options: Parameters<ListCommands>[0]) => {
|
||||
calls.push({ agentId: options.agentId, draftConfig: options.draftConfig });
|
||||
return response;
|
||||
}) as ListCommands,
|
||||
};
|
||||
|
||||
@@ -16,14 +16,22 @@ export interface AgentSlashCommand {
|
||||
|
||||
export type DraftCommandConfig = AgentCommandsDraftConfig;
|
||||
|
||||
export type AgentCommandsClient = Pick<DaemonClient, "listCommands">;
|
||||
interface ListAgentCommandsOptions {
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
}
|
||||
|
||||
export interface AgentCommandsClient {
|
||||
listCommands(options: ListAgentCommandsOptions): ReturnType<DaemonClient["listCommands"]>;
|
||||
}
|
||||
|
||||
export async function fetchAgentCommands(input: {
|
||||
client: AgentCommandsClient;
|
||||
agentId: string;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
}): Promise<AgentSlashCommand[]> {
|
||||
const response = await input.client.listCommands(input.agentId, {
|
||||
const response = await input.client.listCommands({
|
||||
agentId: input.agentId,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
return response.commands as AgentSlashCommand[];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-
|
||||
import {
|
||||
createSetAgentInitializing,
|
||||
ensureAgentIsInitialized,
|
||||
refreshAgentInitializationTimeout,
|
||||
refreshAgent,
|
||||
} from "./use-agent-initialization";
|
||||
|
||||
@@ -80,7 +81,7 @@ describe("ensureAgentIsInitialized", () => {
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("times out initialization after 30 seconds", async () => {
|
||||
it("times out initialization after 65 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
@@ -92,18 +93,50 @@ describe("ensureAgentIsInitialized", () => {
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(29_999);
|
||||
vi.advanceTimersByTime(64_999);
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
await expect(promise).rejects.toThrow("History sync timed out after 30s");
|
||||
await expect(promise).rejects.toThrow("History sync timed out after 65s");
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
|
||||
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe(
|
||||
false,
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("refreshes the initialization timeout after paged catch-up progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
const setAgentInitializing = bindSetAgentInitializing();
|
||||
const key = getInitKey(serverId, agentId);
|
||||
|
||||
const promise = ensureAgentIsInitialized({
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
setAgentInitializing,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(64_999);
|
||||
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(getInitDeferred(key)).toBeDefined();
|
||||
|
||||
const rejection = expect(promise).rejects.toThrow("History sync timed out after 65s");
|
||||
|
||||
vi.advanceTimersByTime(64_998);
|
||||
expect(getInitDeferred(key)).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
await rejection;
|
||||
expect(getInitDeferred(key)).toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshAgent", () => {
|
||||
|
||||
@@ -3,19 +3,36 @@ import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
attachInitTimeout,
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
INIT_TIMEOUT_MS,
|
||||
rejectInitDeferred,
|
||||
refreshInitTimeout,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
|
||||
|
||||
export function createHistorySyncTimeoutError(): Error {
|
||||
return new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`);
|
||||
}
|
||||
|
||||
export function refreshAgentInitializationTimeout(input: {
|
||||
key: string;
|
||||
agentId: string;
|
||||
setAgentInitializing: SetAgentInitializing;
|
||||
}): void {
|
||||
refreshInitTimeout({
|
||||
key: input.key,
|
||||
onTimeout: () => {
|
||||
input.setAgentInitializing(input.agentId, false);
|
||||
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface EnsureAgentIsInitializedInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
@@ -38,14 +55,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
|
||||
|
||||
const deferred = createInitDeferred(key, timelineRequest.direction);
|
||||
const timeoutId = setTimeout(() => {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
key,
|
||||
new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`),
|
||||
);
|
||||
}, INIT_TIMEOUT_MS);
|
||||
attachInitTimeout(key, timeoutId);
|
||||
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
|
||||
|
||||
setAgentInitializing(agentId, true);
|
||||
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { useRef } from "react";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface AgentScreenAgent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
provider?: AgentProvider;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
currentModeId?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
runtimeInfo?: {
|
||||
model?: string | null;
|
||||
modeId?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
} | null;
|
||||
features?: readonly AgentFeature[];
|
||||
lastError?: string | null;
|
||||
projectPlacement?: {
|
||||
projectKey?: string;
|
||||
projectName?: string;
|
||||
checkout?: {
|
||||
cwd?: string;
|
||||
isGit?: boolean;
|
||||
|
||||
@@ -245,6 +245,12 @@ export const ar: TranslationResources = {
|
||||
copyCode: "نسخ الرمز",
|
||||
copyTurn: "نسخ بدوره",
|
||||
copyMessage: "انسخ الرسالة",
|
||||
forkMenu: "تفريع الرسالة",
|
||||
forkInNewTab: "تفريع في تبويب جديد",
|
||||
forkInNewWorkspace: "تفريع في مساحة عمل جديدة",
|
||||
forkUnavailable: "حدّث المضيف لاستخدام هذا.",
|
||||
forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.",
|
||||
forkFailed: "فشل تفريع المحادثة",
|
||||
openFile: "افتح الملف",
|
||||
copied: "منقول",
|
||||
},
|
||||
|
||||
@@ -244,6 +244,12 @@ export const en = {
|
||||
copyCode: "Copy code",
|
||||
copyTurn: "Copy turn",
|
||||
copyMessage: "Copy message",
|
||||
forkMenu: "Fork message",
|
||||
forkInNewTab: "Fork in a new tab",
|
||||
forkInNewWorkspace: "Fork in a new workspace",
|
||||
forkUnavailable: "Update the host to use this.",
|
||||
forkMissingWorkspace: "This agent is not in a workspace.",
|
||||
forkFailed: "Failed to fork chat",
|
||||
openFile: "Open file",
|
||||
copied: "Copied",
|
||||
},
|
||||
|
||||
@@ -248,6 +248,12 @@ export const es: TranslationResources = {
|
||||
copyCode: "Copiar código",
|
||||
copyTurn: "Copiar turno",
|
||||
copyMessage: "Copiar mensaje",
|
||||
forkMenu: "Bifurcar mensaje",
|
||||
forkInNewTab: "Bifurcar en una pestaña nueva",
|
||||
forkInNewWorkspace: "Bifurcar en un espacio de trabajo nuevo",
|
||||
forkUnavailable: "Actualiza el host para usar esto.",
|
||||
forkMissingWorkspace: "Este agente no está en un espacio de trabajo.",
|
||||
forkFailed: "No se pudo bifurcar el chat",
|
||||
openFile: "Abrir archivo",
|
||||
copied: "Copiado",
|
||||
},
|
||||
|
||||
@@ -248,6 +248,12 @@ export const fr: TranslationResources = {
|
||||
copyCode: "Copier le code",
|
||||
copyTurn: "Copier le tour",
|
||||
copyMessage: "Copier le message",
|
||||
forkMenu: "Dupliquer le message",
|
||||
forkInNewTab: "Dupliquer dans un nouvel onglet",
|
||||
forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail",
|
||||
forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.",
|
||||
forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.",
|
||||
forkFailed: "Impossible de dupliquer le chat",
|
||||
openFile: "Ouvrir le fichier",
|
||||
copied: "Copié",
|
||||
},
|
||||
|
||||
@@ -248,6 +248,12 @@ export const ja: TranslationResources = {
|
||||
copyCode: "コードをコピー",
|
||||
copyTurn: "ターンをコピー",
|
||||
copyMessage: "メッセージをコピー",
|
||||
forkMenu: "メッセージをフォーク",
|
||||
forkInNewTab: "新しいタブにフォーク",
|
||||
forkInNewWorkspace: "新しいワークスペースにフォーク",
|
||||
forkUnavailable: "これを使用するにはホストを更新してください。",
|
||||
forkMissingWorkspace: "このエージェントはワークスペース内にありません。",
|
||||
forkFailed: "チャットのフォークに失敗しました",
|
||||
openFile: "ファイルを開く",
|
||||
copied: "コピーしました",
|
||||
},
|
||||
|
||||
@@ -248,6 +248,12 @@ export const ptBR: TranslationResources = {
|
||||
copyCode: "Copiar código",
|
||||
copyTurn: "Copiar turno",
|
||||
copyMessage: "Copiar mensagem",
|
||||
forkMenu: "Bifurcar mensagem",
|
||||
forkInNewTab: "Bifurcar em uma nova aba",
|
||||
forkInNewWorkspace: "Bifurcar em um novo workspace",
|
||||
forkUnavailable: "Atualize o host para usar isto.",
|
||||
forkMissingWorkspace: "Este agente não está em um workspace.",
|
||||
forkFailed: "Falha ao bifurcar o chat",
|
||||
openFile: "Abrir arquivo",
|
||||
copied: "Copiado",
|
||||
},
|
||||
|
||||
@@ -247,6 +247,12 @@ export const ru: TranslationResources = {
|
||||
copyCode: "Скопировать код",
|
||||
copyTurn: "Копировать ход",
|
||||
copyMessage: "Копировать сообщение",
|
||||
forkMenu: "Форкнуть сообщение",
|
||||
forkInNewTab: "Форкнуть в новой вкладке",
|
||||
forkInNewWorkspace: "Форкнуть в новом рабочем пространстве",
|
||||
forkUnavailable: "Обновите хост, чтобы использовать это.",
|
||||
forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.",
|
||||
forkFailed: "Не удалось форкнуть чат",
|
||||
openFile: "Открыть файл",
|
||||
copied: "Скопировано",
|
||||
},
|
||||
|
||||
@@ -245,6 +245,12 @@ export const zhCN: TranslationResources = {
|
||||
copyCode: "复制代码",
|
||||
copyTurn: "复制回合",
|
||||
copyMessage: "复制消息",
|
||||
forkMenu: "分叉消息",
|
||||
forkInNewTab: "分叉到新标签页",
|
||||
forkInNewWorkspace: "分叉到新工作区",
|
||||
forkUnavailable: "请更新主机以使用此功能。",
|
||||
forkMissingWorkspace: "此 Agent 不在工作区中。",
|
||||
forkFailed: "分叉聊天失败",
|
||||
openFile: "打开文件",
|
||||
copied: "已复制",
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { Composer } from "@/composer";
|
||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
@@ -23,10 +24,7 @@ import {
|
||||
type ToastState,
|
||||
} from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
useWorkspaceAttachments,
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
@@ -85,10 +83,16 @@ import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-co
|
||||
interface ChatAgentStateShape {
|
||||
serverId: string | null;
|
||||
id: string | null;
|
||||
provider?: Agent["provider"];
|
||||
status: Agent["status"] | null;
|
||||
cwd: string | null;
|
||||
workspaceId?: string;
|
||||
capabilities?: Agent["capabilities"];
|
||||
currentModeId?: Agent["currentModeId"];
|
||||
model?: Agent["model"];
|
||||
thinkingOptionId?: Agent["thinkingOptionId"];
|
||||
runtimeInfo?: Agent["runtimeInfo"];
|
||||
features?: Agent["features"];
|
||||
lastError?: Agent["lastError"] | null;
|
||||
}
|
||||
|
||||
@@ -129,10 +133,16 @@ function selectChatAgentState(
|
||||
return {
|
||||
serverId: agent.serverId,
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
status: agent.status,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
capabilities: agent.capabilities,
|
||||
currentModeId: agent.currentModeId,
|
||||
model: agent.model,
|
||||
thinkingOptionId: agent.thinkingOptionId,
|
||||
runtimeInfo: agent.runtimeInfo,
|
||||
features: agent.features,
|
||||
lastError: agent.lastError ?? null,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
@@ -150,10 +160,16 @@ function buildChatAgentFromState(
|
||||
return {
|
||||
serverId: state.serverId,
|
||||
id: state.id,
|
||||
provider: state.provider,
|
||||
status: state.status,
|
||||
cwd: state.cwd,
|
||||
workspaceId: state.workspaceId,
|
||||
capabilities: state.capabilities,
|
||||
currentModeId: state.currentModeId,
|
||||
model: state.model,
|
||||
thinkingOptionId: state.thinkingOptionId,
|
||||
runtimeInfo: state.runtimeInfo,
|
||||
features: state.features,
|
||||
lastError: state.lastError ?? null,
|
||||
projectPlacement,
|
||||
};
|
||||
@@ -545,18 +561,7 @@ function AgentPanelBody({
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = resolveChatAgentFromSession(state, serverId, agentId);
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
workspaceId: agent?.workspaceId,
|
||||
lastError: agent?.lastError ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
};
|
||||
}),
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
@@ -587,7 +592,7 @@ function AgentPanelBody({
|
||||
const attemptToken = ++lookupAttemptTokenRef.current;
|
||||
|
||||
client
|
||||
.fetchAgent(agentId)
|
||||
.fetchAgent({ agentId })
|
||||
.then((result) => {
|
||||
if (attemptToken !== lookupAttemptTokenRef.current) {
|
||||
return;
|
||||
@@ -643,9 +648,16 @@ function AgentPanelBody({
|
||||
? {
|
||||
serverId: agentState.serverId,
|
||||
id: agentState.id,
|
||||
provider: agentState.provider,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
workspaceId: agentState.workspaceId,
|
||||
capabilities: agentState.capabilities,
|
||||
currentModeId: agentState.currentModeId,
|
||||
model: agentState.model,
|
||||
thinkingOptionId: agentState.thinkingOptionId,
|
||||
runtimeInfo: agentState.runtimeInfo,
|
||||
features: agentState.features,
|
||||
lastError: agentState.lastError ?? null,
|
||||
projectPlacement,
|
||||
}
|
||||
@@ -984,7 +996,7 @@ function ChatAgentContent({
|
||||
const currentAgent =
|
||||
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
|
||||
if (!currentAgent) {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -1177,7 +1189,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
return (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<View style={styles.container}>
|
||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
|
||||
{composerSection}
|
||||
@@ -1189,7 +1201,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : null}
|
||||
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
@@ -1377,7 +1389,10 @@ function ActiveAgentComposer({
|
||||
cwd,
|
||||
workspaceId,
|
||||
});
|
||||
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
|
||||
const attachmentScopeKeys = useMemo(
|
||||
() => [workspaceAttachmentScopeKey],
|
||||
[workspaceAttachmentScopeKey],
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
@@ -1478,7 +1493,7 @@ function ActiveAgentComposer({
|
||||
value={agentInputDraft.text}
|
||||
onChangeText={agentInputDraft.setText}
|
||||
attachments={agentInputDraft.attachments}
|
||||
workspaceAttachments={workspaceAttachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={agentInputDraft.setAttachments}
|
||||
cwd={cwd}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { createNameId } from "mnemonic-id";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
@@ -35,10 +36,14 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
useWorkspaceDraftSubmissionStore,
|
||||
type PendingWorkspaceDraftSetup,
|
||||
} from "@/stores/workspace-draft-submission-store";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
@@ -56,11 +61,17 @@ import {
|
||||
} from "@/projects/host-projects";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
import {
|
||||
getWorkspaceNamingAttachments,
|
||||
remapDraftCwdToWorkspace,
|
||||
} from "./new-workspace-fork-context";
|
||||
import {
|
||||
pickerItemToCheckoutRequest,
|
||||
type PickerCheckoutRequest,
|
||||
@@ -81,6 +92,37 @@ function resolveCheckoutRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function useIsNewWorkspaceDraftHandoffActive(input: {
|
||||
draftId: string | undefined;
|
||||
selectedServerId: string;
|
||||
}): boolean {
|
||||
const normalizedDraftId = input.draftId?.trim() ?? "";
|
||||
return useCreateFlowStore((state) =>
|
||||
isActiveCreateFlowForDraft({
|
||||
draftId: normalizedDraftId,
|
||||
serverId: input.selectedServerId,
|
||||
pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVisibleDraftContextScopeKeys(input: {
|
||||
isDraftHandoffActive: boolean;
|
||||
draftContextScopeKey: string;
|
||||
}): readonly string[] {
|
||||
if (input.isDraftHandoffActive || !input.draftContextScopeKey) {
|
||||
return [];
|
||||
}
|
||||
return [input.draftContextScopeKey];
|
||||
}
|
||||
|
||||
function isNewWorkspacePending(input: {
|
||||
pendingAction: "chat" | "empty" | null;
|
||||
isDraftHandoffActive: boolean;
|
||||
}): boolean {
|
||||
return input.pendingAction !== null || input.isDraftHandoffActive;
|
||||
}
|
||||
|
||||
function buildFirstAgentContext(input: {
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
@@ -101,6 +143,7 @@ interface NewWorkspaceScreenProps {
|
||||
sourceDirectory?: string;
|
||||
projectId?: string;
|
||||
displayName?: string;
|
||||
draftId?: string;
|
||||
}
|
||||
|
||||
interface PickerOptionData {
|
||||
@@ -713,6 +756,18 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
|
||||
return [styles.content, styles.contentCentered];
|
||||
}
|
||||
|
||||
function buildNewWorkspaceDraftKey(input: {
|
||||
selectedServerId: string;
|
||||
selectedSourceDirectory: string | null;
|
||||
draftId?: string;
|
||||
}): string {
|
||||
const explicitDraftId = input.draftId?.trim();
|
||||
if (explicitDraftId) {
|
||||
return `new-workspace:draft:${explicitDraftId}`;
|
||||
}
|
||||
return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`;
|
||||
}
|
||||
|
||||
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
|
||||
if (!selection) return null;
|
||||
return selection.item;
|
||||
@@ -732,12 +787,28 @@ function normalizeBranchDetails(
|
||||
interface SubmitDraftInput {
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
draftId?: string;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
provider: AgentProvider;
|
||||
composerState: NonNullable<ReturnType<typeof useAgentInputDraft>["composerState"]>;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
}
|
||||
|
||||
type NewWorkspaceComposerState = NonNullable<
|
||||
ReturnType<typeof useAgentInputDraft>["composerState"]
|
||||
>;
|
||||
|
||||
interface WorkspaceDraftSubmissionConfig {
|
||||
cwd: string;
|
||||
provider: AgentProvider;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
async function createAndMergeWorkspace(input: {
|
||||
@@ -819,6 +890,7 @@ async function createMultiplicityWorkspace(input: {
|
||||
interface CreateChatAgentInput {
|
||||
payload: MessagePayload;
|
||||
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
|
||||
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
|
||||
ensureWorkspace: (input: {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
@@ -827,12 +899,67 @@ interface CreateChatAgentInput {
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
draftId?: string;
|
||||
labels: {
|
||||
composerStateRequired: string;
|
||||
selectModel: string;
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkspaceDraftSetupFromComposer(input: {
|
||||
cwd: string;
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
}): WorkspaceDraftTabSetup {
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
modeId: input.composerState.selectedMode || null,
|
||||
model: input.composerState.effectiveModelId || null,
|
||||
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: input.composerState.featureValues ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkspaceDraftSetupForCreatedWorkspace(input: {
|
||||
forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined;
|
||||
workspaceDirectory: string;
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
}): WorkspaceDraftTabSetup | undefined {
|
||||
if (!input.forkDraftSetup) {
|
||||
return undefined;
|
||||
}
|
||||
return buildWorkspaceDraftSetupFromComposer({
|
||||
cwd: remapDraftCwdToWorkspace({
|
||||
cwd: input.forkDraftSetup.setup.cwd,
|
||||
sourceDirectory: input.forkDraftSetup.sourceDirectory,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
}),
|
||||
provider: input.provider,
|
||||
composerState: input.composerState,
|
||||
});
|
||||
}
|
||||
|
||||
function buildComposerInitialValues(input: {
|
||||
workingDir: string | undefined;
|
||||
initialSetup?: WorkspaceDraftTabSetup | null;
|
||||
}): CreateAgentInitialValues | undefined {
|
||||
if (input.initialSetup) {
|
||||
return {
|
||||
workingDir: input.workingDir ?? input.initialSetup.cwd,
|
||||
provider: input.initialSetup.provider,
|
||||
modeId: input.initialSetup.modeId,
|
||||
model: input.initialSetup.model,
|
||||
thinkingOptionId: input.initialSetup.thinkingOptionId,
|
||||
};
|
||||
}
|
||||
if (input.workingDir) {
|
||||
return { workingDir: input.workingDir };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, draftKey } = input;
|
||||
const { text, attachments, cwd } = payload;
|
||||
@@ -844,15 +971,24 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
throw new Error(input.labels.selectModel);
|
||||
}
|
||||
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
|
||||
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
|
||||
const ensuredWorkspace = await ensureWorkspace({
|
||||
cwd,
|
||||
prompt: text,
|
||||
attachments: reviewAttachments,
|
||||
attachments: workspaceNamingAttachments,
|
||||
withInitialAgent: true,
|
||||
});
|
||||
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
|
||||
forkDraftSetup: input.forkDraftSetup,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
provider,
|
||||
composerState,
|
||||
});
|
||||
submitWorkspaceDraft({
|
||||
serverId,
|
||||
draftKey,
|
||||
draftId: input.draftId,
|
||||
initialSetup,
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
text,
|
||||
@@ -867,12 +1003,14 @@ function buildComposerConfig(input: {
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
sourceDirectory: string | null;
|
||||
initialSetup?: WorkspaceDraftTabSetup | null;
|
||||
}): Parameters<typeof useAgentInputDraft>[0]["composer"] {
|
||||
const { serverId, isConnected, workspaceDirectory, sourceDirectory } = input;
|
||||
const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input;
|
||||
const workingDir = workspaceDirectory || sourceDirectory || undefined;
|
||||
return {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: workingDir ? { workingDir } : undefined,
|
||||
initialValues: buildComposerInitialValues({ workingDir, initialSetup }),
|
||||
initialFeatureValues: initialSetup?.featureValues,
|
||||
isVisible: true,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workingDir,
|
||||
@@ -920,21 +1058,72 @@ function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttach
|
||||
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
|
||||
}
|
||||
|
||||
function usePendingWorkspaceDraftSetup(
|
||||
draftId: string | undefined,
|
||||
): PendingWorkspaceDraftSetup | null {
|
||||
const normalizedDraftId = draftId?.trim() ?? "";
|
||||
return useWorkspaceDraftSubmissionStore((state) => {
|
||||
if (!normalizedDraftId) {
|
||||
return null;
|
||||
}
|
||||
return state.setupByDraftId[normalizedDraftId] ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
draftId: string;
|
||||
workspaceDirectory: string;
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
}): WorkspaceDraftSubmissionConfig {
|
||||
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
|
||||
if (initialSetup) {
|
||||
return {
|
||||
cwd: initialSetup.cwd,
|
||||
provider: initialSetup.provider,
|
||||
modeId: initialSetup.modeId,
|
||||
model: initialSetup.model,
|
||||
thinkingOptionId: initialSetup.thinkingOptionId,
|
||||
featureValues: initialSetup.featureValues,
|
||||
target: { kind: "draft", draftId, setup: initialSetup },
|
||||
};
|
||||
}
|
||||
return {
|
||||
cwd: workspaceDirectory,
|
||||
provider,
|
||||
modeId: composerState.selectedMode || null,
|
||||
model: composerState.effectiveModelId || null,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: composerState.featureValues,
|
||||
target: { kind: "draft", draftId },
|
||||
};
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
const {
|
||||
serverId,
|
||||
draftKey,
|
||||
draftId: draftIdInput,
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
text,
|
||||
attachments,
|
||||
provider,
|
||||
composerState,
|
||||
initialSetup,
|
||||
} = input;
|
||||
const draftId = generateDraftId();
|
||||
const draftId = draftIdInput?.trim() || generateDraftId();
|
||||
const clientMessageId = generateMessageId();
|
||||
const timestamp = Date.now();
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||
const submission = resolveWorkspaceDraftSubmissionConfig({
|
||||
draftId,
|
||||
workspaceDirectory,
|
||||
provider,
|
||||
composerState,
|
||||
initialSetup,
|
||||
});
|
||||
useCreateFlowStore.getState().setPending({
|
||||
serverId,
|
||||
draftId,
|
||||
@@ -952,23 +1141,21 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
draftId,
|
||||
text: text.trim(),
|
||||
attachments,
|
||||
cwd: workspaceDirectory,
|
||||
provider,
|
||||
cwd: submission.cwd,
|
||||
provider: submission.provider,
|
||||
clientMessageId,
|
||||
timestamp,
|
||||
...(composerState.selectedMode !== "" ? { modeId: composerState.selectedMode } : {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
|
||||
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||
...(submission.model ? { model: submission.model } : {}),
|
||||
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
|
||||
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
||||
allowEmptyText: true,
|
||||
});
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
currentPathname: "/new",
|
||||
target: { kind: "draft", draftId },
|
||||
target: submission.target,
|
||||
});
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||
}
|
||||
@@ -1359,6 +1546,7 @@ export function NewWorkspaceScreen({
|
||||
sourceDirectory: sourceDirectoryProp,
|
||||
projectId,
|
||||
displayName: displayNameProp,
|
||||
draftId,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -1395,6 +1583,7 @@ export function NewWorkspaceScreen({
|
||||
const projectPickerAnchorRef = useRef<View>(null);
|
||||
const isolationPickerAnchorRef = useRef<View>(null);
|
||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = pickerSearchQuery.trim();
|
||||
@@ -1403,7 +1592,7 @@ export function NewWorkspaceScreen({
|
||||
}, [pickerSearchQuery]);
|
||||
|
||||
const workspace = createdWorkspace;
|
||||
const isPending = pendingAction !== null;
|
||||
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
|
||||
const client = useHostRuntimeClient(selectedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(selectedServerId);
|
||||
const {
|
||||
@@ -1440,7 +1629,17 @@ export function NewWorkspaceScreen({
|
||||
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
|
||||
projects: projectIconTargets,
|
||||
});
|
||||
const draftKey = `new-workspace:${selectedServerId}:${selectedSourceDirectory ?? "choose-project"}`;
|
||||
const draftKey = buildNewWorkspaceDraftKey({
|
||||
selectedServerId,
|
||||
selectedSourceDirectory,
|
||||
draftId,
|
||||
});
|
||||
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
|
||||
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
const visibleDraftContextScopeKeys = useMemo(
|
||||
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
|
||||
[draftContextScopeKey, isDraftHandoffActive],
|
||||
);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey,
|
||||
composer: buildComposerConfig({
|
||||
@@ -1448,6 +1647,7 @@ export function NewWorkspaceScreen({
|
||||
isConnected,
|
||||
workspaceDirectory: workspace?.workspaceDirectory ?? null,
|
||||
sourceDirectory: selectedSourceDirectory,
|
||||
initialSetup: forkDraftSetup?.setup,
|
||||
}),
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
@@ -1792,9 +1992,11 @@ export function NewWorkspaceScreen({
|
||||
await runCreateChatAgent({
|
||||
payload,
|
||||
composerState,
|
||||
forkDraftSetup,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
draftKey,
|
||||
draftId,
|
||||
labels: {
|
||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||
selectModel: t("newWorkspace.errors.selectModel"),
|
||||
@@ -1807,7 +2009,7 @@ export function NewWorkspaceScreen({
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[composerState, draftKey, ensureWorkspace, selectedServerId, t, toast],
|
||||
[composerState, draftId, draftKey, ensureWorkspace, forkDraftSetup, selectedServerId, t, toast],
|
||||
);
|
||||
|
||||
const renderPickerOption = useCallback(
|
||||
@@ -1967,7 +2169,7 @@ export function NewWorkspaceScreen({
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FileDropZone style={styles.container}>
|
||||
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||
<View style={contentStyle}>
|
||||
<TitlebarDragRegion />
|
||||
@@ -1986,12 +2188,13 @@ export function NewWorkspaceScreen({
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={pendingAction !== null}
|
||||
isSubmitLoading={isPending}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
@@ -2003,7 +2206,7 @@ export function NewWorkspaceScreen({
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1244,7 +1244,7 @@ function RemoveHostSection({
|
||||
await updateSettings({ daemon: { manageBuiltInDaemon: false } });
|
||||
didDisableDaemonManagement = true;
|
||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||
setStatus(await stopDesktopDaemon());
|
||||
setStatus(await stopDesktopDaemon("host_remove"));
|
||||
didStopDaemon = true;
|
||||
}
|
||||
await removeHost(host.serverId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCreateFlowStore } from "./create-flow-store";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "./create-flow-store";
|
||||
|
||||
describe("create-flow-store", () => {
|
||||
beforeEach(() => {
|
||||
@@ -88,4 +88,40 @@ describe("create-flow-store", () => {
|
||||
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches only active pending create flows for a draft and server", () => {
|
||||
useCreateFlowStore.getState().setPending({
|
||||
draftId: "draft-1",
|
||||
serverId: "server-1",
|
||||
agentId: null,
|
||||
clientMessageId: "msg-1",
|
||||
text: "hello",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const pending = useCreateFlowStore.getState().pendingByDraftId["draft-1"];
|
||||
|
||||
expect(
|
||||
isActiveCreateFlowForDraft({
|
||||
pending,
|
||||
serverId: "server-1",
|
||||
draftId: " draft-1 ",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isActiveCreateFlowForDraft({
|
||||
pending,
|
||||
serverId: "server-2",
|
||||
draftId: "draft-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "sent" });
|
||||
expect(
|
||||
isActiveCreateFlowForDraft({
|
||||
pending: useCreateFlowStore.getState().pendingByDraftId["draft-1"],
|
||||
serverId: "server-1",
|
||||
draftId: "draft-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,20 @@ export interface PendingCreateAttempt {
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
|
||||
export function isActiveCreateFlowForDraft(input: {
|
||||
pending: PendingCreateAttempt | null | undefined;
|
||||
serverId: string;
|
||||
draftId: string | null | undefined;
|
||||
}): boolean {
|
||||
const draftId = input.draftId?.trim();
|
||||
return Boolean(
|
||||
draftId &&
|
||||
input.pending?.draftId === draftId &&
|
||||
input.pending.serverId === input.serverId &&
|
||||
input.pending.lifecycle === "active",
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateFlowState {
|
||||
pendingByDraftId: Record<string, PendingCreateAttempt>;
|
||||
setPending: (pending: Omit<PendingCreateAttempt, "lifecycle">) => void;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export interface PendingWorkspaceDraftSubmission {
|
||||
serverId: string;
|
||||
@@ -19,9 +20,21 @@ export interface PendingWorkspaceDraftSubmission {
|
||||
allowEmptyText?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingWorkspaceDraftSetup {
|
||||
setup: WorkspaceDraftTabSetup;
|
||||
sourceDirectory?: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceDraftSubmissionState {
|
||||
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
|
||||
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
|
||||
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
|
||||
setDraftSetup: (input: {
|
||||
draftId: string;
|
||||
setup: WorkspaceDraftTabSetup;
|
||||
sourceDirectory?: string | null;
|
||||
}) => void;
|
||||
clearDraftSetup: (input: { draftId: string }) => void;
|
||||
consumePending: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -40,9 +53,14 @@ function matchesPendingSubmission(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDraftId(draftId: string): string {
|
||||
return draftId.trim();
|
||||
}
|
||||
|
||||
export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>(
|
||||
(set, get) => ({
|
||||
pendingByDraftId: {},
|
||||
setupByDraftId: {},
|
||||
setPending: (submission) =>
|
||||
set((state) => ({
|
||||
pendingByDraftId: {
|
||||
@@ -50,6 +68,25 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
|
||||
[submission.draftId]: submission,
|
||||
},
|
||||
})),
|
||||
setDraftSetup: ({ draftId, setup, sourceDirectory }) => {
|
||||
const normalizedDraftId = normalizeDraftId(draftId);
|
||||
if (!normalizedDraftId) return;
|
||||
set((state) => ({
|
||||
setupByDraftId: {
|
||||
...state.setupByDraftId,
|
||||
[normalizedDraftId]: { setup, sourceDirectory: sourceDirectory ?? null },
|
||||
},
|
||||
}));
|
||||
},
|
||||
clearDraftSetup: ({ draftId }) => {
|
||||
const normalizedDraftId = normalizeDraftId(draftId);
|
||||
if (!normalizedDraftId) return;
|
||||
set((state) => {
|
||||
if (!state.setupByDraftId[normalizedDraftId]) return state;
|
||||
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
|
||||
return { setupByDraftId };
|
||||
});
|
||||
},
|
||||
consumePending: (input) => {
|
||||
const pending = get().pendingByDraftId[input.draftId];
|
||||
if (!matchesPendingSubmission(pending, input)) {
|
||||
|
||||
@@ -69,6 +69,14 @@ function makeTimelineEvent(
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeAssistantTimelineEvent(text: string, messageId?: string): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "assistant_message", text, ...(messageId ? { messageId } : {}) },
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeToolCallTimelineEvent(callId: string): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
@@ -1523,6 +1531,65 @@ describe("processAgentStreamEvents", () => {
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps matching assistant message ids in the live head", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Hel", "assistant-one"), 1),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("lo", "assistant-one"), 2),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.tail).toEqual([]);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "Hello",
|
||||
messageId: "assistant-one",
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes the live assistant head before starting a different assistant message id", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("First", "assistant-one"), 1),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Second", "assistant-two"), 2),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(getAssistantTexts(result.tail)).toEqual(["First"]);
|
||||
expect(getAssistantTexts(result.head)).toEqual(["Second"]);
|
||||
});
|
||||
|
||||
it("flushes an anonymous assistant head before starting an identified assistant message", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Anonymous"), 1),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Identified", "assistant-two"), 2),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(getAssistantTexts(result.tail)).toEqual(["Anonymous"]);
|
||||
expect(getAssistantTexts(result.head)).toEqual(["Identified"]);
|
||||
});
|
||||
|
||||
it("promotes completed assistant markdown blocks to tail while keeping the live block in head", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
@@ -1632,6 +1699,92 @@ describe("processAgentStreamEvents", () => {
|
||||
} satisfies TimelineCursor);
|
||||
});
|
||||
|
||||
it("keeps Claude image tool-result output before following assistant blocks while text streams", () => {
|
||||
const imageMarkdown =
|
||||
"";
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "ABC",
|
||||
messageId: "assistant-before",
|
||||
},
|
||||
} as AgentStreamEventPayload,
|
||||
1,
|
||||
),
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("toolu_read_png"), 2),
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "toolu_read_png",
|
||||
name: "Read",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "/tmp/image.png",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
} as AgentStreamEventPayload,
|
||||
3,
|
||||
),
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: imageMarkdown,
|
||||
},
|
||||
} as AgentStreamEventPayload,
|
||||
4,
|
||||
),
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "D",
|
||||
messageId: "assistant-after",
|
||||
},
|
||||
} as AgentStreamEventPayload,
|
||||
5,
|
||||
),
|
||||
makeStreamReducerEvent(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "\n\nE",
|
||||
messageId: "assistant-after",
|
||||
},
|
||||
} as AgentStreamEventPayload,
|
||||
6,
|
||||
),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
|
||||
"ABC",
|
||||
imageMarkdown,
|
||||
"D",
|
||||
"E",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the final optimistic lifecycle patch across a batch", () => {
|
||||
const result = processAgentStreamEvents({
|
||||
events: [
|
||||
|
||||
@@ -888,6 +888,13 @@ function getEventItemKind(event: AgentStreamEventPayload): StreamItem["kind"] |
|
||||
}
|
||||
}
|
||||
|
||||
function getIncomingAssistantMessageId(event: AgentStreamEventPayload): string | undefined {
|
||||
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
return event.item.messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize head items before flushing to tail.
|
||||
* Marks thoughts as "ready" since they're no longer being streamed.
|
||||
@@ -939,10 +946,7 @@ function getTailAssistantToResume(params: {
|
||||
if (params.tailAssistant?.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
const incomingMessageId =
|
||||
params.event.type === "timeline" && params.event.item.type === "assistant_message"
|
||||
? params.event.item.messageId
|
||||
: undefined;
|
||||
const incomingMessageId = getIncomingAssistantMessageId(params.event);
|
||||
if (incomingMessageId !== undefined && params.tailAssistant.messageId !== incomingMessageId) {
|
||||
return null;
|
||||
}
|
||||
@@ -986,6 +990,7 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str
|
||||
groupId: blockGroupId,
|
||||
blockIndex: firstBlockIndex + offset,
|
||||
}),
|
||||
...(activeItem.messageId ? { messageId: activeItem.messageId } : {}),
|
||||
blockGroupId,
|
||||
blockIndex: firstBlockIndex + offset,
|
||||
text: block,
|
||||
@@ -1035,9 +1040,14 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI
|
||||
|
||||
/**
|
||||
* Determine if the head should be flushed based on incoming event kind.
|
||||
* Flush when a different kind arrives or when the incoming kind is not streamable.
|
||||
* Flush when a different streamable lane starts, including a new identified assistant message.
|
||||
*/
|
||||
function shouldFlushHead(head: StreamItem[], incomingKind: StreamItem["kind"] | null): boolean {
|
||||
function shouldFlushHead(input: {
|
||||
head: StreamItem[];
|
||||
incomingKind: StreamItem["kind"] | null;
|
||||
event: AgentStreamEventPayload;
|
||||
}): boolean {
|
||||
const { head, incomingKind, event } = input;
|
||||
if (head.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -1071,6 +1081,11 @@ function shouldFlushHead(head: StreamItem[], incomingKind: StreamItem["kind"] |
|
||||
return true;
|
||||
}
|
||||
|
||||
if (incomingKind === "assistant_message" && lastStreamable.kind === "assistant_message") {
|
||||
const incomingMessageId = getIncomingAssistantMessageId(event);
|
||||
return incomingMessageId !== undefined && lastStreamable.messageId !== incomingMessageId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1133,7 +1148,13 @@ export function applyStreamEvent(params: {
|
||||
const incomingKind = getEventItemKind(event);
|
||||
|
||||
// Check if we need to flush head before processing this event
|
||||
if (shouldFlushHead(nextHead, incomingKind)) {
|
||||
if (
|
||||
shouldFlushHead({
|
||||
head: nextHead,
|
||||
incomingKind,
|
||||
event,
|
||||
})
|
||||
) {
|
||||
flushHead();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface DeferredInit {
|
||||
}
|
||||
|
||||
const initPromises = new Map<string, DeferredInit>();
|
||||
export const INIT_TIMEOUT_MS = 65_000;
|
||||
|
||||
export function getInitKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
@@ -36,12 +37,20 @@ export function createInitDeferred(key: string, requestDirection: "tail" | "afte
|
||||
return deferred;
|
||||
}
|
||||
|
||||
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
|
||||
const deferred = initPromises.get(key);
|
||||
export function refreshInitTimeout(input: {
|
||||
key: string;
|
||||
onTimeout: () => void;
|
||||
timeoutMs?: number;
|
||||
}): void {
|
||||
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
|
||||
const deferred = initPromises.get(input.key);
|
||||
if (!deferred) {
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
deferred.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,16 @@ describe("global routes", () => {
|
||||
}),
|
||||
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&name=Project&projectId=project-1");
|
||||
});
|
||||
|
||||
it("buildNewWorkspaceRoute carries a draft context id", () => {
|
||||
expect(
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: "local",
|
||||
sourceDirectory: "/repo/project",
|
||||
draftId: "draft-1",
|
||||
}),
|
||||
).toBe("/new?serverId=local&dir=%2Frepo%2Fproject&draftId=draft-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host settings section slugs", () => {
|
||||
|
||||
@@ -424,6 +424,7 @@ interface NewWorkspaceRouteOptions {
|
||||
sourceDirectory?: string;
|
||||
displayName?: string;
|
||||
projectId?: string;
|
||||
draftId?: string;
|
||||
}
|
||||
|
||||
function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
|
||||
@@ -441,6 +442,9 @@ function buildNewWorkspaceSearch(options: NewWorkspaceRouteOptions): string {
|
||||
if (options.projectId) {
|
||||
params.set("projectId", options.projectId);
|
||||
}
|
||||
if (options.draftId) {
|
||||
params.set("draftId", options.draftId);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ export function addAttachOptions(cmd: Command): Command {
|
||||
.argument("<id>", "Agent ID (or prefix)");
|
||||
}
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
} from "../../utils/timeline.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload, AgentStreamMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -121,7 +124,7 @@ export async function runAttachCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (!fetchResult) {
|
||||
console.error(`Error: No agent found matching: ${id}`);
|
||||
console.error("Use `paseo ls` to list available agents");
|
||||
@@ -139,6 +142,7 @@ export async function runAttachCommand(
|
||||
const timelineItems = await fetchProjectedTimelineItems({
|
||||
client,
|
||||
agentId: resolvedId,
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
});
|
||||
for (const item of timelineItems) {
|
||||
printTimelineItem(item);
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function runDeleteCommand(
|
||||
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||
});
|
||||
} else if (id) {
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function runInspectCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdArg);
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
} from "../../utils/timeline.js";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -32,8 +35,9 @@ export const NO_ACTIVITY_MESSAGE = "No activity to display.";
|
||||
export async function fetchAgentTimelineItems(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
return fetchProjectedTimelineItems({ client, agentId });
|
||||
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
|
||||
}
|
||||
|
||||
export function formatAgentActivityTranscript(
|
||||
@@ -107,7 +111,7 @@ export async function runLogsCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (!fetchResult) {
|
||||
console.error(`Error: No agent found matching: ${id}`);
|
||||
console.error("Use `paseo ls` to list available agents");
|
||||
@@ -173,7 +177,14 @@ async function runFollowMode(
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
// First, get existing timeline.
|
||||
let existingItems = await fetchAgentTimelineItems(client, agentId);
|
||||
let existingItems: AgentTimelineItem[] = [];
|
||||
try {
|
||||
existingItems = await fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function runModeCommand(
|
||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
|
||||
try {
|
||||
client = await connectToDaemon({ host: options.host });
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function runStopCommand(
|
||||
});
|
||||
} else if (id) {
|
||||
// Stop specific agent
|
||||
const fetchResult = await client.fetchAgent(id);
|
||||
const fetchResult = await client.fetchAgent({ agentId: id });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function runUpdateCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdArg);
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdArg });
|
||||
if (!fetchResult) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
@@ -148,7 +148,7 @@ export async function runUpdateCommand(
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
});
|
||||
|
||||
const updatedResult = await client.fetchAgent(agentId);
|
||||
const updatedResult = await client.fetchAgent({ agentId });
|
||||
if (!updatedResult) {
|
||||
throw new Error(`Agent not found after update: ${agentId}`);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AgentWaitOptions extends CommandOptions {
|
||||
}
|
||||
|
||||
const WAIT_ACTIVITY_PREVIEW_COUNT = 5;
|
||||
const WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS = 2_000;
|
||||
|
||||
function appendRecentActivity(message: string, transcript: string | null): string {
|
||||
if (!transcript || transcript.trim().length === 0) {
|
||||
@@ -46,7 +47,9 @@ async function getRecentActivityTranscript(
|
||||
agentId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const timelineItems = await fetchAgentTimelineItems(client, agentId);
|
||||
const timelineItems = await fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: WAIT_ACTIVITY_PREVIEW_TIMEOUT_MS,
|
||||
});
|
||||
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function connectChatClient(host?: string) {
|
||||
export async function attachAgentNamesToMessages(
|
||||
client: Awaited<ReturnType<typeof connectToDaemon>>,
|
||||
messages: ChatMessageRow[],
|
||||
options: { timeout?: number; bestEffort?: boolean } = {},
|
||||
): Promise<ChatMessageRow[]> {
|
||||
const agentIds = new Set<string>();
|
||||
for (const message of messages) {
|
||||
@@ -39,9 +40,18 @@ export async function attachAgentNamesToMessages(
|
||||
return messages;
|
||||
}
|
||||
|
||||
const payload = await client.fetchAgents({
|
||||
filter: { includeArchived: true },
|
||||
});
|
||||
let payload: Awaited<ReturnType<typeof client.fetchAgents>>;
|
||||
try {
|
||||
payload = await client.fetchAgents({
|
||||
filter: { includeArchived: true },
|
||||
...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (options.bestEffort) {
|
||||
return messages;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const agentNames = new Map<string, string>();
|
||||
for (const entry of payload.entries) {
|
||||
const title = entry.agent.title?.trim();
|
||||
|
||||
@@ -13,26 +13,44 @@ export interface ChatWaitOptions extends ChatCommandOptions {
|
||||
timeout?: string;
|
||||
}
|
||||
|
||||
const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000;
|
||||
|
||||
export async function runWaitCommand(
|
||||
room: string,
|
||||
options: ChatWaitOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ChatMessageRow>> {
|
||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
||||
const { client } = await connectChatClient(options.host);
|
||||
const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null;
|
||||
const hasExplicitTimeout = deadline !== null;
|
||||
const remainingTimeoutMs = () =>
|
||||
deadline === null ? undefined : Math.max(1, deadline - Date.now());
|
||||
try {
|
||||
const latest = await client.readChatMessages({
|
||||
room,
|
||||
limit: 1,
|
||||
...(hasExplicitTimeout
|
||||
? {
|
||||
timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const afterMessageId = latest.messages[0]?.id;
|
||||
const payload = await client.waitForChatMessages({
|
||||
room,
|
||||
afterMessageId,
|
||||
timeoutMs: parseTimeoutMs(options.timeout),
|
||||
timeoutMs: remainingTimeoutMs() ?? timeoutMs,
|
||||
});
|
||||
const messages = await attachAgentNamesToMessages(
|
||||
client,
|
||||
payload.messages.map(toChatMessageRow),
|
||||
hasExplicitTimeout
|
||||
? {
|
||||
timeout: remainingTimeoutMs(),
|
||||
bestEffort: true,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
return {
|
||||
type: "list",
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface StopLocalDaemonResult {
|
||||
home: string;
|
||||
pid: number | null;
|
||||
forced: boolean;
|
||||
usedLifecycleRpc: boolean;
|
||||
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -230,6 +232,15 @@ function resolveStopMessage(
|
||||
return fallbackMessage ?? "Daemon stopped via owner PID signal";
|
||||
}
|
||||
|
||||
function resolveStopReason(
|
||||
forced: boolean,
|
||||
lifecycleRequested: boolean,
|
||||
): StopLocalDaemonResult["reason"] {
|
||||
if (forced) return "owner_pid_sigkill";
|
||||
if (lifecycleRequested) return "lifecycle_shutdown_rpc";
|
||||
return "owner_pid_signal";
|
||||
}
|
||||
|
||||
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
|
||||
@@ -421,6 +432,8 @@ function createNotRunningStopResult(
|
||||
home: state.home,
|
||||
pid,
|
||||
forced: false,
|
||||
usedLifecycleRpc: false,
|
||||
reason: "not_running",
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -667,7 +680,9 @@ async function requestLifecycleShutdown(
|
||||
};
|
||||
}
|
||||
|
||||
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
||||
const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) });
|
||||
if (!client) {
|
||||
return {
|
||||
requested: false,
|
||||
@@ -676,7 +691,7 @@ async function requestLifecycleShutdown(
|
||||
}
|
||||
|
||||
try {
|
||||
await client.shutdownServer();
|
||||
await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) });
|
||||
return { requested: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -696,8 +711,10 @@ export async function stopLocalDaemon(
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
||||
const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||
const state = resolveLocalDaemonState({ home: options.home });
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
||||
|
||||
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
|
||||
const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs());
|
||||
const lifecycleRequested = shutdownAttempt.requested;
|
||||
|
||||
if (!state.pidInfo || (!state.running && !lifecycleRequested)) {
|
||||
@@ -720,7 +737,7 @@ export async function stopLocalDaemon(
|
||||
const { stopped, forced } = await waitForStopAfterRequest({
|
||||
state,
|
||||
pid,
|
||||
timeoutMs,
|
||||
timeoutMs: remainingTimeoutMs(),
|
||||
killTimeoutMs,
|
||||
force: options.force,
|
||||
});
|
||||
@@ -737,6 +754,8 @@ export async function stopLocalDaemon(
|
||||
home: state.home,
|
||||
pid,
|
||||
forced,
|
||||
usedLifecycleRpc: lifecycleRequested,
|
||||
reason: resolveStopReason(forced, lifecycleRequested),
|
||||
message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface PairOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500;
|
||||
|
||||
export function pairCommand(): Command {
|
||||
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
@@ -35,7 +37,9 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
const offer = await client.getDaemonPairingOffer();
|
||||
const offer = await client.getDaemonPairingOffer({
|
||||
timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS,
|
||||
});
|
||||
await client.close().catch(() => {});
|
||||
outputPairingResult(
|
||||
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { CommandOptions, ListResult, OutputSchema } from "../../output/inde
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
|
||||
|
||||
const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500;
|
||||
|
||||
interface ProviderBinaryStatus {
|
||||
label: string;
|
||||
path: string | null;
|
||||
@@ -276,7 +278,10 @@ async function probeDaemonOverWebsocket(args: {
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agentsPayload = await client.fetchAgents({
|
||||
filter: { includeArchived: true },
|
||||
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
const runningAgents = agents.filter((a) => a.status === "running").length;
|
||||
const idleAgents = agents.filter((a) => a.status === "idle").length;
|
||||
@@ -284,7 +289,9 @@ async function probeDaemonOverWebsocket(args: {
|
||||
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
if (supportsDaemonStatusRpc) {
|
||||
try {
|
||||
const statusPayload = await client.getDaemonStatus();
|
||||
const statusPayload = await client.getDaemonStatus({
|
||||
timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
|
||||
daemonProviders = statusPayload.providers.map((p) => ({
|
||||
label: labelMap.get(p.provider) ?? p.provider,
|
||||
|
||||
@@ -16,6 +16,8 @@ interface StopResult {
|
||||
home: string;
|
||||
pid: string;
|
||||
forced: boolean;
|
||||
usedLifecycleRpc: boolean;
|
||||
reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill";
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -75,6 +77,8 @@ export async function runStopCommand(
|
||||
home: result.home,
|
||||
pid: result.pid === null ? "-" : String(result.pid),
|
||||
forced: result.forced,
|
||||
usedLifecycleRpc: result.usedLifecycleRpc,
|
||||
reason: result.reason,
|
||||
message: result.message,
|
||||
},
|
||||
schema: stopResultSchema,
|
||||
|
||||
@@ -40,6 +40,7 @@ type OnboardPersistedConfig = PersistedConfig & {
|
||||
};
|
||||
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const READY_PROBE_TIMEOUT_MS = 1200;
|
||||
|
||||
class OnboardCancelledError extends Error {}
|
||||
|
||||
@@ -201,15 +202,22 @@ function renderProgressLine(progress: DownloadProgress): string {
|
||||
|
||||
type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" };
|
||||
|
||||
async function probeDaemonReady(home: string): Promise<ProbeResult> {
|
||||
async function probeDaemonReady(home: string, timeoutMs: number): Promise<ProbeResult> {
|
||||
const state = resolveLocalDaemonState({ home });
|
||||
const host = resolveTcpHostFromListen(state.listen);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const remainingTimeoutMs = () => Math.max(1, deadline - Date.now());
|
||||
|
||||
if (state.running && host) {
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1200 });
|
||||
const client = await tryConnectToDaemon({
|
||||
host,
|
||||
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
if (client) {
|
||||
try {
|
||||
await client.fetchAgents();
|
||||
await client.fetchAgents({
|
||||
timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
return { kind: "ready", listen: state.listen, host };
|
||||
} catch {
|
||||
// Daemon process is alive but not API-ready yet.
|
||||
@@ -255,23 +263,29 @@ async function waitForDaemonReady(args: {
|
||||
onStatus?: (message: string) => void;
|
||||
}): Promise<{ listen: string; host: string | null }> {
|
||||
const deadline = Date.now() + args.timeoutMs;
|
||||
const createTimeoutError = () => {
|
||||
const recentLogs = tailDaemonLog(args.home, 60);
|
||||
return new Error(
|
||||
[
|
||||
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
);
|
||||
};
|
||||
|
||||
async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> {
|
||||
const probe = await probeDaemonReady(args.home);
|
||||
if (Date.now() >= deadline) {
|
||||
throw createTimeoutError();
|
||||
}
|
||||
const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now()));
|
||||
if (probe.kind === "ready") {
|
||||
return { listen: probe.listen, host: probe.host };
|
||||
}
|
||||
const nextState = announceProgress(args.home, state, args.onStatus);
|
||||
if (Date.now() >= deadline) {
|
||||
const recentLogs = tailDaemonLog(args.home, 60);
|
||||
throw new Error(
|
||||
[
|
||||
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
);
|
||||
throw createTimeoutError();
|
||||
}
|
||||
await sleep(200);
|
||||
return poll(nextState);
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function runAllowCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
if (!fetchResult) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function runDenyCommand(
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
if (!fetchResult) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export const LIVE_HISTORY_FETCH_TIMEOUT_MS = 2_000;
|
||||
|
||||
interface FetchProjectedTimelineItemsInput {
|
||||
client: DaemonClient;
|
||||
agentId: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function fetchProjectedTimelineItems(
|
||||
@@ -13,6 +16,7 @@ export async function fetchProjectedTimelineItems(
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
timeout: input.timeoutMs,
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,11 @@ async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readCapturedSupervisorLogs(paseoHome: string, recentLogs: string): Promise<string> {
|
||||
const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => "");
|
||||
return `${recentLogs}\n${durableLogs}`;
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
check: () => Promise<boolean> | boolean,
|
||||
timeoutMs: number,
|
||||
@@ -206,13 +211,20 @@ try {
|
||||
"stopped",
|
||||
"daemon should remain stopped after stop command",
|
||||
);
|
||||
const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs);
|
||||
assert(
|
||||
recentSupervisorLogs.includes("Shutdown requested by worker. Stopping worker..."),
|
||||
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`,
|
||||
capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') &&
|
||||
capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'),
|
||||
`stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
!recentSupervisorLogs.includes("cli_shutdown"),
|
||||
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`,
|
||||
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') &&
|
||||
capturedSupervisorLogs.includes('"signal":"SIGTERM"'),
|
||||
`stop should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
!capturedSupervisorLogs.includes("cli_shutdown"),
|
||||
`supervisor logs should not route shutdown by reason string:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
console.log("✓ stop leaves supervised daemon stopped (no respawn)\n");
|
||||
} finally {
|
||||
|
||||
@@ -229,8 +229,14 @@ try {
|
||||
);
|
||||
const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs);
|
||||
assert(
|
||||
capturedSupervisorLogs.includes("Restart requested by worker. Stopping worker for restart..."),
|
||||
`restart should route through supervisor restart intent, logs:\n${capturedSupervisorLogs}`,
|
||||
capturedSupervisorLogs.includes('"msg":"Worker requested restart"') &&
|
||||
capturedSupervisorLogs.includes('"reason":"settings_update"'),
|
||||
`restart should log lifecycle restart reason from daemon worker, logs:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
assert(
|
||||
capturedSupervisorLogs.includes('"msg":"Supervisor sending signal to worker"') &&
|
||||
capturedSupervisorLogs.includes('"signal":"SIGTERM"'),
|
||||
`restart should log supervisor signal dispatch, logs:\n${capturedSupervisorLogs}`,
|
||||
);
|
||||
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
|
||||
} finally {
|
||||
|
||||
@@ -144,17 +144,23 @@ try {
|
||||
const parsed = JSON.parse(stopResult.stdout) as {
|
||||
action?: unknown;
|
||||
forced?: unknown;
|
||||
usedLifecycleRpc?: unknown;
|
||||
reason?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
action: parsed.action,
|
||||
forced: parsed.forced,
|
||||
usedLifecycleRpc: parsed.usedLifecycleRpc,
|
||||
reason: parsed.reason,
|
||||
message: parsed.message,
|
||||
},
|
||||
{
|
||||
action: "stopped",
|
||||
forced: true,
|
||||
usedLifecycleRpc: false,
|
||||
reason: "owner_pid_sigkill",
|
||||
message: "Daemon owner process was force-stopped",
|
||||
},
|
||||
`stop should report forced tree cleanup: ${stopResult.stdout}`,
|
||||
|
||||
@@ -522,6 +522,520 @@ test("keeps the transport connected when a session RPC ping times out", async ()
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
});
|
||||
|
||||
test("defaults session RPC waiters to sixty seconds", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.fetchAgent({
|
||||
agentId: "agent-1",
|
||||
requestId: "req-agent-1",
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "fetch_agent_request",
|
||||
requestId: "req-agent-1",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(59_999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
|
||||
});
|
||||
|
||||
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.fetchAgent({
|
||||
agentId: "agent-1",
|
||||
requestId: "req-agent-1",
|
||||
timeout: 5_000,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "fetch_agent_request",
|
||||
requestId: "req-agent-1",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (5000ms)");
|
||||
});
|
||||
|
||||
test("preserves legacy fetchAgent id overload", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.fetchAgent("agent-1", "req-agent-legacy");
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "fetch_agent_request",
|
||||
requestId: "req-agent-legacy",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "fetch_agent_response",
|
||||
payload: {
|
||||
requestId: "req-agent-legacy",
|
||||
agent: null,
|
||||
project: null,
|
||||
error: "legacy fetch sentinel",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
|
||||
});
|
||||
|
||||
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.fetchAgentTimeline("agent-1", {
|
||||
requestId: "req-timeline-1",
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
timeout: 2_000,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "fetch_agent_timeline_request",
|
||||
requestId: "req-timeline-1",
|
||||
agentId: "agent-1",
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)");
|
||||
});
|
||||
|
||||
test("honors explicit fetchAgents timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.fetchAgents({
|
||||
requestId: "req-agents-1",
|
||||
scope: "active",
|
||||
timeout: 1_200,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "fetch_agents_request",
|
||||
requestId: "req-agents-1",
|
||||
scope: "active",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_199);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1200ms)");
|
||||
});
|
||||
|
||||
test("honors explicit shutdownServer timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.shutdownServer({
|
||||
requestId: "req-shutdown-1",
|
||||
timeout: 1_500,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "shutdown_server_request",
|
||||
requestId: "req-shutdown-1",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_499);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
||||
});
|
||||
|
||||
test("honors explicit readChatMessages timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.readChatMessages({
|
||||
requestId: "req-chat-read-1",
|
||||
room: "room-1",
|
||||
limit: 1,
|
||||
timeout: 2_500,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "chat/read",
|
||||
requestId: "req-chat-read-1",
|
||||
room: "room-1",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_499);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2500ms)");
|
||||
});
|
||||
|
||||
test("honors explicit getDaemonStatus timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.getDaemonStatus({
|
||||
requestId: "req-daemon-status-1",
|
||||
timeout: 1_500,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "daemon.get_status.request",
|
||||
requestId: "req-daemon-status-1",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_499);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
||||
});
|
||||
|
||||
test("honors explicit getDaemonPairingOffer timeout below the session RPC default", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.getDaemonPairingOffer({
|
||||
requestId: "req-pairing-offer-1",
|
||||
timeout: 1_500,
|
||||
});
|
||||
let settled = false;
|
||||
void responsePromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "daemon.get_pairing_offer.request",
|
||||
requestId: "req-pairing-offer-1",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_499);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
||||
});
|
||||
|
||||
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const waitPromise = client.waitForAgentUpsert("agent-1", () => false, 5_000);
|
||||
let settled = false;
|
||||
void waitPromise.then(
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "fetch_agent_request",
|
||||
requestId: expect.any(String),
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(waitPromise).rejects.toThrow("Timed out waiting for agent agent-1");
|
||||
});
|
||||
|
||||
test("keeps default connect timeout shorter than session RPC waiters", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
let settled = false;
|
||||
const pendingConnect = client.connect().then(
|
||||
() => {
|
||||
settled = true;
|
||||
return { ok: true as const };
|
||||
},
|
||||
(error) => {
|
||||
settled = true;
|
||||
return { ok: false as const, error };
|
||||
},
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(14_999);
|
||||
expect(settled).toBe(false);
|
||||
expect(client.getConnectionState().status).toBe("connecting");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const result = await pendingConnect;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(Error);
|
||||
if (result.error instanceof Error) {
|
||||
expect(result.error.message).toContain("Connection timed out");
|
||||
}
|
||||
}
|
||||
expect(client.getConnectionState().status).toBe("disconnected");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays online through ten minutes of pongs that arrive five seconds late", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
@@ -1754,16 +2268,8 @@ test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const lifecycleClient = client as unknown as {
|
||||
shutdownServer: (requestId?: string) => Promise<{
|
||||
status: "shutdown_requested";
|
||||
clientId: string;
|
||||
requestId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(typeof lifecycleClient.shutdownServer).toBe("function");
|
||||
const promise = lifecycleClient.shutdownServer("req-shutdown-1");
|
||||
expect(typeof client.shutdownServer).toBe("function");
|
||||
const promise = client.shutdownServer({ requestId: "req-shutdown-1" });
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
@@ -3347,7 +3853,8 @@ test("lists commands with draft config via RPC", async () => {
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.listCommands("__new_agent__", {
|
||||
const promise = client.listCommands({
|
||||
agentId: "__new_agent__",
|
||||
draftConfig: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
@@ -3392,7 +3899,7 @@ test("lists commands with draft config via RPC", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("lists commands with legacy requestId signature via RPC", async () => {
|
||||
test("lists commands with explicit requestId via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -3409,13 +3916,16 @@ test("lists commands with legacy requestId signature via RPC", async () => {
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.listCommands("agent-1", "req-legacy");
|
||||
const promise = client.listCommands({
|
||||
agentId: "agent-1",
|
||||
requestId: "req-commands",
|
||||
});
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request.type).toBe("list_commands_request");
|
||||
expect(request.agentId).toBe("agent-1");
|
||||
expect(request.requestId).toBe("req-legacy");
|
||||
expect(request.requestId).toBe("req-commands");
|
||||
expect(request.draftConfig).toBeUndefined();
|
||||
|
||||
mock.triggerMessage(
|
||||
@@ -3427,7 +3937,7 @@ test("lists commands with legacy requestId signature via RPC", async () => {
|
||||
agentId: "agent-1",
|
||||
commands: [],
|
||||
error: null,
|
||||
requestId: "req-legacy",
|
||||
requestId: "req-commands",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -3437,7 +3947,113 @@ test("lists commands with legacy requestId signature via RPC", async () => {
|
||||
agentId: "agent-1",
|
||||
commands: [],
|
||||
error: null,
|
||||
requestId: "req-legacy",
|
||||
requestId: "req-commands",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves legacy listCommands id overload", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.listCommands("agent-1", "req-commands-legacy");
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "list_commands_request",
|
||||
requestId: "req-commands-legacy",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
commands: [],
|
||||
error: null,
|
||||
requestId: "req-commands-legacy",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
agentId: "agent-1",
|
||||
commands: [],
|
||||
error: null,
|
||||
requestId: "req-commands-legacy",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves legacy listCommands options overload", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.listCommands("__new_agent__", {
|
||||
requestId: "req-commands-draft-legacy",
|
||||
draftConfig: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "bypassPermissions",
|
||||
model: "gpt-5",
|
||||
thinkingOptionId: "off",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "list_commands_request",
|
||||
requestId: "req-commands-draft-legacy",
|
||||
agentId: "__new_agent__",
|
||||
draftConfig: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "bypassPermissions",
|
||||
model: "gpt-5",
|
||||
thinkingOptionId: "off",
|
||||
},
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "list_commands_response",
|
||||
payload: {
|
||||
agentId: "__new_agent__",
|
||||
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
||||
error: null,
|
||||
requestId: "req-commands-draft-legacy",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
agentId: "__new_agent__",
|
||||
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
||||
error: null,
|
||||
requestId: "req-commands-draft-legacy",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -472,7 +472,7 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
},
|
||||
latest: () => latest,
|
||||
refetch: async (requestId) => {
|
||||
const result = await daemonClient.fetchAgent(id, requestId);
|
||||
const result = await daemonClient.fetchAgent({ agentId: id, requestId });
|
||||
latest = result?.agent ?? null;
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => ({
|
||||
runExternalCliJsonCommand: vi.fn(),
|
||||
runExternalCliTextCommand: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
@@ -30,7 +32,7 @@ vi.mock("electron", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("electron-log/main", () => ({
|
||||
default: { info: vi.fn(), error: vi.fn() },
|
||||
default: { info: mocks.logInfo, error: mocks.logError },
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
@@ -101,6 +103,8 @@ describe("daemon-manager commands", () => {
|
||||
mocks.runExternalCliJsonCommand.mockReset();
|
||||
mocks.runExternalCliTextCommand.mockReset();
|
||||
mocks.spawnProcess.mockReset();
|
||||
mocks.logInfo.mockReset();
|
||||
mocks.logError.mockReset();
|
||||
rmSync(mocks.paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -194,6 +198,31 @@ describe("daemon-manager commands", () => {
|
||||
"status",
|
||||
"--json",
|
||||
]);
|
||||
expect(mocks.logInfo).toHaveBeenCalledWith(
|
||||
"[desktop daemon]",
|
||||
"desktop daemon stop requested",
|
||||
expect.objectContaining({
|
||||
reason: "manual_ipc",
|
||||
statusBefore: expect.objectContaining({
|
||||
status: "running",
|
||||
pid: 4242,
|
||||
serverId: "server-1",
|
||||
desktopManaged: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.logInfo).toHaveBeenCalledWith(
|
||||
"[desktop daemon]",
|
||||
"desktop daemon stop completed",
|
||||
expect.objectContaining({
|
||||
reason: "manual_ipc",
|
||||
cliResult: { action: "stopped" },
|
||||
statusAfter: expect.objectContaining({
|
||||
status: "stopped",
|
||||
serverId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
||||
@@ -239,6 +268,39 @@ describe("daemon-manager commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("records the renderer stop reason when stopping the desktop daemon", async () => {
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
serverId: "server-1",
|
||||
pid: 4242,
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
serverId: "",
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await handlers.stop_desktop_daemon({ reason: "host_remove" });
|
||||
|
||||
expect(mocks.logInfo).toHaveBeenCalledWith(
|
||||
"[desktop daemon]",
|
||||
"desktop daemon stop requested",
|
||||
expect.objectContaining({ reason: "host_remove" }),
|
||||
);
|
||||
expect(mocks.logInfo).toHaveBeenCalledWith(
|
||||
"[desktop daemon]",
|
||||
"desktop daemon stop completed",
|
||||
expect.objectContaining({
|
||||
reason: "host_remove",
|
||||
cliResult: { action: "stopped", reason: "lifecycle_shutdown_rpc" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stale_pid",
|
||||
|
||||
@@ -46,6 +46,19 @@ const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200;
|
||||
|
||||
type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
const DESKTOP_DAEMON_STOP_REASON_VALUES = [
|
||||
"manual_ipc",
|
||||
"settings",
|
||||
"host_remove",
|
||||
"quit",
|
||||
"app_update",
|
||||
"version_mismatch",
|
||||
"restart",
|
||||
] as const;
|
||||
export type DesktopDaemonStopReason = (typeof DESKTOP_DAEMON_STOP_REASON_VALUES)[number];
|
||||
|
||||
const DESKTOP_DAEMON_STOP_REASONS = new Set<string>(DESKTOP_DAEMON_STOP_REASON_VALUES);
|
||||
const DEFAULT_DESKTOP_DAEMON_STOP_REASON: DesktopDaemonStopReason = "manual_ipc";
|
||||
|
||||
export interface DesktopDaemonStatus {
|
||||
serverId: string;
|
||||
@@ -88,6 +101,16 @@ function parseAppUpdateCheckIntent(
|
||||
return args?.intent === "manual" ? "manual" : "automatic";
|
||||
}
|
||||
|
||||
function parseDesktopDaemonStopReason(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): DesktopDaemonStopReason {
|
||||
const reason = args?.reason;
|
||||
if (typeof reason === "string" && DESKTOP_DAEMON_STOP_REASONS.has(reason)) {
|
||||
return reason as DesktopDaemonStopReason;
|
||||
}
|
||||
return DEFAULT_DESKTOP_DAEMON_STOP_REASON;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -112,17 +135,62 @@ export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDesktopDaemonViaCli(): Promise<void> {
|
||||
await runExternalCliJsonCommand([
|
||||
"daemon",
|
||||
"stop",
|
||||
"--json",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--force",
|
||||
"--kill-timeout",
|
||||
"5",
|
||||
]);
|
||||
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
|
||||
return {
|
||||
status: status.status,
|
||||
pid: status.pid,
|
||||
listen: status.listen,
|
||||
serverId: status.serverId || null,
|
||||
version: status.version,
|
||||
desktopManaged: status.desktopManaged,
|
||||
error: status.error,
|
||||
};
|
||||
}
|
||||
|
||||
const DESKTOP_DAEMON_STOP_CLI_ARGS = [
|
||||
"daemon",
|
||||
"stop",
|
||||
"--json",
|
||||
"--timeout",
|
||||
"5",
|
||||
"--force",
|
||||
"--kill-timeout",
|
||||
"5",
|
||||
];
|
||||
|
||||
async function runDesktopDaemonStopViaCli({
|
||||
reason,
|
||||
statusBefore,
|
||||
resolveStatusAfter = false,
|
||||
}: {
|
||||
reason: DesktopDaemonStopReason;
|
||||
statusBefore?: DesktopDaemonStatus | null;
|
||||
resolveStatusAfter?: boolean;
|
||||
}): Promise<{
|
||||
cliResult: unknown;
|
||||
statusAfter: DesktopDaemonStatus | null;
|
||||
}> {
|
||||
logDesktopDaemonLifecycle("desktop daemon stop requested", {
|
||||
reason,
|
||||
statusBefore: statusBefore ? summarizeDesktopDaemonStatus(statusBefore) : null,
|
||||
});
|
||||
|
||||
const cliResult = await runExternalCliJsonCommand(DESKTOP_DAEMON_STOP_CLI_ARGS);
|
||||
const statusAfter = resolveStatusAfter ? await resolveDesktopDaemonStatus() : null;
|
||||
|
||||
logDesktopDaemonLifecycle("desktop daemon stop completed", {
|
||||
reason,
|
||||
cliResult,
|
||||
statusAfter: statusAfter ? summarizeDesktopDaemonStatus(statusAfter) : null,
|
||||
});
|
||||
|
||||
return { cliResult, statusAfter };
|
||||
}
|
||||
|
||||
export async function stopDesktopDaemonViaCli(
|
||||
reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON,
|
||||
): Promise<void> {
|
||||
await runDesktopDaemonStopViaCli({ reason });
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
@@ -317,7 +385,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
appVersion: normalizeVersion(resolveDesktopAppVersion()),
|
||||
daemonVersion: normalizeVersion(current.version),
|
||||
});
|
||||
await stopDesktopDaemon();
|
||||
await stopDesktopDaemon("version_mismatch");
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
@@ -405,17 +473,29 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return pollForRunningDaemon();
|
||||
}
|
||||
|
||||
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
export async function stopDesktopDaemon(
|
||||
reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON,
|
||||
): Promise<DesktopDaemonStatus> {
|
||||
const status = await resolveDesktopDaemonStatus();
|
||||
if (status.status !== "running") return status;
|
||||
if (status.status !== "running") {
|
||||
logDesktopDaemonLifecycle("desktop daemon stop skipped", {
|
||||
reason,
|
||||
statusBefore: summarizeDesktopDaemonStatus(status),
|
||||
});
|
||||
return status;
|
||||
}
|
||||
|
||||
await stopDesktopDaemonViaCli();
|
||||
return await resolveDesktopDaemonStatus();
|
||||
const { statusAfter } = await runDesktopDaemonStopViaCli({
|
||||
reason,
|
||||
statusBefore: status,
|
||||
resolveStatusAfter: true,
|
||||
});
|
||||
return statusAfter ?? (await resolveDesktopDaemonStatus());
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
||||
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
|
||||
await stopDesktopDaemon();
|
||||
await stopDesktopDaemon("restart");
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
@@ -491,7 +571,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
}),
|
||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||
start_desktop_daemon: () => startDaemon(),
|
||||
stop_desktop_daemon: () => stopDesktopDaemon(),
|
||||
stop_desktop_daemon: (args) => stopDesktopDaemon(parseDesktopDaemonStopReason(args)),
|
||||
restart_desktop_daemon: () => restartDaemon(),
|
||||
desktop_daemon_logs: () => getDaemonLogs(),
|
||||
desktop_daemon_pairing: () => getDaemonPairing(),
|
||||
@@ -532,7 +612,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
return downloadAndInstallUpdate(
|
||||
{ currentVersion, releaseChannel: await resolveRequestedReleaseChannel(args) },
|
||||
async () => {
|
||||
await stopDesktopDaemon();
|
||||
await stopDesktopDaemon("app_update");
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -755,7 +755,7 @@ app.on(
|
||||
stopDesktopManagedDaemonOnQuitIfNeeded({
|
||||
settingsStore: getDesktopSettingsStore(),
|
||||
isDesktopManagedDaemonRunning: isDesktopManagedDaemonRunningSync,
|
||||
stopDaemon: stopDesktopDaemonViaCli,
|
||||
stopDaemon: () => stopDesktopDaemonViaCli("quit"),
|
||||
showShutdownFeedback: showDaemonShutdownDialog,
|
||||
}),
|
||||
onStopError: (error) => {
|
||||
|
||||
@@ -226,6 +226,47 @@ describe("shared messages attachments", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known text attachment context kinds and ignores future ones", () => {
|
||||
const parsed = SendAgentMessageRequestSchema.parse({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-text-context",
|
||||
agentId: "agent-1",
|
||||
text: "Continue",
|
||||
attachments: [
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Earlier context",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "future_context",
|
||||
title: "Future context",
|
||||
text: "Future client hint",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(parsed.attachments).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Earlier context",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Future context",
|
||||
text: "Future client hint",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
|
||||
const parsed = CreatePaseoWorktreeRequestSchema.parse({
|
||||
type: "create_paseo_worktree_request",
|
||||
|
||||
@@ -843,12 +843,18 @@ export const GitHubIssueAttachmentSchema = z.object({
|
||||
body: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const TextAttachmentSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
mimeType: z.literal("text/plain"),
|
||||
title: z.string().nullable().optional(),
|
||||
text: z.string(),
|
||||
});
|
||||
export const TextAttachmentSchema = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
mimeType: z.literal("text/plain"),
|
||||
contextKind: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
text: z.string(),
|
||||
})
|
||||
.transform(({ contextKind, ...attachment }) => ({
|
||||
...attachment,
|
||||
...(contextKind === "chat_history" ? { contextKind } : {}),
|
||||
}));
|
||||
|
||||
export const ReviewAttachmentContextLineSchema = z.object({
|
||||
oldLineNumber: z.number().int().positive().nullable(),
|
||||
@@ -1274,6 +1280,13 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
|
||||
projection: z.enum(["projected", "canonical"]).optional(),
|
||||
});
|
||||
|
||||
export const AgentForkContextRequestMessageSchema = z.object({
|
||||
type: z.literal("agent.fork_context.request"),
|
||||
agentId: z.string(),
|
||||
boundaryMessageId: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SetAgentModeRequestMessageSchema = z.object({
|
||||
type: z.literal("set_agent_mode_request"),
|
||||
agentId: z.string(),
|
||||
@@ -2060,6 +2073,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
RestartServerRequestMessageSchema,
|
||||
DaemonUpdateRequestMessageSchema,
|
||||
FetchAgentTimelineRequestMessageSchema,
|
||||
AgentForkContextRequestMessageSchema,
|
||||
SetAgentModeRequestMessageSchema,
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
@@ -2335,6 +2349,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
daemonDiagnostics: z.boolean().optional(),
|
||||
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
|
||||
daemonSelfUpdate: z.boolean().optional(),
|
||||
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
|
||||
agentForkContext: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2926,6 +2942,18 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentForkContextResponseMessageSchema = z.object({
|
||||
type: z.literal("agent.fork_context.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
attachment: TextAttachmentSchema.nullable(),
|
||||
itemCount: z.number().int().nonnegative(),
|
||||
boundaryMessageId: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CancelAgentResponseMessageSchema = z.object({
|
||||
type: z.literal("cancel_agent_response"),
|
||||
payload: z.object({
|
||||
@@ -4158,6 +4186,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ArchiveWorkspaceResponseMessageSchema,
|
||||
FetchAgentResponseMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
AgentForkContextResponseMessageSchema,
|
||||
CancelAgentResponseMessageSchema,
|
||||
ClearAgentAttentionResponseMessageSchema,
|
||||
WorkspaceCreateResponseSchema,
|
||||
@@ -4320,6 +4349,7 @@ export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessage
|
||||
export type FetchAgentTimelineResponseMessage = z.infer<
|
||||
typeof FetchAgentTimelineResponseMessageSchema
|
||||
>;
|
||||
export type AgentForkContextResponseMessage = z.infer<typeof AgentForkContextResponseMessageSchema>;
|
||||
export type CancelAgentResponseMessage = z.infer<typeof CancelAgentResponseMessageSchema>;
|
||||
export type SendAgentMessageResponseMessage = z.infer<typeof SendAgentMessageResponseMessageSchema>;
|
||||
export type SetVoiceModeResponseMessage = z.infer<typeof SetVoiceModeResponseMessageSchema>;
|
||||
@@ -4410,6 +4440,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
|
||||
>;
|
||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
|
||||
export type DictationStreamStartMessage = z.infer<typeof DictationStreamStartMessageSchema>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user