Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-29 20:24:57 +02:00
156 changed files with 5976 additions and 1189 deletions
+12 -2
View File
@@ -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,
+4 -2
View File
@@ -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;
}
+2 -4
View File
@@ -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. */
+3 -3
View File
@@ -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]);
},
);
});
+97 -11
View File
@@ -68,18 +68,48 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantIndexInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
}): number | null {
for (
let index = input.startIndex;
index >= 0 && index < input.items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = input.items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return index;
}
}
return null;
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
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;
}
+25 -1
View File
@@ -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>
);
+191 -8
View File
@@ -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;
+4
View File
@@ -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} />
);
+13
View File
@@ -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;
}
@@ -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]);
}
+20 -72
View File
@@ -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>
);
}
+97 -95
View File
@@ -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;
+6
View File
@@ -245,6 +245,12 @@ export const ar: TranslationResources = {
copyCode: "نسخ الرمز",
copyTurn: "نسخ بدوره",
copyMessage: "انسخ الرسالة",
forkMenu: "تفريع الرسالة",
forkInNewTab: "تفريع في تبويب جديد",
forkInNewWorkspace: "تفريع في مساحة عمل جديدة",
forkUnavailable: "حدّث المضيف لاستخدام هذا.",
forkMissingWorkspace: "هذا الوكيل ليس في مساحة عمل.",
forkFailed: "فشل تفريع المحادثة",
openFile: "افتح الملف",
copied: "منقول",
},
+6
View File
@@ -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",
},
+6
View File
@@ -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",
},
+6
View File
@@ -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é",
},
+6
View File
@@ -248,6 +248,12 @@ export const ja: TranslationResources = {
copyCode: "コードをコピー",
copyTurn: "ターンをコピー",
copyMessage: "メッセージをコピー",
forkMenu: "メッセージをフォーク",
forkInNewTab: "新しいタブにフォーク",
forkInNewWorkspace: "新しいワークスペースにフォーク",
forkUnavailable: "これを使用するにはホストを更新してください。",
forkMissingWorkspace: "このエージェントはワークスペース内にありません。",
forkFailed: "チャットのフォークに失敗しました",
openFile: "ファイルを開く",
copied: "コピーしました",
},
+6
View File
@@ -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",
},
+6
View File
@@ -247,6 +247,12 @@ export const ru: TranslationResources = {
copyCode: "Скопировать код",
copyTurn: "Копировать ход",
copyMessage: "Копировать сообщение",
forkMenu: "Форкнуть сообщение",
forkInNewTab: "Форкнуть в новой вкладке",
forkInNewWorkspace: "Форкнуть в новом рабочем пространстве",
forkUnavailable: "Обновите хост, чтобы использовать это.",
forkMissingWorkspace: "Этот агент не находится в рабочем пространстве.",
forkFailed: "Не удалось форкнуть чат",
openFile: "Открыть файл",
copied: "Скопировано",
},
+6
View File
@@ -245,6 +245,12 @@ export const zhCN: TranslationResources = {
copyCode: "复制代码",
copyTurn: "复制回合",
copyMessage: "复制消息",
forkMenu: "分叉消息",
forkInNewTab: "分叉到新标签页",
forkInNewWorkspace: "分叉到新工作区",
forkUnavailable: "请更新主机以使用此功能。",
forkMissingWorkspace: "此 Agent 不在工作区中。",
forkFailed: "分叉聊天失败",
openFile: "打开文件",
copied: "已复制",
},
+37 -22
View File
@@ -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);
}
+225 -22
View File
@@ -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 =
"![Image](/tmp/paseo-attachments/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png)";
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: [
+28 -7
View File
@@ -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();
}
+11 -2
View File
@@ -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", () => {
+4
View File
@@ -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 -2
View File
@@ -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);
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+15 -4
View File
@@ -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) {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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}`);
}
+4 -1
View File
@@ -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;
+13 -3
View File
@@ -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();
+19 -1
View File
@@ -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),
};
}
+5 -1
View File
@@ -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 },
+9 -2
View File
@@ -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,
+4
View File
@@ -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,
+27 -13
View File
@@ -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);
+1 -1
View File
@@ -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 = {
+1 -1
View File
@@ -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 = {
+4
View File
@@ -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}`,
+632 -16
View File
@@ -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
+1 -1
View File
@@ -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",
+99 -19
View File
@@ -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");
},
);
},
+1 -1
View File
@@ -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",
+37 -6
View File
@@ -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