Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 9e802e92cd refactor(app): extract composer-actions module with pure tests
Move composer cancel/queue/attachment/dispatch orchestration out of
composer.tsx into a React-free actions module. The module takes its
dependencies (send client, queue writer, stream writer, attachment
persister, image picker) as injected ports, so the new
composer-actions.test.ts can drive every action with inline fakes —
zero vi.mock of internal modules, zero JSDOM, zero React.

The old composer.test.tsx (heavy mocked component test) is removed
and replaced with composer-actions.test.ts (31 unit tests).

Also extract isWorkspaceAttachment / userAttachmentsOnly /
workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx
into a sibling .ts so the actions module (and composer-attachments.ts)
can use them without dragging React/RN/lucide into a pure test graph.
2026-05-04 22:25:59 +07:00
28 changed files with 2008 additions and 2482 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
+1 -7
View File
@@ -1,13 +1,12 @@
import { test as base, expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
// reliably for every test that uses this `test` object.
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
const test = base.extend<{ paseoE2ESetup: void }>({
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
@@ -103,11 +102,6 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
},
{ auto: true },
],
withWorkspace: async ({ page }, provide) => {
const handle = createWithWorkspace(page);
await provide(handle.withWorkspace);
await handle.cleanup();
},
});
export { test, expect, type Page };
-17
View File
@@ -1,17 +0,0 @@
import { expect, type Page } from "@playwright/test";
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
const messages = page.getByTestId("assistant-message");
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
await expect(target).toBeVisible({ timeout: 30_000 });
}
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
).toBeVisible({ timeout: 30_000 });
}
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
}
+17
View File
@@ -658,6 +658,23 @@ export const createAgentInRepo = async (
await createAgent(page, config.prompt);
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId("permission-request-question").first();
await expect(promptText).toBeVisible({ timeout });
};
export const allowPermission = async (page: Page) => {
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5000 });
await acceptButton.click();
};
export const denyPermission = async (page: Page) => {
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5000 });
await denyButton.click();
};
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
// Wait for the stop button to disappear
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
-18
View File
@@ -1,18 +0,0 @@
import { expect, type Page } from "@playwright/test";
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(text);
await input.press("Enter");
}
export async function cancelAgent(page: Page): Promise<void> {
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stopButton).toBeVisible({ timeout: 10_000 });
await stopButton.click();
}
-17
View File
@@ -1,17 +0,0 @@
import { expect, type Page } from "@playwright/test";
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
}
export async function allowPermission(page: Page): Promise<void> {
const acceptButton = page.getByTestId("permission-request-accept").first();
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
await acceptButton.click();
}
export async function denyPermission(page: Page): Promise<void> {
const denyButton = page.getByTestId("permission-request-deny").first();
await expect(denyButton).toBeVisible({ timeout: 5_000 });
await denyButton.click();
}
-52
View File
@@ -1,52 +0,0 @@
import { expect, type Page } from "@playwright/test";
const SECTION_LABELS = {
general: "General",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
diagnostics: "Diagnostics",
about: "About",
} as const;
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
if (section === "projects") {
await page.getByTestId("settings-projects").click();
await expect(page).toHaveURL(/\/settings\/projects$/);
return;
}
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
}
export async function openAddHostFlow(page: Page): Promise<void> {
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
}
export async function selectHostConnectionType(
page: Page,
type: "direct" | "relay",
): Promise<void> {
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
await page.getByRole("button", { name: label }).click();
}
export async function toggleHostAdvanced(page: Page): Promise<void> {
await page.getByTestId("direct-host-advanced-toggle").click();
}
-21
View File
@@ -1,21 +0,0 @@
import { expect, type Page } from "@playwright/test";
function requireServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
await expect(
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
).toBeVisible({ timeout: 30_000 });
}
-105
View File
@@ -1,105 +0,0 @@
import { execSync } from "node:child_process";
import { realpath } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { waitForTabBar } from "./launcher";
import { selectWorkspaceInSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
type WorkspaceSetupDaemonClient,
} from "./workspace-setup";
export interface CreatedWorkspace {
workspaceId: string;
repoPath: string;
navigateTo(): Promise<void>;
}
export interface WithWorkspaceOptions {
worktree?: boolean;
prefix?: string;
}
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
interface WorktreeRecord {
repoPath: string;
worktreePath: string;
}
export interface WithWorkspaceHandle {
withWorkspace: WithWorkspace;
cleanup: () => Promise<void>;
}
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
let client: WorkspaceSetupDaemonClient | null = null;
const repos: Array<{ cleanup: () => Promise<void> }> = [];
const worktrees: WorktreeRecord[] = [];
const withWorkspace: WithWorkspace = async (options) => {
if (!client) {
client = await connectWorkspaceSetupClient();
}
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
const repo = await createTempGitRepo(prefix);
repos.push(repo);
let workspacePath = repo.path;
if (options?.worktree) {
const tempRoot = await realpath("/tmp");
workspacePath = path.join(
tempRoot,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `paseo-wt-${Date.now()}`;
execSync(
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
{ cwd: repo.path, stdio: "ignore" },
);
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
// Register the parent project so the sidebar lists it before we navigate.
await client.openProject(repo.path);
}
const opened = await client.openProject(workspacePath);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
}
const workspaceId = opened.workspace.id;
return {
workspaceId,
repoPath: workspacePath,
navigateTo: async () => {
await openHomeWithProject(page, repo.path);
await selectWorkspaceInSidebar(page, workspaceId);
await waitForTabBar(page);
},
};
};
return {
withWorkspace,
cleanup: async () => {
for (const { repoPath, worktreePath } of worktrees) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repoPath,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so the original test failure is preserved.
}
}
for (const repo of repos) {
await repo.cleanup();
}
if (client) {
await client.close().catch(() => undefined);
}
},
};
}
+9 -5
View File
@@ -1,7 +1,6 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
import { expectSettingsHeader, openSettingsHost } from "./helpers/settings";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
@@ -19,8 +18,13 @@ function getSeededDaemonPort(): string {
return port;
}
async function openHostPage(page: Page, serverId: string) {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
async function expectHostLabelHeader(page: Page) {
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
@@ -34,7 +38,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostPage(page, serverId);
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expectHostLabelHeader(page);
@@ -63,7 +67,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostPage(page, serverId);
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
@@ -81,7 +85,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostPage(page, serverId);
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
// Pair-device now lives behind a row that only the local host sees
+112 -27
View File
@@ -1,44 +1,129 @@
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { clickTerminal } from "./helpers/launcher";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({
page,
withWorkspace,
}) => {
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
await workspace.navigateTo();
await clickTerminal(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-main-");
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
// Use sidebar navigation to avoid Expo Router hydration issues
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({
page,
withWorkspace,
}) => {
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
await workspace.navigateTo();
await clickTerminal(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-worktree-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `workspace-cwd-${Date.now()}`;
let worktreeCreated = false;
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
try {
await seedProjectForWorkspaceSetup(client, repo.path);
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
// Use sidebar navigation to avoid Expo Router hydration issues
// with direct URL navigation to the 2nd+ workspace.
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
+167 -19
View File
@@ -1,9 +1,39 @@
import { test } from "./fixtures";
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
} from "./helpers/workspace-lifecycle";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace lifecycle", () => {
// The first test after a spec-file switch can intermittently fail because
@@ -12,36 +42,154 @@ test.describe("Workspace lifecycle", () => {
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
await workspace.navigateTo();
await createAgentChatFromLauncher(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-chat-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(60_000);
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-shell-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
await workspace.navigateTo();
await createAgentChatFromLauncher(page);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-chat-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-chat-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
await workspace.navigateTo();
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, workspace.repoPath);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-shell-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-shell-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = workspaceResult.workspace.id;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, worktreePath);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
});
@@ -9,8 +9,12 @@ import type {
} from "@/attachments/types";
import { AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { AgentAttachment } from "@server/shared/messages";
import { useClearReviewDraft } from "@/review/store";
interface WorkspaceAttachmentBindingInput {
@@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
});
}
function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
return (
<WorkspaceAttachmentPill
@@ -303,7 +282,7 @@ function WorkspaceAttachmentPill({
export const composerWorkspaceAttachment = {
is: isWorkspaceAttachment,
renderPill,
toSubmitAttachment,
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
@@ -0,0 +1,35 @@
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { AgentAttachment } from "@server/shared/messages";
export function isWorkspaceAttachment(
attachment: ComposerAttachment | undefined,
): attachment is WorkspaceComposerAttachment {
return attachment?.kind === "review" || attachment?.kind === "browser_element";
}
export function userAttachmentsOnly(
attachments: readonly ComposerAttachment[],
): UserComposerAttachment[] {
return attachments.filter(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" && attachment.kind !== "browser_element",
);
}
export function workspaceAttachmentToSubmitAttachment(
attachment: ComposerAttachment,
): AgentAttachment | null {
if (attachment.kind === "browser_element") {
return {
type: "text",
mimeType: "text/plain",
title: `Browser element · ${attachment.attachment.tag}`,
text: attachment.attachment.formatted,
};
}
return attachment.kind === "review" ? attachment.attachment : null;
}
@@ -0,0 +1,715 @@
import { describe, expect, it } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { StreamItem } from "@/types/stream";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
editQueuedComposerMessage,
findGithubItemByOption,
isAttachmentSelectedForGithubItem,
openComposerAttachment,
pickAndPersistImages,
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
type AgentStreamWriter,
type AttachmentPersister,
type ComposerCancelClient,
type ComposerSendClient,
type QueueWriter,
type QueuedComposerMessage,
} from "./composer-actions";
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "img-1",
fileName: "img-1.png",
byteSize: 42,
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
url: "https://github.com/acme/paseo/issues/101",
state: "open",
body: "Issue body",
labels: ["composer"],
baseRefName: null,
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
state: "open",
body: "PR body",
labels: ["composer"],
baseRefName: "main",
headRefName: "composer-attachments",
};
function imageWithId(id: string): AttachmentMetadata {
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
}
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
const attachment: Extract<AgentAttachment, { type: "review" }> = {
type: "review",
mimeType: "application/paseo-review",
cwd: "/repo",
mode: "uncommitted",
baseRef: null,
comments: [
{
filePath: "src/example.ts",
side: "new",
lineNumber: 41,
body,
context: {
hunkHeader: "@@ -40,2 +40,2 @@",
targetLine: {
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
lines: [
{
oldLineNumber: null,
newLineNumber: 41,
type: "add",
content: "const value = newValue;",
},
],
},
},
],
};
return {
kind: "review",
reviewDraftKey: `review:${body}`,
commentCount: 1,
attachment,
};
}
function browserElementWorkspaceAttachment(): Extract<
WorkspaceComposerAttachment,
{ kind: "browser_element" }
> {
return {
kind: "browser_element",
attachment: {
url: "https://example.com/page",
selector: "button.primary",
tag: "button",
text: "Save",
outerHTML: '<button class="primary">Save</button>',
computedStyles: { display: "flex" },
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
reactSource: null,
parentChain: ["form.settings"],
children: [],
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
},
};
}
function createFakePersister(): AttachmentPersister & {
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
deletedBatches: AttachmentMetadata[][];
} {
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
const deletedBatches: AttachmentMetadata[][] = [];
return {
blobCalls,
fileUriCalls,
deletedBatches,
persistFromBlob: async ({ blob, mimeType, fileName }) => {
blobCalls.push({ blob, mimeType, fileName });
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
},
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
fileUriCalls.push({ uri, mimeType, fileName });
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
},
deleteAttachments: (metadata) => {
deletedBatches.push(metadata);
},
};
}
interface FakeSendCall {
agentId: string;
text: string;
options: {
messageId: string;
images: Array<{ data: string; mimeType: string }>;
attachments: AgentAttachment[];
};
}
function createFakeSendClient(
options: { rejection?: Error } = {},
): ComposerSendClient & { calls: FakeSendCall[] } {
const calls: FakeSendCall[] = [];
return {
calls,
sendAgentMessage: async (agentId, text, opts) => {
calls.push({ agentId, text, options: opts });
if (options.rejection) {
throw options.rejection;
}
},
};
}
interface FakeStream extends AgentStreamWriter {
head: Map<string, StreamItem[]>;
tail: Map<string, StreamItem[]>;
}
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
},
setTail: (updater) => {
fake.tail = updater(fake.tail);
},
};
return fake;
}
function createFakeQueue(
initial: Map<string, QueuedComposerMessage[]> = new Map(),
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
state: new Map(initial),
read: (agentId) => fake.state.get(agentId) ?? [],
write: (updater) => {
fake.state = updater(fake.state);
},
};
return fake;
}
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
describe("cancelComposerAgent", () => {
function baseInput(): {
client: ComposerCancelClient & { canceledIds: string[] };
agentId: string;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
} {
const canceledIds: string[] = [];
return {
client: {
canceledIds,
cancelAgent: async (id) => {
canceledIds.push(id);
},
},
agentId: "agent",
isAgentRunning: true,
isCancellingAgent: false,
isConnected: true,
};
}
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
const input = baseInput();
const result = cancelComposerAgent(input);
expect(result).toBe(true);
expect(input.client.canceledIds).toEqual(["agent"]);
});
it("does nothing when the agent is not running", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
expect(result).toBe(false);
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when the agent is already being canceled", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
expect(result).toBe(false);
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when disconnected or the client is null", () => {
const input = baseInput();
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
expect(input.client.canceledIds).toEqual([]);
});
});
describe("pickAndPersistImages", () => {
it("returns [] when the picker yields nothing", async () => {
const persister = createFakePersister();
const result = await pickAndPersistImages({
pickImages: async () => null,
persister,
});
expect(result).toEqual([]);
expect(persister.blobCalls).toEqual([]);
expect(persister.fileUriCalls).toEqual([]);
});
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
const persister = createFakePersister();
const blob = new Blob(["image"]);
const result = await pickAndPersistImages({
pickImages: async () => [
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
],
persister,
});
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
});
it("persists file_uri sources via persistFromFileUri", async () => {
const persister = createFakePersister();
const result = await pickAndPersistImages({
pickImages: async () => [
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
],
persister,
});
expect(persister.fileUriCalls).toEqual([
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
]);
expect(result).toHaveLength(1);
});
});
describe("dispatchComposerAgentMessage", () => {
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const image = imageWithId("img-2");
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "send attachments",
attachments: [
{ kind: "image", metadata: image },
{ kind: "github_pr", item: prItem },
],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls).toHaveLength(1);
const [call] = client.calls;
expect(call.agentId).toBe("agent");
expect(call.text).toBe("send attachments");
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
expect(call.options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
]);
expect(stream.head.get("agent")).toBeUndefined();
const tail = stream.tail.get("agent");
expect(tail).toHaveLength(1);
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
expect(userMessage.kind).toBe("user_message");
expect(userMessage.text).toBe("send attachments");
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
});
it("appends to the existing head when one is present", async () => {
const existingItem: StreamItem = {
kind: "user_message",
id: "prior",
text: "prior",
timestamp: new Date(0),
};
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
const client = createFakeSendClient();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "next message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
});
expect(stream.head.get("agent")).toHaveLength(2);
expect(stream.tail.get("agent")).toBeUndefined();
});
it("submits empty wire arrays when no attachments are provided", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "plain message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0]?.options).toMatchObject({
images: [],
attachments: [],
});
});
it("serializes workspace review attachments through the structured attachment path", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const review = reviewWorkspaceAttachment("Please simplify this.");
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "review this",
attachments: [review],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
expect(client.calls[0]?.options.images).toEqual([]);
});
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const browserElement = browserElementWorkspaceAttachment();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "inspect element",
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0]?.options.attachments).toEqual([
{
type: "text",
mimeType: "text/plain",
title: "Browser element · button",
text: browserElement.attachment.formatted,
},
]);
});
});
describe("queueComposerMessage", () => {
it("queues a trimmed message under the agent id and returns the new entry", () => {
const queue = createFakeQueue();
const result = queueComposerMessage({
agentId: "agent",
text: " draft ",
attachments: [],
queue,
});
expect(result.queued?.text).toBe("draft");
expect(queue.state.get("agent")).toEqual([
{ id: result.queued?.id, text: "draft", attachments: [] },
]);
});
it("does not queue an empty message with no attachments", () => {
const queue = createFakeQueue();
const result = queueComposerMessage({
agentId: "agent",
text: " ",
attachments: [],
queue,
});
expect(result.queued).toBeNull();
expect(queue.state.get("agent")).toBeUndefined();
});
it("captures workspace review attachments at queue time alongside user attachments", () => {
const queue = createFakeQueue();
const review = reviewWorkspaceAttachment("Initial queued review.");
const image = imageWithId("img-queue");
queueComposerMessage({
agentId: "agent",
text: "queue this",
attachments: [{ kind: "image", metadata: image }, review],
queue,
});
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
{ kind: "image", metadata: image },
review,
]);
});
});
describe("editQueuedComposerMessage", () => {
it("returns null and leaves the queue untouched when the message id is missing", () => {
const queue = createFakeQueue(
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
);
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
expect(result).toBeNull();
expect(queue.state.get("agent")).toHaveLength(1);
});
it("returns the text and only user attachments, removing the queued entry", () => {
const review = reviewWorkspaceAttachment("Queued snapshot.");
const image = imageWithId("img-queued-edit");
const queue = createFakeQueue(
new Map([
[
"agent",
[
{
id: "msg-1",
text: "queued draft",
attachments: [{ kind: "image", metadata: image }, review],
},
],
],
]),
);
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
expect(result).toEqual({
text: "queued draft",
attachments: [{ kind: "image", metadata: image }],
});
expect(queue.state.get("agent")).toEqual([]);
});
});
describe("sendQueuedComposerMessageNow", () => {
it("returns missing without submitting when the message id is gone", async () => {
const queue = createFakeQueue();
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "missing" });
expect(submitted).toEqual([]);
});
it("removes the queued entry and submits its text + attachments", async () => {
const review = reviewWorkspaceAttachment("Queued for send.");
const queue = createFakeQueue(
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
);
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "submitted" });
expect(queue.state.get("agent")).toEqual([]);
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
const queue = createFakeQueue(
new Map([
[
"agent",
[
{ id: "msg-1", text: "first", attachments: [] },
{ id: "msg-2", text: "second", attachments: [] },
],
],
]),
);
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
messageId: "msg-1",
queue,
submitMessage: async () => {
throw new Error("network down");
},
});
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
const state = queue.state.get("agent");
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
});
});
describe("removeComposerAttachmentAtIndex", () => {
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
const image = imageWithId("img-remove");
const persister = createFakePersister();
const next = removeComposerAttachmentAtIndex({
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
index: 0,
deleteAttachments: persister.deleteAttachments,
});
expect(next).toEqual([]);
expect(persister.deletedBatches).toEqual([[image]]);
});
it("removes a github attachment without scheduling any storage deletes", () => {
const persister = createFakePersister();
const next = removeComposerAttachmentAtIndex({
attachments: [
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
] satisfies UserComposerAttachment[],
index: 0,
deleteAttachments: persister.deleteAttachments,
});
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
expect(persister.deletedBatches).toEqual([]);
});
});
describe("openComposerAttachment", () => {
it("opens the lightbox for image attachments", () => {
const image = imageWithId("img-body");
const lightboxCalls: AttachmentMetadata[] = [];
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: { kind: "image", metadata: image },
setLightboxMetadata: (metadata) => {
lightboxCalls.push(metadata);
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(lightboxCalls).toEqual([image]);
expect(externalUrlCalls).toEqual([]);
});
it("delegates workspace review attachments to the workspace opener", () => {
const review = reviewWorkspaceAttachment("Open me.");
const workspaceCalls: ComposerAttachment[] = [];
openComposerAttachment({
attachment: review,
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: ({ attachment }) => {
workspaceCalls.push(attachment);
return true;
},
openExternalUrl: () => {
throw new Error("unexpected external url call");
},
});
expect(workspaceCalls).toEqual([review]);
});
it("opens GitHub item URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: { kind: "github_issue", item: issueItem },
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
});
describe("toggleGithubAttachment", () => {
it("appends a GitHub issue when not already attached", () => {
const next = toggleGithubAttachment([], issueItem);
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
});
it("appends a GitHub PR when not already attached", () => {
const next = toggleGithubAttachment([], prItem);
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("removes an existing GitHub item with the same kind+number", () => {
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
expect(next).toEqual([]);
});
it("does not affect other items with different kind or number", () => {
const start: UserComposerAttachment[] = [
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
];
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
const next = toggleGithubAttachment(start, otherIssue);
expect(next).toEqual([
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
{ kind: "github_issue", item: otherIssue },
]);
});
});
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
});
it("recognizes when an attachment list already contains a matching GitHub item", () => {
const attachments: ComposerAttachment[] = [
{ kind: "image", metadata: imageWithId("img-x") },
{ kind: "github_issue", item: issueItem },
reviewWorkspaceAttachment("ignored"),
];
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
});
});
@@ -0,0 +1,325 @@
import type { GitHubSearchItem } from "@server/shared/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { generateMessageId, type StreamItem } from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
export interface QueuedComposerMessage {
id: string;
text: string;
attachments: ComposerAttachment[];
}
export interface AttachmentPersister {
persistFromBlob: (input: {
blob: Blob;
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
persistFromFileUri: (input: {
uri: string;
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
}
export interface ComposerSendClient {
sendAgentMessage: (
agentId: string,
text: string,
options: {
messageId: string;
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
}
export interface ComposerCancelClient {
cancelAgent: (agentId: string) => Promise<void> | void;
}
export interface AgentStreamWriter {
getHead: (agentId: string) => StreamItem[] | undefined;
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
}
export interface QueueWriter {
read: (agentId: string) => QueuedComposerMessage[];
write: (
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
) => void;
}
export async function pickAndPersistImages(input: {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
}): Promise<AttachmentMetadata[]> {
const result = await input.pickImages();
if (!result?.length) return [];
return await Promise.all(
result.map(async (picked) => {
const fileName = picked.fileName ?? null;
const mimeType = picked.mimeType || "image/jpeg";
if (picked.source.kind === "blob") {
return await input.persister.persistFromBlob({
blob: picked.source.blob,
mimeType,
fileName,
});
}
return await input.persister.persistFromFileUri({
uri: picked.source.uri,
mimeType,
fileName,
});
}),
);
}
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
attachments: T[];
index: number;
deleteAttachments: AttachmentPersister["deleteAttachments"];
}): T[] {
const removed = input.attachments[input.index];
if (removed?.kind === "image") {
void input.deleteAttachments([removed.metadata]);
}
return input.attachments.filter((_, i) => i !== input.index);
}
export interface CancelComposerAgentInput {
client: ComposerCancelClient | null;
agentId: string;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
if (!input.isAgentRunning || input.isCancellingAgent) return false;
if (!input.isConnected || !input.client) return false;
void input.client.cancelAgent(input.agentId);
return true;
}
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
text: string;
attachments: ComposerAttachment[];
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
stream: AgentStreamWriter;
}
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const messageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
text: input.text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream(input.agentId, userMessage, input.stream);
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
}
function appendUserMessageToStream(
agentId: string,
userMessage: StreamItem,
stream: AgentStreamWriter,
): void {
const head = stream.getHead(agentId);
if (head && head.length > 0) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
return;
}
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
}
export interface QueueComposerMessageInput {
agentId: string;
text: string;
attachments: ComposerAttachment[];
queue: QueueWriter;
}
export interface QueueComposerMessageResult {
queued: QueuedComposerMessage | null;
}
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
const trimmed = input.text.trim();
if (!trimmed && input.attachments.length === 0) {
return { queued: null };
}
const item: QueuedComposerMessage = {
id: generateMessageId(),
text: trimmed,
attachments: input.attachments,
};
input.queue.write((prev) => {
const next = new Map(prev);
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
return next;
});
return { queued: item };
}
export interface EditQueuedComposerMessageInput {
agentId: string;
messageId: string;
queue: QueueWriter;
}
export interface EditQueuedComposerMessageResult {
text: string;
attachments: UserComposerAttachment[];
}
export function editQueuedComposerMessage(
input: EditQueuedComposerMessageInput,
): EditQueuedComposerMessageResult | null {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return null;
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
input.agentId,
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
);
return next;
});
return {
text: item.text,
attachments: userAttachmentsOnly(item.attachments),
};
}
export interface SendQueuedComposerMessageNowInput {
agentId: string;
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
}
export type SendQueuedComposerMessageNowResult =
| { status: "missing" }
| { status: "submitted" }
| { status: "failed"; errorMessage: string };
export async function sendQueuedComposerMessageNow(
input: SendQueuedComposerMessageNowInput,
): Promise<SendQueuedComposerMessageNowResult> {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return { status: "missing" };
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
input.agentId,
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
);
return next;
});
try {
await input.submitMessage({ text: item.text, attachments: item.attachments });
return { status: "submitted" };
} catch (error) {
input.queue.write((prev) => {
const next = new Map(prev);
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
return next;
});
return {
status: "failed",
errorMessage: error instanceof Error ? error.message : "Failed to send message",
};
}
}
export interface OpenComposerAttachmentInput {
attachment: ComposerAttachment;
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
openExternalUrl: (url: string) => void;
}
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
if (input.attachment.kind === "image") {
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (isWorkspaceAttachment(input.attachment)) {
input.openWorkspaceAttachment({ attachment: input.attachment });
return;
}
input.openExternalUrl(input.attachment.item.url);
}
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
export function toggleGithubAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
@@ -1,5 +1,8 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import {
isWorkspaceAttachment,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
@@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (composerWorkspaceAttachment.is(attachment)) {
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
if (isWorkspaceAttachment(attachment)) {
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
}
File diff suppressed because it is too large Load Diff
+84 -231
View File
@@ -23,7 +23,6 @@ import {
import Animated from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
AgentStatusBar,
DraftAgentStatusBar,
@@ -31,7 +30,6 @@ import {
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
MessageInput,
@@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
editQueuedComposerMessage,
findGithubItemByOption,
isAttachmentSelectedForGithubItem,
openComposerAttachment,
pickAndPersistImages,
queueComposerMessage,
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachment,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/components/composer-actions";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -79,17 +93,12 @@ import type {
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
interface QueuedMessage {
id: string;
text: string;
attachments: ComposerAttachment[];
}
type QueuedMessage = QueuedComposerMessage;
type AttachmentListUpdater =
| UserComposerAttachment[]
@@ -128,37 +137,6 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
async function pickAndPersistImages(
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
): Promise<ImageAttachment[]> {
const result = await pickImages();
if (!result?.length) return [];
return await Promise.all(
result.map(async (pickedImage) => {
if (pickedImage.source.kind === "blob") {
return await persistAttachmentFromBlob({
blob: pickedImage.source.blob,
mimeType: pickedImage.mimeType || "image/jpeg",
fileName: pickedImage.fileName ?? null,
});
}
return await persistAttachmentFromFileUri({
uri: pickedImage.source.uri,
mimeType: pickedImage.mimeType || "image/jpeg",
fileName: pickedImage.fileName ?? null,
});
}),
);
}
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
const removed = prev[index];
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
}
return prev.filter((_, i) => i !== index);
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
@@ -237,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
}
function findGithubItemByOption(
items: readonly GitHubSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
): boolean {
return composerWorkspaceAttachment
.userAttachmentsOnly(current)
.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function toggleGithubAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
}
interface RenderAttachmentPreviewListArgs {
selectedAttachments: ComposerAttachment[];
isComposerLocked: boolean;
@@ -416,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
});
}
interface DispatchAgentMessageSendArgs {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
serverId: string;
targetAgentId: string;
text: string;
sendAttachments: ComposerAttachment[];
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
}
function appendUserMessageToStream(
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
): void {
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
const currentHead = useSessionStore
.getState()
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
if (currentHead && currentHead.length > 0) {
setAgentStreamHead(serverId, (prev) => {
const head = prev.get(targetAgentId) || [];
const updated = new Map(prev);
updated.set(targetAgentId, [...head, userMessage]);
return updated;
});
return;
}
setAgentStreamTail(serverId, (prev) => {
const currentStream = prev.get(targetAgentId) || [];
const updated = new Map(prev);
updated.set(targetAgentId, [...currentStream, userMessage]);
return updated;
});
}
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
const { client, targetAgentId, text, sendAttachments } = args;
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
const clientMessageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: clientMessageId,
text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
appendUserMessageToStream({ ...args, userMessage });
const imagesData = await encodeImages(wirePayload.images);
await client.sendAgentMessage(targetAgentId, text, {
messageId: clientMessageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
}
function openComposerAttachment(
attachment: ComposerAttachment,
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
): void {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
if (composerWorkspaceAttachment.is(attachment)) {
openWorkspaceAttachment({ attachment });
return;
}
void openExternalUrl(attachment.item.url);
}
interface CancelRunningAgentArgs {
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
client: ReturnType<typeof useHostRuntimeClient>;
agentIdRef: { current: string };
setIsCancellingAgent: (value: boolean) => void;
messageInputRef: { current: MessageInputRef | null };
}
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
const {
isAgentRunning,
isCancellingAgent,
isConnected,
client,
agentIdRef,
setIsCancellingAgent,
messageInputRef,
} = args;
if (!isAgentRunning || isCancellingAgent) return;
if (!isConnected || !client) return;
setIsCancellingAgent(true);
void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus();
}
function focusMessageInputWithPlatformStrategy(messageInputRef: {
current: MessageInputRef | null;
}): void {
@@ -1152,14 +993,18 @@ export function Composer({
if (!client) {
throw new Error("Host is not connected");
}
await dispatchAgentMessageSend({
const stream: AgentStreamWriter = {
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
};
await dispatchComposerAgentMessage({
client,
serverId,
targetAgentId,
agentId: targetAgentId,
text,
sendAttachments,
setAgentStreamHead,
setAgentStreamTail,
attachments: sendAttachments,
encodeImages,
stream,
});
onAttentionPromptSend?.();
};
@@ -1172,33 +1017,23 @@ export function Composer({
const isAgentRunning = agentState.status === "running";
const hasAgent = agentState.status !== null;
const updateQueue = useCallback(
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, updater(prev.get(agentId) ?? []));
return next;
});
},
[agentId, serverId, setQueuedMessages],
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
);
const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
const trimmedMessage = queuedMessage.trim();
if (!trimmedMessage && queuedAttachments.length === 0) return;
const newItem = {
id: generateMessageId(),
text: trimmedMessage,
const result = queueComposerMessage({
agentId,
text: queuedMessage,
attachments: queuedAttachments,
};
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
return next;
queue: queueWriter,
});
if (!result.queued) return;
setUserInput("");
setSelectedAttachments([]);
@@ -1208,9 +1043,8 @@ export function Composer({
[
agentId,
clearSentAttachments,
queueWriter,
resetSuppression,
serverId,
setQueuedMessages,
setSelectedAttachments,
setUserInput,
],
@@ -1284,7 +1118,15 @@ export function Composer({
);
const handlePickImage = useCallback(async () => {
const newImages = await pickAndPersistImages(pickImages);
const newImages = await pickAndPersistImages({
pickImages,
persister: {
persistFromBlob: ({ blob, mimeType, fileName }) =>
persistAttachmentFromBlob({ blob, mimeType, fileName }),
persistFromFileUri: ({ uri, mimeType, fileName }) =>
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
},
});
if (newImages.length === 0) return;
addImages(newImages);
}, [addImages, pickImages]);
@@ -1298,14 +1140,23 @@ export function Composer({
if (didRemoveWorkspaceAttachment) {
return;
}
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
setSelectedAttachments((prev) =>
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
);
},
[removeAttachment, selectedAttachments, setSelectedAttachments],
);
const handleOpenAttachment = useCallback(
(attachment: ComposerAttachment) => {
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
openComposerAttachment({
attachment,
setLightboxMetadata,
openWorkspaceAttachment: openAttachment,
openExternalUrl: (url) => {
void openExternalUrl(url);
},
});
},
[openAttachment],
);
@@ -1317,15 +1168,16 @@ export function Composer({
}, [isAgentRunning, isConnected]);
const handleCancelAgent = useCallback(() => {
cancelRunningAgent({
const didCancel = cancelComposerAgent({
client,
agentId: agentIdRef.current,
isAgentRunning,
isCancellingAgent,
isConnected,
client,
agentIdRef,
setIsCancellingAgent,
messageInputRef,
});
if (!didCancel) return;
setIsCancellingAgent(true);
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
const focusMessageInputForKeyboardAction = useCallback(() => {
@@ -1391,33 +1243,34 @@ export function Composer({
const handleEditQueuedMessage = useCallback(
(id: string) => {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
const result = editQueuedComposerMessage({
agentId,
messageId: id,
queue: queueWriter,
});
if (!result) return;
setUserInput(result.text);
setSelectedAttachments(result.attachments);
},
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
[agentId, queueWriter, setSelectedAttachments, setUserInput],
);
const handleSendQueuedNow = useCallback(
async (id: string) => {
const item = queuedMessages.find((q) => q.id === id);
if (!item) return;
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id));
// Reuse the regular send path; server-side send atomically interrupts any active run.
try {
await submitMessage(item.text, item.attachments);
} catch (error) {
updateQueue((current) => [item, ...current]);
setSendError(error instanceof Error ? error.message : "Failed to send message");
const result = await sendQueuedComposerMessageNow({
agentId,
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
submitMessage(text, queuedAttachments),
});
if (result.status === "failed") {
setSendError(result.errorMessage);
}
},
[queuedMessages, submitMessage, updateQueue],
[agentId, queueWriter, submitMessage],
);
const handleQueue = useCallback(
@@ -0,0 +1,169 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PrPaneData } from "@/utils/pr-pane-data";
import { PrPane } from "./pr-pane";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
colors: {
surfaceSidebar: "#0b0b0b",
surfaceSidebarHover: "#151515",
border: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
statusSuccess: "#30d158",
statusDanger: "#ff453a",
statusWarning: "#f2c945",
statusMerged: "#a371f7",
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
CircleCheck: createIcon("CircleCheck"),
CircleDot: createIcon("CircleDot"),
CircleSlash: createIcon("CircleSlash"),
CircleX: createIcon("CircleX"),
ExternalLink: createIcon("ExternalLink"),
GitMerge: createIcon("GitMerge"),
GitPullRequest: createIcon("GitPullRequest"),
GitPullRequestClosed: createIcon("GitPullRequestClosed"),
GitPullRequestDraft: createIcon("GitPullRequestDraft"),
MessageSquare: createIcon("MessageSquare"),
};
});
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function makeData(overrides: Partial<PrPaneData> = {}): PrPaneData {
return {
number: 1284,
title: "fix(server,app): coalesce tool call stream events",
state: "open",
url: "https://github.com/getpaseo/paseo/pull/1284",
reviewDecision: "changes_requested",
awaitingReviewers: [],
checks: [
{ name: "typecheck", workflow: "CI", status: "success", duration: "1m", url: "#" },
{ name: "test", workflow: "CI", status: "failure", duration: "2m", url: "#" },
],
activity: [
{
kind: "review",
author: "alicek",
avatarColor: "#8b5cf6",
reviewState: "approved",
body: "LGTM",
age: "2h ago",
url: "#",
},
{
kind: "comment",
author: "bmartin",
avatarColor: "#f97316",
body: "Worth a benchmark before/after.",
age: "3h ago",
url: "#",
},
],
...overrides,
};
}
function render(data: PrPaneData) {
act(() => {
root?.render(<PrPane data={data} />);
});
}
describe("PrPane", () => {
it("renders the PR title and 'Open' state label from the data prop", () => {
render(makeData());
expect(container?.textContent).toContain("fix(server,app): coalesce tool call stream events");
expect(container?.textContent).toContain("Open");
});
it("renders the correct state label and icon for each PR state", () => {
render(makeData({ state: "draft" }));
expect(container?.textContent).toContain("Draft");
expect(container?.querySelector('[data-icon="GitPullRequestDraft"]')).not.toBeNull();
render(makeData({ state: "merged" }));
expect(container?.textContent).toContain("Merged");
expect(container?.querySelector('[data-icon="GitMerge"]')).not.toBeNull();
render(makeData({ state: "closed" }));
expect(container?.textContent).toContain("Closed");
expect(container?.querySelector('[data-icon="GitPullRequestClosed"]')).not.toBeNull();
});
it("derives check summary counts from the data prop", () => {
render(makeData());
const text = container?.textContent ?? "";
expect(text).toContain("typecheck");
expect(text).toContain("test");
// 1 passed, 1 failed, 0 pending — so pending pill is hidden.
expect(text).toMatch(/1.*1/);
});
it("derives review summary counts and renders activity rows", () => {
render(makeData());
const text = container?.textContent ?? "";
expect(text).toContain("alicek");
expect(text).toContain("Approved");
expect(text).toContain("bmartin");
expect(text).toContain("Commented");
});
});
+14 -1
View File
@@ -16,7 +16,6 @@ import {
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
import type {
CheckStatus,
PrPaneActivity,
@@ -284,6 +283,13 @@ function ActivityRow({ item }: { item: PrPaneActivity }) {
);
}
function getActivityVerb(item: PrPaneActivity): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
}
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
if (state === "open") return theme.colors.statusSuccess;
if (state === "draft") return theme.colors.foregroundMuted;
@@ -298,6 +304,13 @@ function getStateIcon(state: PrState) {
return GitPullRequest;
}
function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
@@ -1,174 +0,0 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
export interface DraftStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface StoredDraft {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}
export interface AgentInputDraftCore {
load(initialCwd?: string): Promise<StoredDraft | null>;
save(draft: StoredDraft): Promise<void>;
clear(): Promise<void>;
}
export function createAgentInputDraftCore(input: {
storage: DraftStorage;
storageKey: string;
}): AgentInputDraftCore {
return {
async load(initialCwd?: string) {
const json = await input.storage.getItem(input.storageKey);
if (!json) return null;
try {
const parsed = JSON.parse(json) as Record<string, unknown>;
return {
text: typeof parsed.text === "string" ? parsed.text : "",
attachments: Array.isArray(parsed.attachments)
? (parsed.attachments as UserComposerAttachment[])
: [],
cwd: typeof parsed.cwd === "string" ? parsed.cwd : (initialCwd ?? ""),
};
} catch {
return null;
}
},
async save(draft) {
await input.storage.setItem(input.storageKey, JSON.stringify(draft));
},
async clear() {
await input.storage.removeItem(input.storageKey);
},
};
}
export interface DraftKeyContext {
selectedServerId: string | null;
}
export type DraftKeyInput = string | ((context: DraftKeyContext) => string);
export function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
export function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
return input.selectedModel.trim();
}
export function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftComposerCommandConfig(input: {
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.provider || !cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
export function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
allProviderModels: formState.allProviderModels,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
};
}
export function hasDraftContent(input: {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}): boolean {
return (
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
);
}
export function areAttachmentsEqual(input: {
left: UserComposerAttachment[];
right: UserComposerAttachment[];
}): boolean {
if (input.left.length !== input.right.length) {
return false;
}
return input.left.every((attachment, index) => {
const other = input.right[index];
return JSON.stringify(attachment) === JSON.stringify(other);
});
}
@@ -1,188 +1,194 @@
import { describe, expect, it } from "vitest";
import {
buildDraftComposerCommandConfig,
createAgentInputDraftCore,
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "./use-agent-input-draft-core";
import { beforeAll, describe, expect, it, vi } from "vitest";
function makeStorage() {
const map = new Map<string, string>();
return {
getItem: async (key: string) => map.get(key) ?? null,
setItem: async (key: string, value: string) => {
map.set(key, value);
},
removeItem: async (key: string) => {
map.delete(key);
},
};
}
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
},
}));
describe("resolveDraftKey", () => {
it("returns a string draft key unchanged", () => {
expect(
resolveDraftKey({
draftKey: "draft:key",
selectedServerId: "host-1",
}),
).toBe("draft:key");
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: async () => undefined,
}));
vi.mock("./use-agent-form-state", () => ({
useAgentFormState: () => ({
selectedServerId: "host-1",
setSelectedServerId: () => undefined,
setSelectedServerIdFromUser: () => undefined,
selectedProvider: "codex",
setProviderFromUser: () => undefined,
selectedMode: "auto",
setModeFromUser: () => undefined,
selectedModel: "",
setModelFromUser: () => undefined,
selectedThinkingOptionId: "",
setThinkingOptionFromUser: () => undefined,
workingDir: "/repo",
setWorkingDir: () => undefined,
setWorkingDirFromUser: () => undefined,
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
providerDefinitionMap: new Map(),
agentDefinition: undefined,
modeOptions: [{ id: "auto", label: "Auto" }],
availableModels: [],
allProviderModels: new Map(),
isAllModelsLoading: false,
availableThinkingOptions: [],
isModelLoading: false,
modelError: null,
refreshProviderModels: () => undefined,
setProviderAndModelFromUser: () => undefined,
workingDirIsEmpty: false,
persistFormPreferences: async () => undefined,
}),
}));
let __private__: typeof import("./use-agent-input-draft").__private__;
beforeAll(async () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
},
configurable: true,
});
it("resolves a computed draft key from the selected server", () => {
expect(
resolveDraftKey({
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
selectedServerId: "host-1",
}),
).toBe("draft:host-1");
});
({ __private__ } = await import("./use-agent-input-draft"));
});
describe("resolveEffectiveComposerModelId", () => {
it("returns the selected model trimmed", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: " gpt-5.4-mini ",
availableModels: [],
}),
).toBe("gpt-5.4-mini");
describe("useAgentInputDraft", () => {
describe("__private__.resolveDraftKey", () => {
it("returns an object draft key string unchanged", () => {
expect(
__private__.resolveDraftKey({
draftKey: "draft:key",
selectedServerId: "host-1",
}),
).toBe("draft:key");
});
it("resolves a computed draft key from the selected server", () => {
expect(
__private__.resolveDraftKey({
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
selectedServerId: "host-1",
}),
).toBe("draft:host-1");
});
});
it("returns empty string when no model selected", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: "",
availableModels: [],
}),
).toBe("");
});
});
describe("resolveEffectiveComposerThinkingOptionId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
];
it("prefers the selected thinking option when present", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("high");
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
buildDraftComposerCommandConfig({
describe("__private__.resolveEffectiveComposerModelId", () => {
const models = [
{
provider: "codex",
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
},
{
provider: "codex",
id: "gpt-5.4-mini",
label: "gpt-5.4-mini",
},
];
it("prefers the selected model when present", () => {
expect(
__private__.resolveEffectiveComposerModelId({
selectedModel: "gpt-5.4-mini",
availableModels: models,
}),
).toBe("gpt-5.4-mini");
});
it("returns empty string when no model selected", () => {
expect(
__private__.resolveEffectiveComposerModelId({
selectedModel: "",
availableModels: models,
}),
).toBe("");
});
});
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftComposerCommandConfig({
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
];
it("prefers the selected thinking option when present", () => {
expect(
__private__.resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
__private__.resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
).toBe("high");
});
});
describe("__private__.buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
__private__.buildDraftComposerCommandConfig({
provider: "codex",
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
});
it("builds the draft command config from derived composer state", () => {
expect(
__private__.buildDraftComposerCommandConfig({
provider: "codex",
cwd: "/repo",
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
});
});
});
describe("createAgentInputDraftCore", () => {
it("load returns null when nothing has been saved", async () => {
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
expect(await core.load()).toBeNull();
});
it("load returns the draft that was saved", async () => {
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
await core.save({ text: "hello world", attachments: [], cwd: "/repo" });
expect(await core.load()).toEqual({ text: "hello world", attachments: [], cwd: "/repo" });
});
it("clear removes the persisted draft", async () => {
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
await core.save({ text: "hello", attachments: [], cwd: "/repo" });
await core.clear();
expect(await core.load()).toBeNull();
});
it("load seeds cwd from initialCwd when cwd is absent from stored data", async () => {
const storage = makeStorage();
await storage.setItem("test", JSON.stringify({ text: "hello", attachments: [] }));
const core = createAgentInputDraftCore({ storage, storageKey: "test" });
const draft = await core.load("/initial");
expect(draft?.cwd).toBe("/initial");
});
it("round-trips attachments unchanged", async () => {
const attachment = {
kind: "github_issue" as const,
item: {
kind: "issue" as const,
number: 42,
title: "Unify attachments",
url: "https://github.com/example/repo/issues/42",
state: "open" as const,
body: "body",
labels: ["composer"],
},
};
const core = createAgentInputDraftCore({ storage: makeStorage(), storageKey: "test" });
await core.save({ text: "", attachments: [attachment], cwd: "/repo" });
const draft = await core.load();
expect(draft?.attachments).toEqual([attachment]);
});
it("isolated keys do not share state", async () => {
const storage = makeStorage();
const a = createAgentInputDraftCore({ storage, storageKey: "key-a" });
const b = createAgentInputDraftCore({ storage, storageKey: "key-b" });
await a.save({ text: "from a", attachments: [], cwd: "" });
expect(await b.load()).toBeNull();
expect(await a.load()).toMatchObject({ text: "from a" });
});
});
+123 -10
View File
@@ -8,17 +8,9 @@ import {
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftComposerCommandConfig,
buildDraftStatusControls,
hasDraftContent,
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type DraftKeyInput,
} from "@/hooks/use-agent-input-draft-core";
import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -32,6 +24,12 @@ interface AgentInputDraftComposerOptions {
lockedWorkingDir?: string;
}
interface DraftKeyContext {
selectedServerId: string | null;
}
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
interface UseAgentInputDraftInput {
draftKey: DraftKeyInput;
initialCwd?: string;
@@ -59,6 +57,121 @@ interface AgentInputDraft {
composerState: DraftComposerState | null;
}
function hasDraftContent(input: {
text: string;
attachments: UserComposerAttachment[];
cwd: string;
}): boolean {
return (
input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0
);
}
function areAttachmentsEqual(input: {
left: UserComposerAttachment[];
right: UserComposerAttachment[];
}): boolean {
if (input.left.length !== input.right.length) {
return false;
}
return input.left.every((attachment, index) => {
const other = input.right[index];
return JSON.stringify(attachment) === JSON.stringify(other);
});
}
function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
return input.selectedModel.trim();
}
function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
function buildDraftComposerCommandConfig(input: {
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.provider || !cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
allProviderModels: formState.allProviderModels,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
};
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
+44 -42
View File
@@ -285,12 +285,18 @@ describe("highlightDiffFile", () => {
// First content line: "const foo = 1;"
const constLine = hunk.lines[1];
expect(constLine.tokens).toContainEqual(
expect.objectContaining({ text: "const", style: "keyword" }),
);
expect(constLine.tokens).toContainEqual(
expect.objectContaining({ text: "1", style: "number" }),
);
expect(constLine.tokens).toBeDefined();
expect(constLine.tokens!.length).toBeGreaterThan(0);
// Should have a "keyword" token for "const"
const constToken = constLine.tokens!.find((t) => t.text === "const");
expect(constToken).toBeDefined();
expect(constToken!.style).toBe("keyword");
// Should have a "number" token for "1"
const numberToken = constLine.tokens!.find((t) => t.text === "1");
expect(numberToken).toBeDefined();
expect(numberToken!.style).toBe("number");
});
it("highlights both added and removed lines correctly", () => {
@@ -300,15 +306,15 @@ describe("highlightDiffFile", () => {
// Removed line: "const bar = 2;"
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
expect(removedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(removedLine?.tokens).toBeDefined();
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
expect(removedNumber?.style).toBe("number");
// Added line: "const bar = 3;"
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "3", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
expect(addedNumber?.style).toBe("number");
});
it("does not modify unsupported file types", () => {
@@ -345,16 +351,15 @@ describe("highlightDiffFile", () => {
const highlighted = highlightDiffFile(files[0]);
const hunk = highlighted.hunks[0];
expect(hunk.lines[1].tokens).toContainEqual(
expect.objectContaining({ text: "fn", style: "keyword" }),
);
const fnLine = hunk.lines[1];
expect(fnLine.tokens).toBeDefined();
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to C code", () => {
@@ -362,14 +367,15 @@ describe("highlightDiffFile", () => {
const highlighted = highlightDiffFile(files[0]);
const hunk = highlighted.hunks[0];
expect(hunk.lines[1].tokens?.length).toBeGreaterThan(0);
const mainLine = hunk.lines[1];
expect(mainLine.tokens).toBeDefined();
expect(mainLine.tokens!.length).toBeGreaterThan(0);
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to Java code", () => {
@@ -377,16 +383,15 @@ describe("highlightDiffFile", () => {
const highlighted = highlightDiffFile(files[0]);
const hunk = highlighted.hunks[0];
expect(hunk.lines[1].tokens).toContainEqual(
expect.objectContaining({ text: "public", style: "keyword" }),
);
const classLine = hunk.lines[1];
expect(classLine.tokens).toBeDefined();
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
@@ -397,9 +402,8 @@ describe("highlightDiffFile", () => {
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to Go code", () => {
@@ -409,9 +413,8 @@ describe("highlightDiffFile", () => {
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to PHP code", () => {
@@ -421,9 +424,8 @@ describe("highlightDiffFile", () => {
(line) => line.type === "add" && line.content.includes("$version"),
);
expect(addedLine?.tokens).toContainEqual(
expect.objectContaining({ text: "2", style: "number" }),
);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to YAML code", () => {
@@ -433,7 +435,8 @@ describe("highlightDiffFile", () => {
(line) => line.type === "add" && line.content.includes("count"),
);
expect(addedLine?.tokens?.length).toBeGreaterThan(0);
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.length).toBeGreaterThan(0);
});
it("adds syntax highlighting tokens to XML code", () => {
@@ -443,7 +446,8 @@ describe("highlightDiffFile", () => {
(line) => line.type === "add" && line.content.includes("<count>"),
);
expect(addedLine?.tokens).toContainEqual(expect.objectContaining({ style: "tag" }));
expect(addedLine?.tokens).toBeDefined();
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
});
});
@@ -452,9 +456,7 @@ describe("parseAndHighlightDiff", () => {
const files = parseAndHighlightDiff(SIMPLE_DIFF);
expect(files).toHaveLength(1);
expect(files[0].hunks[0].lines[1].tokens).toContainEqual(
expect.objectContaining({ text: "const", style: "keyword" }),
);
expect(files[0].hunks[0].lines[1].tokens).toBeDefined();
});
it("handles multiple files", () => {
+1 -42
View File
@@ -3,13 +3,7 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@server/shared/messages";
import {
deriveAvatarColor,
formatAge,
getActivityVerb,
getStateLabel,
mapPrPaneData,
} from "./pr-pane-data";
import { deriveAvatarColor, formatAge, mapPrPaneData } from "./pr-pane-data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
@@ -341,38 +335,3 @@ describe("formatAge", () => {
expect(formatAge(now - 365 * 24 * 60 * 60_000, now)).toBe("1y ago");
});
});
describe("getStateLabel", () => {
it.each([
["open", "Open"],
["draft", "Draft"],
["merged", "Merged"],
["closed", "Closed"],
] as const)("maps %s → %s", (state, expected) => {
expect(getStateLabel(state)).toBe(expected);
});
});
describe("getActivityVerb", () => {
it("returns Commented for comment kind", () => {
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
});
it("returns Approved for approved review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
});
it("returns Requested changes for changes_requested review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
"Requested changes",
);
});
it("returns Reviewed for a commented review with body (generic case)", () => {
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
});
it("returns Reviewed when reviewState is undefined", () => {
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
});
});
-14
View File
@@ -225,17 +225,3 @@ function hashLogin(login: string): number {
}
return hash;
}
export function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
}
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
}
@@ -49,7 +49,7 @@ describe("configToDraft", () => {
expect(devRow.commandOriginalKind).toBe("string");
expect(devRow.type).toBe("long-running");
expect(devRow.portText).toBe("3000");
expect(devRow.id).toMatch(/^script-draft-\d+$/);
expect(devRow.id).toBeTruthy();
expect(buildRow.name).toBe("build");
expect(buildRow.commandText).toBe("npm\nrun\nbuild");
expect(buildRow.commandOriginalKind).toBe("array");