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
+2
View File
@@ -169,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
+2
View File
@@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
## Resume behavior
+1 -1
View File
@@ -1 +1 @@
sha256-Q8PQXknQerl0SOejZ+q/uCSqQwH541AHJ4bzu5Dap+A=
sha256-4fS/faM5nUjfRnc0UznCisNAby2fiNg0ROklW0FyptY=
+129 -129
View File
@@ -109,134 +109,6 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.181.tgz",
"integrity": "sha512-xT2pXqqvCSmTwO5zctIxL4dO9vX7+rKeybvLBuEvUxub8sDyScK8s1dVstxL4ZtHQzOIDUng43qEq/kefNp+aA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.181",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.181"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.181.tgz",
"integrity": "sha512-krU5aV651tfv0IeLXo2m+YYduOdirQ+/ptv4IVXivsrjtBXHrtTTLrOaBEFuRQSLqCAMaTH3cITR+mj6ij4kMA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.181.tgz",
"integrity": "sha512-kD+eMhGu2GA1/itW1LDYYELTNnQCre5C1RpekdEtQRP1iXWt6qQ+E42RJmMutBGVLGAl1cqRXIxTHXbuOWsecQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.181.tgz",
"integrity": "sha512-DgrafZ4bfZN7mPOLraEzraU2A263K9Kg01i3PBSH4Mx5CfTUbJ6IVR1cYgwRCk2c4BwQmS0onnvxfgwAxP0dKg==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.181.tgz",
"integrity": "sha512-lI39OgTCqKAqTtNF3a6+z4ToS2QKLWQw0oZGSGCMb2k6MrPB2OqyN/JSH6nvKfxLpaQmVTowwpyR4JInHMzobw==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.181.tgz",
"integrity": "sha512-KKYcN2PKHPUOPKKWsPY1bEEkeVMiOmTRpzIleGlCPsXJLS/QnN6RgNOHIrapRmh0z9uFl24KCP5X1OUMWZqu4Q==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.181.tgz",
"integrity": "sha512-FNf6QhAZ8/7q9MfFTCEfc0nA6oOSrEyKq3Xv2Jt4iSEViXYuIC25shk4T4EvU8qoLtbS51NCxCdpD7b0zCHvFQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.181.tgz",
"integrity": "sha512-7plhWEB9/ExlzTMY9SzUy6zSoCrMP4KFBJwfmkt0plxwv8lFJqX/tEH/r4QDQxrMQnR+d6RcbgPH6g3hnu3NMQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.181.tgz",
"integrity": "sha512-CQKaMv5tYBUxtFe1XG2qhnNJrmChXf+nhi5FhbE3GElWeNaXKMOTYqflii3zOT3yhqnrbh7fz1gzhOFwALtCeA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.104.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
@@ -38147,7 +38019,7 @@
"version": "0.1.102-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.1",
"@getpaseo/highlight": "0.1.102-beta.1",
@@ -38195,6 +38067,134 @@
"vitest": "^4.1.6"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz",
"integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz",
"integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz",
"integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz",
"integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz",
"integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz",
"integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz",
"integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz",
"integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz",
"integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
+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",

Some files were not shown because too many files have changed in this diff Show More