diff --git a/DESIGN.md b/DESIGN.md index d80f6a7075..75a2972dbc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -56,6 +56,11 @@ paused.” and “Resume this task to send a message.” with a “Resume task Subtrees use “Subtree is paused.” and “Resume subtree.” The takeover cannot be dismissed, retains drafts, and hides message inputs until the pause is released. +Pending questions, confirmations, and other task-thread inputs appear in a separate +card directly above the ordinary composer. The composer stays available for new +messages while the card is open. Dismissing a card leaves a pending indicator that +can reopen it; resolving or skipping the input removes that indicator. + ## Enforcement (what "compliant" means for the extraction run) - **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 417715fc92..e07ba3f82c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -106,6 +106,16 @@ next question. Reduced-motion mode advances without animation. Multi-select and custom answers wait for Next, and the final page waits for Submit answers. The adjacent **Verified** story exercises the full flow. +Use **Composer → Interaction above composer** to review the production +pending-input layout. The stories cover questions, confirmations, checkbox +choices, item verdicts, suggested tasks, tool reviews, runtime questions, and phone layouts with +the bottom navigation. The normal message composer remains usable below the +pending card. + +Use **Composer → Model and effort picker** to review harness-specific model +choices. Codex uses the curated adapter catalog unless the instance declares +`PAPERCLIP_ADAPTER_MODELS`; general OpenAI API models are not Codex choices. + The Storybook visual regression suite uses external PNG baselines instead of committed screenshots: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 04aaa912d5..927e7f1a7e 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -698,6 +698,11 @@ Issue-thread interactions are coordination records, not grants of authority. Eve interaction kind defaults to resolver policy `anyone` when the create request omits `resolverPolicy`. Restrictions are opt-in. +Question, confirmation, checkbox confirmation, and item verdict cards stay pending +when a user sends an ordinary task comment. Their `supersedeOnUserComment` flag +defaults to `false`. A creator may set it to `true` when a comment should replace +the pending request, as the opening onboarding question does. + Canonical resolver policies are: - `anyone`: any authenticated actor in the interaction's company who can read the @@ -1348,7 +1353,8 @@ Board can at any time: Ask-first connection calls use a server-owned tool-action confirmation linked to the authoritative action request. The task feed retains a stable record; dismissal -only hides the composer takeover. Task and Connections decisions share one +only hides the pending card above the composer. The ordinary composer remains +available while the card is open. Task and Connections decisions share one transaction. Approval runs stored, signed arguments once; decline runs nothing. The human decision remains distinct from provider execution success or failure. diff --git a/doc/composer-stop.md b/doc/composer-stop.md index 7c77b2d113..5579d55f10 100644 --- a/doc/composer-stop.md +++ b/doc/composer-stop.md @@ -110,8 +110,8 @@ Run from the worktree: pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open ``` -Open `http://localhost:6016/?path=/story/tasks-execution-controls--running-empty`. -The `Tasks / Execution Controls` stories compose the production composer and +Open `http://localhost:6016/?path=/story/composer-execution-controls--running-empty`. +The `Composer / Execution controls` stories compose the production composer and menu/dialog controls together. They cover text switching, attachment-only, idle, stopping, paused, errors, cancellation preview/loading, and mobile/light presentations. The Storybook state transitions simulate requests; runner diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 3293014bc7..a26f980fc9 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -1601,6 +1601,55 @@ describe("shared ACPX engine runtime behavior", () => { expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth); }); + it.each(["OPENAI_API_KEY", "CODEX_API_KEY"] as const)( + "uses isolated API-key auth instead of the host ChatGPT login for %s", + async (keyName) => { + const root = await makeTempRoot(); + const sourceCodexHome = path.join(root, "source-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + await fs.mkdir(sourceCodexHome, { recursive: true }); + const sourceAuth = path.join(sourceCodexHome, "auth.json"); + await fs.writeFile(sourceAuth, JSON.stringify({ tokens: "host-login" }), "utf8"); + const managedHome = path.join( + paperclipHome, "instances", "test-instance", "companies", "company-1", + "acp-engine", "agents", "agent-1", "codex-home", + ); + await fs.mkdir(managedHome, { recursive: true }); + const managedAuth = path.join(managedHome, "auth.json"); + if (process.platform === "win32") { + await fs.writeFile(managedAuth, JSON.stringify({ tokens: "stale-login" }), "utf8"); + } else { + await fs.symlink(sourceAuth, managedAuth); + } + + vi.stubEnv("CODEX_HOME", sourceCodexHome); + vi.stubEnv("PAPERCLIP_HOME", paperclipHome); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "test-instance"); + vi.stubEnv("OPENAI_API_KEY", ""); + vi.stubEnv("CODEX_API_KEY", ""); + try { + const { sessionInputs } = await runExecutor({ + agent: "codex", + stateDir: path.join(root, "state"), + env: { [keyName]: "sk-acp-test-key" }, + paperclipRuntimeSkills: [], + paperclipSkillSync: { desiredSkills: [] }, + }); + const sessionEnv = (sessionInputs[0]!.sessionOptions as { env: Record }).env; + expect(sessionEnv.CODEX_HOME).toBe(managedHome); + expect(sessionEnv.DEFAULT_AUTH_REQUEST).toBe(JSON.stringify({ methodId: "api-key" })); + expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(false); + expect(JSON.parse(await fs.readFile(managedAuth, "utf8"))).toEqual({ OPENAI_API_KEY: "sk-acp-test-key" }); + expect(await fs.readFile(sourceAuth, "utf8")).toBe(JSON.stringify({ tokens: "host-login" })); + if (process.platform !== "win32") { + expect((await fs.stat(managedAuth)).mode & 0o777).toBe(0o600); + } + } finally { + vi.unstubAllEnvs(); + } + }, + ); + it("sets GROK_HOME for a Grok run from the company Grok home, and leaves CODEX_HOME unchanged for a Codex run", async () => { const root = await makeTempRoot(); const paperclipHome = path.join(root, "paperclip-home"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 9640ee414f..2c4b029e12 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -808,6 +808,10 @@ function resolveManagedCodexHomeDir(companyId: string): string { return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home"); } +function resolveManagedCodexApiKeyHomeDir(companyId: string, agentId: string): string { + return path.join(defaultStateDir(companyId, agentId), "codex-home"); +} + // Mirrors `resolveManagedGrokHomeDir` in // `packages/adapters/grok-local/src/server/grok-home.ts` — this package // cannot import that adapter package (it would invert the dependency @@ -1002,15 +1006,30 @@ async function prepareManagedCodexHome(input: { companyId: string; sourceHome: string; targetHome: string; + apiKey?: string; onLog: AdapterExecutionContext["onLog"]; }): Promise { - const { sourceHome, targetHome, onLog } = input; + const { sourceHome, targetHome, apiKey, onLog } = input; if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome; await fs.mkdir(targetHome, { recursive: true }); - const authJson = path.join(sourceHome, "auth.json"); - if (await pathExists(authJson)) await ensureSymlink(path.join(targetHome, "auth.json"), authJson); + const targetAuth = path.join(targetHome, "auth.json"); + if (apiKey) { + // Codex reads auth.json ahead of the process environment. Never leave a + // shared ChatGPT-login symlink in an API-key agent's managed home, and never + // write through that symlink into the operator's own Codex credentials. + // Atomic replacement also keeps concurrent turns from seeing a missing or + // partially written credential file. + await writeFileAtomically({ + target: targetAuth, + contents: JSON.stringify({ OPENAI_API_KEY: apiKey }), + mode: 0o600, + }); + } else { + const sourceAuth = path.join(sourceHome, "auth.json"); + if (await pathExists(sourceAuth)) await ensureSymlink(targetAuth, sourceAuth); + } for (const name of ["config.json", "config.toml", "instructions.md"]) { const source = path.join(sourceHome, name); @@ -1255,6 +1274,7 @@ async function reconcileManagedCodexSkills(input: { async function prepareCodexSkillRuntime(input: { companyId: string; + agentId: string; config: Record; env: Record; moduleDir: string; @@ -1282,12 +1302,19 @@ async function prepareCodexSkillRuntime(input: { typeof process.env.CODEX_HOME === "string" && process.env.CODEX_HOME.trim().length > 0 ? path.resolve(process.env.CODEX_HOME.trim()) : path.join(os.homedir(), ".codex"); - const managedCodexHome = resolveManagedCodexHomeDir(input.companyId); + const apiKey = input.env.OPENAI_API_KEY?.trim() || input.env.CODEX_API_KEY?.trim() + || process.env.OPENAI_API_KEY?.trim() || process.env.CODEX_API_KEY?.trim(); + // Keep API-key auth separate from the company home used by subscription + // agents, so one agent cannot switch another agent's login on its next turn. + const managedCodexHome = apiKey + ? resolveManagedCodexApiKeyHomeDir(input.companyId, input.agentId) + : resolveManagedCodexHomeDir(input.companyId); const effectiveCodexHome = configuredCodexHome ?? await prepareManagedCodexHome({ companyId: input.companyId, sourceHome: sourceCodexHome, targetHome: managedCodexHome, + apiKey, onLog: input.onLog, }); const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir); @@ -2046,6 +2073,7 @@ async function buildRuntime(input: { const preparedSkills = await measureStartupStep(input.ctx, nowMs, "codex-home.seed", () => prepareCodexSkillRuntime({ companyId: agent.companyId, + agentId: agent.id, config, env, moduleDir: input.engine.moduleDir, diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 3cb69dc4ce..5d3a95c888 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -114,6 +114,7 @@ export const models = [ { id: "gpt-6-luna", label: "gpt-6-luna" }, { id: "gpt-5.6-terra", label: "gpt-5.6-terra" }, { id: "gpt-5.6-luna", label: "gpt-5.6-luna" }, + { id: "gpt-5.5", label: "gpt-5.5" }, { id: "gpt-5.4", label: "gpt-5.4" }, { id: "gpt-5.4-mini", label: "gpt-5.4-mini" }, { id: "gpt-5", label: "gpt-5" }, diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 5935b238b3..b31dafcb9f 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -1042,6 +1042,54 @@ describe("codex_local ACP lane", () => { expect(mode).toBe(0o600); }); + it("does not copy an API-key run's sandbox auth into the shared subscription home", async () => { + const root = await makeTempRoot("paperclip-codex-acp-key-copyback-"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + const keyHome = path.join(root, "api-key-home"); + const sharedHostHome = path.join(root, "shared-codex-home"); + await Promise.all([localCwd, remoteCwd, keyHome, sharedHostHome].map((dir) => fs.mkdir(dir, { recursive: true }))); + const sharedAuth = subscriptionAuthJson("acct-same", OLDER_REFRESH, "host-older"); + await fs.writeFile(path.join(sharedHostHome, "auth.json"), sharedAuth, { mode: 0o600 }); + // A subscription-shaped sandbox credential must never be considered for + // the shared home when this run explicitly authenticates with an API key. + await fs.writeFile( + path.join(keyHome, "auth.json"), + subscriptionAuthJson("acct-same", NEWER_REFRESH, "sandbox-newer"), + { mode: 0o600 }, + ); + process.env.CODEX_HOME = sharedHostHome; + + const execute = createCodexAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never, + }); + const result = await execute(buildContext(localCwd, { + config: { + engine: "acp", + cwd: localCwd, + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: keyHome, OPENAI_API_KEY: "sk-test-key" }, + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner: createLocalSandboxRunner(), + } as never, + authToken: "real-run-jwt", + })); + + expect(result.exitCode).toBe(0); + expect(await fs.readFile(path.join(sharedHostHome, "auth.json"), "utf8")).toBe(sharedAuth); + }); + it("keeps the shared host Codex auth when the sandbox copy is not strictly newer", async () => { const root = await makeTempRoot("paperclip-codex-acp-copyback-older-"); const localCwd = path.join(root, "worktree"); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index e9b19def48..c7715dc75f 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -166,17 +166,16 @@ export function buildCodexAcpConfig(config: Record): Record + restore: apiKeyAuth ? undefined : async ({ assetDir, readFile }) => void (await copyBackCodexAuth({ readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")), hostAuthPath: path.join(input.config.managedAiConnection ? effectiveCodexHome : resolveSharedCodexHomeDir(process.env), "auth.json"), @@ -224,7 +228,7 @@ async function prepareCodexRemoteManagedHome( return { stagedRuntime, - // Per-run copy-back: fires on EVERY run's teardown (including a compatible + // Subscription copy-back fires on EVERY run's teardown (including a compatible // resume that reuses this staged runtime). It reads the sandbox auth.json / // workspace live and copies back to the host; it does NOT remove the staged // in-sandbox home, so re-running it across resumes can't leave a later run @@ -238,7 +242,9 @@ async function prepareCodexRemoteManagedHome( teardown: createWorkspaceRestoreTeardown({ stagedRuntime, onLog, - startMessage: "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", + startMessage: apiKeyAuth + ? "[paperclip] Restoring workspace changes from the sandbox.\n" + : "[paperclip] Restoring workspace changes and Codex auth from the sandbox.\n", failurePrefix: "[paperclip] Codex ACP teardown restore/copy-back failed", }), // One-time cleanup of the HOST staged home temp dir. Fired ONLY when the diff --git a/scripts/storybook-agent-avatar-assets.mjs b/scripts/storybook-agent-avatar-assets.mjs index 736bcb2864..3487904493 100644 --- a/scripts/storybook-agent-avatar-assets.mjs +++ b/scripts/storybook-agent-avatar-assets.mjs @@ -1,11 +1,54 @@ import { createRequire } from "node:module"; import { createHash } from "node:crypto"; -/** Build-only: reuse the API worker and finite preset contract, never a browser renderer. */ +/** Render avatar presets with the API worker in dev and package them in builds. */ export function storybookAgentAvatarAssets() { return { name: "storybook-agent-avatar-assets", - apply: "build", + configureServer(server) { + let rendererPromise; + const images = new Map(); + const renderer = () => rendererPromise ??= (async () => { + const serverRequire = createRequire(new URL("../server/package.json", import.meta.url)); + const { tsImport } = await import(serverRequire.resolve("tsx/esm/api")); + const { createAgentAvatarPool } = await tsImport("../server/src/services/agent-avatar-pool.ts", import.meta.url); + const { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = + await tsImport("../packages/shared/src/agent-appearance.ts", import.meta.url); + return { pool: createAgentAvatarPool(2), AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette }; + })(); + server.middlewares.use((req, res, next) => { + const pathname = new URL(req.url ?? "/", "http://storybook.local").pathname; + const match = /^\/agent-avatar-images\/cap-v1\/([^/]+)\/([^/]+)-(\d+)-([12])\.png$/.exec(pathname); + if (!match) return next(); + void (async () => { + const [, palette, pose, sizeText, scaleText] = match; + const { pool, AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } = await renderer(); + const size = Number(sizeText); + const scale = Number(scaleText); + const muted = palette === "muted-dream"; + if (!(muted || AGENT_PALETTE_IDS.includes(palette)) || !CHARACTER_STATES.includes(pose) || !AGENT_AVATAR_SIZES.includes(size)) { + res.statusCode = 404; + res.end(); + return; + } + if (!images.has(pathname)) { + const appearance = appearanceForPalette(muted ? AGENT_PALETTE_IDS[0] : palette); + images.set(pathname, pool.render({ appearance, size, scale, pose, muted }).catch((error) => { + images.delete(pathname); + throw error; + })); + } + const png = await images.get(pathname); + res.setHeader("Content-Type", "image/png"); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.end(png); + })().catch(() => { + res.statusCode = 500; + res.end("Avatar rendering failed"); + }); + }); + server.httpServer?.once("close", () => { void rendererPromise?.then(({ pool }) => pool.close()); }); + }, async generateBundle() { const serverRequire = createRequire(new URL("../server/package.json", import.meta.url)); const { tsImport } = await import(serverRequire.resolve("tsx/esm/api")); diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index e35ba1b447..c04f07fb00 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -5,7 +5,7 @@ import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local" import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local"; import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local"; import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server"; -import { listAdapterModels, listServerAdapters, refreshAdapterModels } from "../adapters/index.js"; +import { listAdapterModels, listServerAdapters, refreshAdapterModels, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js"; @@ -195,14 +195,14 @@ describe("adapter model listing", () => { ])); }); - it("loads codex models dynamically and merges fallback options", async () => { + it("keeps general OpenAI API models out of the Codex catalog", async () => { process.env.OPENAI_API_KEY = "sk-test"; const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ data: [ - { id: "gpt-5-pro" }, - { id: "gpt-5" }, + { id: "gpt-image-1" }, + { id: "text-embedding-3-large" }, ], }), } as Response); @@ -210,40 +210,26 @@ describe("adapter model listing", () => { const first = await listAdapterModels("codex_local"); const second = await listAdapterModels("codex_local"); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(first).toEqual(second); - expect(first.some((model) => model.id === "gpt-5-pro")).toBe(true); - expect(first.some((model) => model.id === "codex-mini-latest")).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(first).toEqual(codexFallbackModels); + expect(second).toEqual(codexFallbackModels); }); - it("refreshes cached codex models on demand", async () => { + it("keeps the curated Codex list when refreshing with an OpenAI key", async () => { process.env.OPENAI_API_KEY = "sk-test"; - const fetchSpy = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: [{ id: "gpt-5" }], - }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: [{ id: "gpt-5.6-terra" }], - }), - } as Response); + const fetchSpy = vi.spyOn(globalThis, "fetch"); const initial = await listAdapterModels("codex_local"); const refreshed = await refreshAdapterModels("codex_local"); - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(initial.some((model) => model.id === "gpt-5")).toBe(true); - expect(refreshed.some((model) => model.id === "gpt-5.6-terra")).toBe(true); - expect(refreshed.some((model) => model.id === "gpt-5.6-luna")).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(initial).toEqual(codexFallbackModels); + expect(refreshed).toEqual(codexFallbackModels); }); - it("falls back to static codex models when OpenAI model discovery fails", async () => { + it("uses static Codex models without calling OpenAI model discovery", async () => { process.env.OPENAI_API_KEY = "sk-test"; - vi.spyOn(globalThis, "fetch").mockResolvedValue({ + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 401, json: async () => ({}), @@ -251,6 +237,33 @@ describe("adapter model listing", () => { const models = await listAdapterModels("codex_local"); expect(models).toEqual(codexFallbackModels); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("uses a custom Codex adapter's model and refresh hooks", async () => { + const builtin = listServerAdapters().find((adapter) => adapter.type === "codex_local")!; + const customModels = [{ id: "plugin-codex", label: "Plugin Codex" }]; + const listModels = vi.fn(async () => customModels); + const refreshModels = vi.fn(async () => customModels); + registerServerAdapter({ ...builtin, models: [], listModels, refreshModels }); + try { + await expect(listAdapterModels("codex_local")).resolves.toEqual(customModels); + await expect(refreshAdapterModels("codex_local")).resolves.toEqual(customModels); + expect(listModels).toHaveBeenCalledOnce(); + expect(refreshModels).toHaveBeenCalledOnce(); + + process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({ + codex_local: [{ id: "declared-codex", label: "Declared Codex" }], + }); + const declared = [{ id: "declared-codex", label: "Declared Codex" }]; + await expect(listAdapterModels("codex_local")).resolves.toEqual(declared); + await expect(refreshAdapterModels("codex_local")).resolves.toEqual(declared); + expect(listModels).toHaveBeenCalledOnce(); + expect(refreshModels).toHaveBeenCalledOnce(); + } finally { + delete process.env.PAPERCLIP_ADAPTER_MODELS; + unregisterServerAdapter("codex_local"); + } }); @@ -315,6 +328,16 @@ describe("adapter model listing", () => { ]); }); + it("uses declared Codex models for both listing and refresh", async () => { + process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({ + codex_local: [{ id: "private-codex", label: "Private Codex" }], + }); + const declared = [{ id: "private-codex", label: "Private Codex" }]; + + await expect(listAdapterModels("codex_local")).resolves.toEqual(declared); + await expect(refreshAdapterModels("codex_local")).resolves.toEqual(declared); + }); + it("observes env changes between calls (memo keyed by raw env value)", async () => { process.env.PAPERCLIP_ADAPTER_MODELS = JSON.stringify({ opencode_local: [{ id: "model-a" }], diff --git a/server/src/__tests__/chat-interaction-publications.test.ts b/server/src/__tests__/chat-interaction-publications.test.ts index c9cba1b72f..6c4c5d5f8e 100644 --- a/server/src/__tests__/chat-interaction-publications.test.ts +++ b/server/src/__tests__/chat-interaction-publications.test.ts @@ -1009,7 +1009,11 @@ describeEmbeddedPostgres( { id: fixture.issueId, companyId: fixture.companyId }, { kind: "request_confirmation", - payload: { version: 1, prompt: "Proceed with the release?" }, + payload: { + version: 1, + prompt: "Proceed with the release?", + supersedeOnUserComment: true, + }, }, { agentId: fixture.agentId }, ); @@ -1053,6 +1057,7 @@ describeEmbeddedPostgres( kind: "ask_user_questions", payload: { version: 1, + supersedeOnUserComment: true, questions: [ { id: "priority", diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index d73a3fbd2d..d8282401e9 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -394,6 +394,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { continuationPolicy: "wake_assignee", payload: { version: 1, + supersedeOnUserComment: true, questions: [{ id: "scope", prompt: "Which scope?", @@ -1435,7 +1436,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); - it("expires ask_user_questions interactions by default when a user comments after creation", async () => { + it("expires ask_user_questions when a creator opts into comment supersede", async () => { const { companyId, issueId } = await seedConfirmationIssue("Question supersede"); const commentId = randomUUID(); @@ -1446,6 +1447,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { kind: "ask_user_questions", payload: { version: 1, + supersedeOnUserComment: true, questions: [{ id: "scope", prompt: "Choose the scope", @@ -1491,7 +1493,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); - it("keeps ask_user_questions pending when user-comment supersede is explicitly disabled", async () => { + it("keeps ask_user_questions pending by default when the user sends a message", async () => { const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out"); await interactionsSvc.create({ @@ -1501,7 +1503,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { kind: "ask_user_questions", payload: { version: 1, - supersedeOnUserComment: false, questions: [{ id: "scope", prompt: "Choose the scope", @@ -1513,6 +1514,9 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { userId: "local-board", }); + const [created] = await db.select().from(issueThreadInteractions); + expect(created?.payload).toMatchObject({ supersedeOnUserComment: false }); + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ id: issueId, companyId, @@ -1600,6 +1604,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { kind: "ask_user_questions", payload: { version: 1, + supersedeOnUserComment: true, questions: [{ id: "scope", prompt: "Choose the scope", @@ -2519,7 +2524,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { status: "pending", continuationPolicy: "wake_assignee", payload: { - supersedeOnUserComment: true, + supersedeOnUserComment: false, allowDeclineReason: true, }, }); @@ -2618,6 +2623,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Which files should be deleted?", + supersedeOnUserComment: true, options: [{ id: "file-a", label: "a.txt" }], }, }, { @@ -2648,6 +2654,27 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("keeps checkbox confirmations pending by default after a user comment", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Checkbox card remains"); + const created = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_checkbox_confirmation", + payload: { + version: 1, + prompt: "Choose a file", + options: [{ id: "file-a", label: "a.txt" }], + }, + }, { userId: "local-board" }); + expect(created.payload.supersedeOnUserComment).toBe(false); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment( + { id: issueId, companyId }, + { id: randomUUID(), createdAt: new Date(Date.now() + 1_000), authorUserId: "local-board" }, + { userId: "local-board" }, + ); + expect(expired).toHaveLength(0); + expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending"); + }); + it("submits request_item_verdicts partially and completes when all items are resolved", async () => { const { companyId, issueId } = await seedConfirmationIssue("Item verdict partial submit"); @@ -2677,7 +2704,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { verdicts: ["approve", "reject"], requireReasonOn: ["reject"], allowBulkApprove: true, - supersedeOnUserComment: true, + supersedeOnUserComment: false, }, }); @@ -2835,6 +2862,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Review generated artifacts.", + supersedeOnUserComment: true, items: [ { id: "api", label: "API route" }, { id: "docs", label: "Docs" }, @@ -2885,6 +2913,27 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("keeps item verdict requests pending by default after a user comment", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Verdict card remains"); + const created = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_item_verdicts", + payload: { + version: 1, + prompt: "Review the file", + items: [{ id: "file-a", label: "a.txt" }], + }, + }, { userId: "local-board" }); + expect(created.payload.supersedeOnUserComment).toBe(false); + + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment( + { id: issueId, companyId }, + { id: randomUUID(), createdAt: new Date(Date.now() + 1_000), authorUserId: "local-board" }, + { userId: "local-board" }, + ); + expect(expired).toHaveLength(0); + expect((await db.select().from(issueThreadInteractions))[0]?.status).toBe("pending"); + }); + it("returns accepted agent confirmations from review without resetting active work", async () => { const companyId = randomUUID(); const goalId = randomUUID(); @@ -3187,7 +3236,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { .resolves.toBe("planning"); }); - it("expires request confirmations by default when a user comments after creation", async () => { + it("expires request confirmations when a creator opts into comment supersede", async () => { const { companyId, issueId } = await seedConfirmationIssue(); const commentId = randomUUID(); @@ -3199,6 +3248,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, }, }, { userId: "local-board", @@ -3234,7 +3284,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); - it("keeps request confirmations pending when user-comment supersede is explicitly disabled", async () => { + it("keeps request confirmations pending by default when the user sends a message", async () => { const { companyId, issueId } = await seedConfirmationIssue("Comment supersede opt-out"); await interactionsSvc.create({ @@ -3245,12 +3295,14 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", - supersedeOnUserComment: false, }, }, { userId: "local-board", }); + const [created] = await db.select().from(issueThreadInteractions); + expect(created?.payload).toMatchObject({ supersedeOnUserComment: false }); + const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({ id: issueId, companyId, @@ -3475,6 +3527,7 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { payload: { version: 1, prompt: "Proceed with the current draft?", + supersedeOnUserComment: true, }, }, { userId: "local-board", diff --git a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts index d86813db1d..d3db5df11d 100644 --- a/server/src/__tests__/issue-thread-interactions-telemetry.test.ts +++ b/server/src/__tests__/issue-thread-interactions-telemetry.test.ts @@ -438,6 +438,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => { continuationPolicy: "wake_assignee", payload: { version: 1, + supersedeOnUserComment: true, questions: [ { id: "scope", @@ -563,6 +564,7 @@ describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => { payload: { version: 1, prompt: "Approve this plan?", + supersedeOnUserComment: true, }, }, { userId: "local-board", diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index bf7712de39..841896e033 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -201,7 +201,8 @@ function registerModuleMocks() { })); } -async function createApp() { +async function createApp(transaction: (callback: (tx: Record) => Promise) => Promise = + async (callback) => callback({})) { const [{ errorHandler }, { issueRoutes }] = await Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), @@ -220,7 +221,7 @@ async function createApp() { next(); }); app.use("/api", issueRoutes({ - transaction: async (callback: (tx: Record) => Promise) => callback({}), + transaction, } as any, {} as any)); app.use(errorHandler); return app; @@ -330,6 +331,7 @@ describe("issue update comment wakeups", () => { externalConversationState, assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, + assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } }, }); mockIssueService.getById.mockResolvedValue(existing); mockIssueService.update.mockResolvedValue(updated); @@ -347,11 +349,16 @@ describe("issue update comment wakeups", () => { assigneeUserId: null, comment: "write the whole thing", commentClientRequestId: "55555555-5555-4555-8555-555555555555", + assigneeAdapterOverrides: updated.assigneeAdapterOverrides, }); expect(res.status).toBe(200); + expect(mockIssueService.update).toHaveBeenCalledWith(existing.id, expect.objectContaining({ + assigneeAdapterOverrides: updated.assigneeAdapterOverrides, + }), expect.anything()); expect(mockIssueService.addComment).toHaveBeenCalledWith(existing.id, "write the whole thing", expect.anything(), - expect.objectContaining({ clientRequestId: "55555555-5555-4555-8555-555555555555" })); + expect.objectContaining({ clientRequestId: "55555555-5555-4555-8555-555555555555" }), expect.anything()); + expect(mockIssueService.update.mock.calls[0]?.[2]).toBe(mockIssueService.addComment.mock.calls[0]?.[4]); // The route dispatches the wake after it sends the response, so wait for // the fire-and-forget dispatch to settle. This keeps the wake inside this // test and stops it from leaking into the next test as an extra call. @@ -378,6 +385,35 @@ describe("issue update comment wakeups", () => { ); }); + it("rolls back adapter settings if the accompanying comment fails", async () => { + const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null }); + let persistedModel = "gpt-6-sol"; + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockImplementation(async (_id, fields) => { + persistedModel = fields.assigneeAdapterOverrides.adapterConfig.model; + return { ...existing, ...fields }; + }); + mockIssueService.addComment.mockRejectedValue(new Error("comment write failed")); + const transaction = vi.fn(async (callback: (tx: Record) => Promise) => { + const previousModel = persistedModel; + try { + return await callback({}); + } catch (error) { + persistedModel = previousModel; + throw error; + } + }); + + const res = await request(await createApp(transaction)) + .patch(`/api/issues/${existing.id}`) + .send({ comment: "use Astra", assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra" } } }); + + expect(res.status).toBe(500); + expect(transaction).toHaveBeenCalledOnce(); + expect(persistedModel).toBe("gpt-6-sol"); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("interrupts the active run and wakes the newly assigned agent with handoff context", async () => { const existing = makeIssue({ assigneeAgentId: PREVIOUS_AGENT_ID, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 236fd06410..afa38c1ad0 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1033,13 +1033,22 @@ function getDeclaredAdapterModels(): ReturnType { return value; } +function declaredModelsForAdapter(type: string): { id: string; label: string }[] | null { + const declared = getDeclaredAdapterModels()?.[type]; + return declared?.length + ? declared.map((model) => ({ id: model.id, label: model.label ?? model.id })) + : null; +} + export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { - const declaredModels = getDeclaredAdapterModels(); - if (declaredModels && declaredModels[type]?.length) { - return declaredModels[type].map((m) => ({ id: m.id, label: m.label ?? m.id })); - } + const declaredModels = declaredModelsForAdapter(type); + if (declaredModels) return declaredModels; const adapter = findActiveServerAdapter(type); if (!adapter) return []; + // The built-in Codex adapter's OpenAI discovery includes image, audio, and + // embedding models that Codex cannot run. Use its curated list; declared + // models above and custom adapter discovery remain authoritative. + if (adapter === codexLocalAdapter) return adapter.models ?? []; if (adapter.listModels) { const discovered = await adapter.listModels(); if (discovered.length > 0) return discovered; @@ -1048,6 +1057,9 @@ export async function listAdapterModels(type: string): Promise<{ id: string; lab } export async function refreshAdapterModels(type: string): Promise<{ id: string; label: string }[]> { + const declaredModels = declaredModelsForAdapter(type); + if (declaredModels) return declaredModels; + if (findActiveServerAdapter(type) === codexLocalAdapter) return listAdapterModels(type); const adapter = findActiveServerAdapter(type); if (!adapter) return []; if (adapter.refreshModels) { diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index dcdc6f85df..f80d325505 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -50,7 +50,7 @@ Status quick guide: - Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. - When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you. - For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, set the source issue to `in_review`, and do not create implementation subtasks until the board/user accepts it. -- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed. +- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `false`, so a later board/user comment keeps the pending card open while discussion continues. Set it to `true` when a new comment should replace the pending request. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index cf9d10a55f..1472483914 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -17,7 +17,7 @@ You are an agent at Paperclip company. 3. Only then create `request_confirmation` with `target={ type: 'issue_document', key: 'plan', revisionId: latestRevisionId }` and `idempotencyKey=confirmation:{issueId}:plan:{revisionId}`. 4. Wait for acceptance before creating implementation subtasks. Never present a plan only in a thread comment or through `ask_user_questions`; comments are supporting context and questions are for gathering input, not plan review. -- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed. +- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `false`, so a later board/user comment keeps the pending card open while discussion continues. Set it to `true` when a new comment should replace the pending request. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed. - For human input, save a pending question/confirmation interaction and set `in_review`; prose alone does not create a waiting path. Use `blockedByIssueIds` for issue dependencies. An agent may set an `unblockDescriptor` only for itself (`owner: { "agentId": "" }` plus `action`), not for the board/user or another agent. - Respect budget, pause/cancel, approval gates, and company boundaries. diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 653c5e7965..10ac1f566c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -13729,13 +13729,17 @@ export function issueRoutes( }); const decision = transition.decision && decisionId ? transition.decision : null; - let attachmentComment: Awaited> | null = + let transactionalComment: Awaited> | null = null; - const attachmentCommentSourceTrust = commentAttachmentIds?.length + const commentWithAdapterOverrides = Boolean( + commentBody && updateFields.assigneeAdapterOverrides !== undefined, + ); + const transactionalCommentSourceTrust = commentAttachmentIds?.length || commentWithAdapterOverrides ? await sourceTrustForActorWrite(existing, actor) : undefined; const shouldUseTransactionalIssueUpdate = Boolean(commentAttachmentIds?.length) || + commentWithAdapterOverrides || Boolean(decision) || shouldRelayStop || persistReviewActivityTransactionally || @@ -13750,10 +13754,10 @@ export function issueRoutes( return null; const updated = await updateIssue(tx); if (!updated) return null; - if (commentAttachmentIds?.length) { - // Reassignment, comment creation and upload binding commit together. - // An invalid or already-bound receipt rolls back the issue update. - attachmentComment = await svc.addComment( + if (commentAttachmentIds?.length || commentWithAdapterOverrides) { + // Adapter settings, reassignment, comment and upload binding commit together. + // A failed comment or invalid receipt rolls back the issue update. + transactionalComment = await svc.addComment( id, commentBody, { @@ -13768,7 +13772,7 @@ export function issueRoutes( clientRequestId: actor.actorType === "user" ? commentClientRequestId : undefined, mirrorToSlack: actor.actorType === "user", authorizationReason: issueMutationAuthorizationReason, - sourceTrust: attachmentCommentSourceTrust, + sourceTrust: transactionalCommentSourceTrust, }, tx, ); @@ -14353,7 +14357,7 @@ export function issueRoutes( } let comment: Awaited> | null = - attachmentComment; + transactionalComment; let goalCommentSteered = false; let lostReviewPathRef: string | null = null; if (commentBody) { diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 86cd7077e2..deef5871d5 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -851,7 +851,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; case "request_confirmation": @@ -859,7 +859,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; case "request_checkbox_confirmation": @@ -867,7 +867,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; case "request_item_verdicts": @@ -875,7 +875,7 @@ function normalizeCreateInteractionInput( ...input, payload: { ...input.payload, - supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true, + supersedeOnUserComment: input.payload.supersedeOnUserComment ?? false, }, }; default: diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index dab0e16d0f..1b46dfe55d 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1700,7 +1700,7 @@ export class PaperclipRunnerToolAuthority { acceptLabel: normalizedPayload.acceptLabel ?? "Confirm", rejectLabel: normalizedPayload.rejectLabel ?? "Request changes", rejectRequiresReason: normalizedPayload.rejectRequiresReason ?? false, - supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? true, + supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? false, } : {}), }, } as never, { agentId: this.binding.agentId, userId: null, identityContextId }); diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts index e9c89067a3..88751f70d3 100644 --- a/tests/e2e/agent-chat-projects.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -246,11 +246,8 @@ for (const mode of ["Ask", "Plan"]) const f = await setup(request); try { await page.goto(f.route); - await page.getByTestId("task-chat-composer-mode").click(); - await page - .getByTestId("task-chat-composer-mode-menu") - .getByText(`${mode} mode`, { exact: true }) - .click(); + await page.getByTestId("task-chat-composer-add").click(); + await page.getByTestId(mode === "Plan" ? "composer-add-plan" : "composer-add-ask").click(); await send(page, { action: "project", name: "Forbidden mutation" }); await idle(request, f.chatPath); expect( @@ -387,11 +384,8 @@ test("plan approval hands the preserved revision to an assigned project task", a const f = await setup(request); try { await page.goto(f.route); - await page.getByTestId("task-chat-composer-mode").click(); - await page - .getByTestId("task-chat-composer-mode-menu") - .getByText("Plan mode", { exact: true }) - .click(); + await page.getByTestId("task-chat-composer-add").click(); + await page.getByTestId("composer-add-plan").click(); await send(page, { action: "plan", text: "# Approved welcome\nWrite two friendly sentences.", diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index 3d0deb442d..de7fb5cbde 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type APIRequestContext, + type Locator, type Page, } from "@playwright/test"; @@ -95,15 +96,19 @@ const files = [ }, ]; +async function openAttachmentChooser(page: Page, composer: Locator) { + await composer.getByRole("button", { name: "Add to composer" }).click(); + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { name: "Files and images", exact: true }).click(); + return chooser; +} + async function upload( page: Page, fixture: Awaited>, file: (typeof files)[number], ) { - const chooser = page.waitForEvent("filechooser"); - await fixture.composer - .getByRole("button", { name: "Attach file", exact: true }) - .click(); + const chooser = await openAttachmentChooser(page, fixture.composer); const response = page.waitForResponse( (res) => res.request().method() === "POST" && @@ -111,7 +116,7 @@ async function upload( `/issues/${fixture.issue.id}/attachments`, ), ); - await (await chooser).setFiles(file); + await chooser.setFiles(file); const receipt = await body(await response); await expect .poll(async () => @@ -376,11 +381,8 @@ for (const classic of [false, true]) await route.fulfill({ response }); }, ); - const chooser = page.waitForEvent("filechooser"); - await fixture.composer - .getByRole("button", { name: "Attach file", exact: true }) - .click(); - await (await chooser).setFiles(files[1]!); + const chooser = await openAttachmentChooser(page, fixture.composer); + await chooser.setFiles(files[1]!); await expect.poll(() => arrived).toBe(true); await expect(fixture.send).toBeDisabled(); try { @@ -430,11 +432,8 @@ test("legacy failed upload can be removed before sending the retained text", asy body: JSON.stringify({ error: "Fixture upload rejected" }), }), ); - const chooser = page.waitForEvent("filechooser"); - await fixture.composer - .getByRole("button", { name: "Attach file", exact: true }) - .click(); - await (await chooser).setFiles(files[0]!); + const chooser = await openAttachmentChooser(page, fixture.composer); + await chooser.setFiles(files[0]!); await expect( fixture.composer.getByText("Fixture upload rejected", { exact: true }), ).toBeVisible(); diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 97ce3997b9..de11726d12 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -275,13 +275,14 @@ test.describe("Board send delivery refresh", () => { "base64", ), }; + if (!classic) { + await page.getByRole("button", { name: "Add to composer" }).click(); + } const chooserPromise = page.waitForEvent("filechooser"); - await page - .getByRole("button", { - name: classic ? "Upload attachment" : "Attach file", - exact: true, - }) - .click(); + await (classic + ? page.getByRole("button", { name: "Upload attachment", exact: true }) + : page.getByRole("menuitem", { name: "Files and images", exact: true }) + ).click(); const responsePromise = page.waitForResponse( (response) => response.request().method() === "POST" && diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index ea53f5fc74..564ae156d9 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -12,12 +12,12 @@ const TASK_TITLE = "Paperclip onboarding"; /** * The first task opens with the chief of staff's opening card sitting where * the composer is. Cancel hands the plain composer back (the card stays - * pending), and the composer is where the mode toggle lives. + * pending), and the composer is where the mode chip lives. * * The card arrives with the interactions fetch, after the composer's first * paint, so a bare `count()` right after navigation sees no card and skips * the click; the card then lands on top of the composer and hides the mode - * toggle. Wait for the card (or, if it is already dismissed, the pending + * chip. Wait for the card (or, if it is already dismissed, the pending * strip it leaves behind) before deciding, and only return once the plain * composer is back. */ @@ -176,8 +176,8 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await page.goto(issuePath); await dismissOpeningCard(page); await page.getByTestId("task-chat-composer-mode").click(); - await page.getByRole("menuitem", { name: /Auto mode/ }).click(); - await expect(page.getByTestId("task-chat-composer-mode")).toHaveAttribute("data-pending-work-mode", "standard"); + await expect(page.getByTestId("task-chat-composer-mode")).toHaveCount(0); + await expect(page.getByTestId("task-chat-composer-add")).toBeVisible(); await page.screenshot({ path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`, fullPage: true, diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 08a5f9c304..f8ae68be14 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -693,11 +693,8 @@ export async function runChatFlow(input: ChatFlowInput) { await idle(3); } else await turn(clarification, 3); } else if (caseId === "plan-handoff") { - await page.getByTestId("task-chat-composer-mode").click(); - await page - .getByTestId("task-chat-composer-mode-menu") - .getByText("Plan mode", { exact: true }) - .click(); + await page.getByTestId("task-chat-composer-add").click(); + await page.getByTestId("composer-add-plan").click(); await turn( `Let's plan a two-sentence garden club welcome note. The finished welcome note itself must contain the exact phrase ${draftMarker}. Write a plan in the plan panel that includes this requirement, and present it for approval. When I approve the final revision, create a suitable repository-free project and an assigned task for yourself, copy the plan into that task, and have it save the note as a Paperclip document attached to that execution task and finish. Do not create the project or task before approval.`, 1, diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 62e1e686d1..384fa952a3 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -732,20 +732,19 @@ describe("IssueChatThread", () => { expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning"); expect(composer?.className).toContain("amber"); - const toggle = container.querySelector( - '[data-testid="issue-chat-composer-work-mode-toggle"]', + const chip = container.querySelector( + '[data-testid="issue-chat-composer-work-mode-chip"]', ); - expect(toggle).not.toBeNull(); - expect(toggle?.getAttribute("data-pending-work-mode")).toBe("planning"); - expect(toggle?.getAttribute("aria-pressed")).toBe("true"); - expect(toggle?.textContent).toContain("Plan mode"); + expect(chip).not.toBeNull(); + expect(chip?.getAttribute("data-pending-work-mode")).toBe("planning"); + expect(chip?.textContent).toContain("Plan mode"); act(() => { root.unmount(); }); }); - it("shows a persistent neutral mode chip on a standard issue and selects planning through its menu", () => { + it("selects planning from the add menu and removes its chip", () => { const root = createRoot(container); const onWorkModeChange = vi.fn(); @@ -766,13 +765,9 @@ describe("IssueChatThread", () => { ); }); - // The mode chip is always present (mockup rev 5) — neutral "Auto mode" here. - const chip = container.querySelector( - '[data-testid="issue-chat-composer-work-mode-toggle"]', - ) as HTMLButtonElement | null; - expect(chip).not.toBeNull(); - expect(chip?.getAttribute("data-pending-work-mode")).toBe("standard"); - expect(chip?.textContent).toContain("Auto mode"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); + const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; + expect(add).not.toBeNull(); const composer = container.querySelector( '[data-testid="issue-chat-composer"]', @@ -781,11 +776,11 @@ describe("IssueChatThread", () => { expect(composer?.className).not.toContain("amber"); act(() => { - chip?.click(); + add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); }); const menuItem = document.querySelector( - '[data-testid="issue-chat-composer-work-mode-menu-planning"]', + '[data-testid="composer-add-plan"]', ) as HTMLButtonElement | null; expect(menuItem).not.toBeNull(); expect(menuItem?.textContent).toContain("Plan mode"); @@ -798,7 +793,11 @@ describe("IssueChatThread", () => { expect(onWorkModeChange).not.toHaveBeenCalled(); expect(composer?.getAttribute("data-pending-work-mode")).toBe("planning"); expect(composer?.className).toContain("amber"); + const chip = container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]') as HTMLButtonElement; expect(chip?.textContent).toContain("Plan mode"); + act(() => chip.click()); + expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); act(() => { root.unmount(); @@ -826,21 +825,19 @@ describe("IssueChatThread", () => { ); }); - const chip = container.querySelector( - '[data-testid="issue-chat-composer-work-mode-toggle"]', - ) as HTMLButtonElement | null; + const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; const composer = container.querySelector( '[data-testid="issue-chat-composer"]', ) as HTMLDivElement | null; - expect(chip).not.toBeNull(); + expect(add).not.toBeNull(); expect(composer).not.toBeNull(); act(() => { - chip?.click(); + add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); }); const askMenuItem = document.querySelector( - '[data-testid="issue-chat-composer-work-mode-menu-ask"]', + '[data-testid="composer-add-ask"]', ) as HTMLButtonElement | null; expect(askMenuItem).not.toBeNull(); expect(askMenuItem?.textContent).toContain("Ask mode"); @@ -852,7 +849,7 @@ describe("IssueChatThread", () => { expect(onWorkModeChange).not.toHaveBeenCalled(); expect(composer?.getAttribute("data-pending-work-mode")).toBe("ask"); expect(composer?.className).toContain("sky"); - expect(chip?.textContent).toContain("Ask mode"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')?.textContent).toContain("Ask mode"); act(() => { composer?.dispatchEvent( @@ -866,7 +863,7 @@ describe("IssueChatThread", () => { }); expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard"); - expect(chip?.textContent).toContain("Auto mode"); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); act(() => { root.unmount(); @@ -3681,26 +3678,33 @@ describe("IssueChatThread", () => { }); }); - it("shows non-image attachment upload state in the composer after a drop", async () => { + it("keeps mode controls available while a dropped file uploads", async () => { const root = createRoot(container); - const onAttachImage = vi.fn(async (file: File) => ({ - id: "attachment-1", - companyId: "company-1", - issueId: "issue-1", - issueCommentId: null, - assetId: "asset-1", - provider: "local_disk", - objectKey: "issues/issue-1/report.pdf", - contentPath: "/api/attachments/attachment-1/content", - originalFilename: file.name, - contentType: file.type, - byteSize: file.size, - sha256: "abc123", - createdByAgentId: null, - createdByUserId: "user-1", - createdAt: new Date("2026-04-24T12:00:00.000Z"), - updatedAt: new Date("2026-04-24T12:00:00.000Z"), - })); + let finishUpload: () => void = () => {}; + const uploadGate = new Promise((resolve) => { + finishUpload = resolve; + }); + const onAttachImage = vi.fn(async (file: File) => { + await uploadGate; + return { + id: "attachment-1", + companyId: "company-1", + issueId: "issue-1", + issueCommentId: null, + assetId: "asset-1", + provider: "local_disk", + objectKey: "issues/issue-1/report.pdf", + contentPath: "/api/attachments/attachment-1/content", + originalFilename: file.name, + contentType: file.type, + byteSize: file.size, + sha256: "abc123", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-04-24T12:00:00.000Z"), + updatedAt: new Date("2026-04-24T12:00:00.000Z"), + }; + }); await act(async () => { root.render( @@ -3712,6 +3716,8 @@ describe("IssueChatThread", () => { liveRuns={[]} onAdd={async () => {}} onAttachImage={onAttachImage} + issueWorkMode="standard" + onWorkModeChange={() => {}} enableLiveTranscriptPolling={false} /> , @@ -3725,11 +3731,28 @@ describe("IssueChatThread", () => { type: "application/pdf", }); - await act(async () => { + act(() => { composer?.dispatchEvent(createFileDragEvent("drop", [file])); }); expect(onAttachImage).toHaveBeenCalledWith(file); + const add = container.querySelector('[data-testid="issue-chat-composer-add"]') as HTMLButtonElement; + expect(add.disabled).toBe(false); + act(() => { + add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 })); + }); + expect(document.querySelector('[data-testid="composer-add-file"]')?.getAttribute("data-disabled")).not.toBeNull(); + const plan = document.querySelector('[data-testid="composer-add-plan"]') as HTMLButtonElement; + act(() => plan.click()); + const chip = container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]') as HTMLButtonElement; + expect(chip?.textContent).toContain("Plan mode"); + expect(chip.disabled).toBe(false); + act(() => chip.click()); + expect(container.querySelector('[data-testid="issue-chat-composer-work-mode-chip"]')).toBeNull(); + + await act(async () => { + finishUpload(); + }); const attachmentList = container.querySelector( '[data-testid="issue-chat-composer-attachments"]', ); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1b7f8418e7..328a476df8 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1,5 +1,8 @@ import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; +import type { ComposerRunSettings } from "./task-chat/composer-run-settings"; +import { ComposerRunSettingsPicker } from "./task-chat/ComposerRunSettingsPicker"; +import { ComposerAddMenu, ComposerModeChip } from "./task-chat/ComposerAddMenu"; import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover"; import { useEmailComment } from "./EmailMessageCard"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; @@ -46,6 +49,7 @@ import type { SuccessfulRunHandoffState, IssueWorkMode, IssueWorkProduct, + IssueAssigneeAdapterOverrides, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { findUIAdapter } from "../adapters/registry"; @@ -213,9 +217,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { liveBlueBadge } from "../lib/status-colors"; import { nextWorkMode, - titleForPendingWorkMode, workModeMetaFor, - workModeMetaList, } from "../lib/work-mode-meta"; import { Tooltip, @@ -518,6 +520,8 @@ interface IssueChatComposerProps { enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; + companyId?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; suggestedAssigneeValue?: string; mentions?: MentionOption[]; agentMap?: Map; @@ -609,6 +613,7 @@ interface IssueChatThreadProps { reassignment?: CommentReassignment, attachmentIds?: string[], clientRequestId?: string, + runSettings?: ComposerRunSettings, ) => Promise; onReviewConversation?: () => Promise; onCancelRun?: () => Promise; @@ -625,6 +630,7 @@ interface IssueChatThreadProps { enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; suggestedAssigneeValue?: string; mentions?: MentionOption[]; composerPause?: TaskComposerPause | null; @@ -4659,6 +4665,8 @@ const IssueChatComposer = forwardRef< enableReassign = false, reassignOptions = [], currentAssigneeValue = "", + companyId, + assigneeAdapterOverrides, suggestedAssigneeValue, mentions = [], agentMap, @@ -4771,6 +4779,8 @@ const IssueChatComposer = forwardRef< const [reassignTarget, setReassignTarget] = useState( effectiveSuggestedAssigneeValue, ); + const [runSettings, setRunSettings] = useState(null); + useEffect(() => setRunSettings(null), [draftKey, currentAssigneeValue]); const [noAssigneeDialogOpen, setNoAssigneeDialogOpen] = useState(false); const [dismissedCoachToken, setDismissedCoachToken] = useState( null, @@ -4779,7 +4789,6 @@ const IssueChatComposer = forwardRef< const [pendingWorkMode, setPendingWorkMode] = useState( resolvedIssueWorkMode, ); - const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false); const canToggleWorkMode = typeof onWorkModeChange === "function"; const attachInputRef = useRef(null); const reassignTriggerRef = useRef(null); @@ -5054,10 +5063,11 @@ const IssueChatComposer = forwardRef< } // assistant-ui thread.append is fire-and-forget. Await the actual Board // mutation; it already owns optimistic echo and durable error handling. - const sendPromise = onSend( - submittedBody, reopen, reassignment, - attachmentIds.length ? attachmentIds : undefined, attemptId, - ); + const sendPromise = runSettings + ? onSend(submittedBody, reopen, reassignment, + attachmentIds.length ? attachmentIds : undefined, attemptId, runSettings) + : onSend(submittedBody, reopen, reassignment, + attachmentIds.length ? attachmentIds : undefined, attemptId); queueViewportRestore(viewportSnapshot); await sendPromise; // Settle the captured task even if the user navigated away. The exact @@ -5069,6 +5079,7 @@ const IssueChatComposer = forwardRef< current.filter((item) => !submittedAttachmentKeys.has(item.id)), ); setReassignTarget(effectiveSuggestedAssigneeValue); + setRunSettings(null); } catch (error) { if (mountedTaskKey.current !== draftKey) return; const nextDraft = bodyRef.current; @@ -5344,9 +5355,7 @@ const IssueChatComposer = forwardRef< ); } - const workModeOptions = workModeMetaList(); const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode); - const PendingWorkModeIcon = pendingWorkModeMeta.icon; function handleComposerKeyDown(evt: ReactKeyboardEvent) { // Match the period via both `code` and `key`: iOS Safari with a hardware @@ -5586,89 +5595,38 @@ const IssueChatComposer = forwardRef<
{canAcceptFiles ? ( - <> - - - - ) : null} - {canToggleWorkMode ? ( - - - {/* Single persistent mode chip (PAP-95b mockup rev 5): yellow in - planning, neutral in standard, caret opens the switch menu. */} - - - - {workModeOptions.map((option) => { - const Icon = option.icon; - const active = option.value === pendingWorkMode; - return ( - - ); - })} -
- Cmd/Ctrl+. cycles modes -
-
-
+ ) : null} + attachInputRef.current?.click() : undefined} + attachDisabled={attaching} + disabled={!!uncertainSubmission} + triggerTestId="issue-chat-composer-add" menuTestId="issue-chat-composer-add-menu" /> + setPendingWorkMode("standard") : undefined} + disabled={!!uncertainSubmission} + testId="issue-chat-composer-work-mode-chip" />
- {enableReassign && reassignOptions.length > 0 ? ( + {enableReassign && reassignOptions.length > 0 && companyId && agentMap ? ( + { + const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null; + return selected ? : null; + }} + /> + ) : enableReassign && reassignOptions.length > 0 ? ( ( - (body, reopen, reassignment, attachmentIds, clientRequestId) => { + (body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) => { pendingSubmitScrollRef.current = true; - return onAdd(body, reopen, reassignment, attachmentIds, clientRequestId); + return runSettings + ? onAdd(body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) + : onAdd(body, reopen, reassignment, attachmentIds, clientRequestId); }, [onAdd], ); @@ -6805,6 +6766,8 @@ export function IssueChatThread({ enableReassign={enableReassign} reassignOptions={reassignOptions} currentAssigneeValue={currentAssigneeValue} + companyId={companyId} + assigneeAdapterOverrides={assigneeAdapterOverrides} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentions} agentMap={agentMap} diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 6f88bbeeb1..c809a8cb28 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -63,6 +63,7 @@ import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { pinDocumentScrollToZero } from "../lib/pin-document-scroll"; import { cn } from "../lib/utils"; +import { classifyShellRoute } from "../lib/shell-navigation"; import { NotFoundPage } from "../pages/NotFound"; import { PluginSlotMount, @@ -145,6 +146,7 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); + const isTaskDetailRoute = classifyShellRoute(location.pathname, companyPrefix).isTaskDetail; const isCompanySettingsRoute = [ "/company/settings", "/company/export", @@ -738,8 +740,8 @@ export function Layout() { style={ isMobile ? ({ - "--tc-composer-bottom": mobileNavVisible - ? "var(--sz-calc-14)" + "--tc-composer-bottom": mobileNavVisible + ? "var(--tc-composer-visible-nav-offset)" : "var(--sz-calc-8)", } as CSSProperties) : undefined @@ -750,7 +752,9 @@ export function Layout() { // changes (e.g. switching skill-detail tabs) don't widen/shift // when the vertical scrollbar appears or disappears (PAP-10907). isMobile - ? "overflow-visible pb-(--sz-calc-14)" + ? isTaskDetailRoute && mobileNavVisible + ? "overflow-visible pb-(--tc-composer-visible-nav-offset)" + : "overflow-visible pb-(--sz-calc-14)" : "overflow-auto [scrollbar-gutter:stable]", )} > diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 79437ea31f..ccf674d853 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -740,7 +740,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { isMobile ? ({ "--tc-composer-bottom": mobileNavVisible - ? "var(--sz-calc-14)" + ? "var(--tc-composer-visible-nav-offset)" : "var(--tc-composer-hidden-nav-offset)", } as CSSProperties) : undefined @@ -755,8 +755,10 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { // changes (e.g. switching skill-detail tabs) don't widen/shift // when the vertical scrollbar appears or disappears (PAP-10907). isMobile - ? isTaskDetailRoute && !mobileNavVisible - ? "overflow-visible pb-(--tc-composer-hidden-nav-offset)" + ? isTaskDetailRoute + ? mobileNavVisible + ? "overflow-visible pb-(--tc-composer-visible-nav-offset)" + : "overflow-visible pb-(--tc-composer-hidden-nav-offset)" : "overflow-visible pb-(--sz-calc-14)" : "overflow-auto [scrollbar-gutter:stable]", )} diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 6ec28f29e8..34e75aee25 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -87,6 +87,7 @@ import { taskChatContentKey, } from "@/components/task-chat/TaskChatThreadView"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock"; import { RunnerGoalWidget, useRunnerGoalControl, @@ -510,6 +511,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode, reassignOptions, currentAssigneeValue, + assigneeAdapterOverrides, issueStatus, issueAssigneeAgentId = null, onAcceptInteraction, @@ -2985,28 +2987,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
) : null} {showComposer ? ( -
+ {composerAccessory} {tailTurnStatus ? ( @@ -3079,8 +3060,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode={conversationMode} reassignOptions={reassignOptions} agentMap={agentMap} + modelAgents={agentMap} userProfileMap={userProfileMap} currentAssigneeValue={currentAssigneeValue} + companyId={companyId} + assigneeAdapterOverrides={assigneeAdapterOverrides} onPendingAssigneeChange={setPendingComposerAssignee} issueStatus={issueStatus} mobile={isMobile} @@ -3106,7 +3090,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
{footer} - + ) : null} diff --git a/ui/src/components/task-chat/ComposerAddMenu.tsx b/ui/src/components/task-chat/ComposerAddMenu.tsx new file mode 100644 index 0000000000..a083b45d3d --- /dev/null +++ b/ui/src/components/task-chat/ComposerAddMenu.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from "react"; +import type { IssueWorkMode } from "@paperclipai/shared"; +import { Check, ClipboardList, MessageCircleQuestion, Paperclip, Plus, Target, X, type LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { workModeMetaFor } from "@/lib/work-mode-meta"; +import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import "./composer-run-settings.css"; + +const MOBILE_SHELL_QUERY = "(max-width: 767px)"; + +interface ComposerAddMenuProps { + mode: IssueWorkMode; + onModeChange?: (mode: IssueWorkMode) => void; + onAttachFile?: () => void; + attachDisabled?: boolean; + onGoal?: () => void; + disabled?: boolean; + mobile?: boolean; + triggerTestId?: string; + menuTestId?: string; +} + +export function ComposerAddMenu({ + mode, onModeChange, onAttachFile, attachDisabled, onGoal, disabled, mobile: mobileProp, triggerTestId, menuTestId, +}: ComposerAddMenuProps) { + const [open, setOpen] = useState(false); + const goalFocusRef = useRef(false); + const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_SHELL_QUERY).matches); + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const query = window.matchMedia(MOBILE_SHELL_QUERY); + const update = () => setNarrow(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + const mobile = mobileProp ?? narrow; + if (!onModeChange && !onAttachFile && !onGoal) return null; + const actions: Array<{ id: string; label: string; detail?: string; Icon: LucideIcon; select: () => void; disabled?: boolean; selected?: boolean }> = [ + ...(onAttachFile ? [{ id: "composer-add-file", label: "Files and images", Icon: Paperclip, select: onAttachFile, disabled: attachDisabled }] : []), + ...(onGoal ? [{ id: "composer-add-goal", label: "Goal", detail: "Keep pursuing", Icon: Target, select: onGoal }] : []), + ...(onModeChange ? [ + { id: "composer-add-plan", label: "Plan mode", detail: "Plan before acting", Icon: ClipboardList, select: () => onModeChange(mode === "planning" ? "standard" : "planning"), selected: mode === "planning" }, + { id: "composer-add-ask", label: "Ask mode", detail: "Answer without changes", Icon: MessageCircleQuestion, select: () => onModeChange(mode === "ask" ? "standard" : "ask"), selected: mode === "ask" }, + ] : []), + ]; + const trigger = ; + const content = (action: typeof actions[number]) => <> + + {action.label}{action.detail ? {action.detail} : null} + {action.selected ? : null} + ; + if (mobile) return + {trigger} + { if (goalFocusRef.current) { event.preventDefault(); goalFocusRef.current = false; } }} + className="composer-mobile-dialog top-(--pct-50) -translate-y-(--pct-50) gap-0 overflow-y-auto p-0"> +
+ Add + +
+
{actions.map((action) => )}
+
+
; + return + {trigger} + +
Add
+ {actions.map((action) => + {content(action)} + )} +
+
; +} + +interface ComposerModeChipProps { + mode: IssueWorkMode; + onRemove?: () => void; + disabled?: boolean; + testId?: string; + mobile?: boolean; +} + +export function ComposerModeChip({ mode, onRemove, disabled, testId, mobile = false }: ComposerModeChipProps) { + if (mode === "standard") return null; + const meta = workModeMetaFor(mode); + const Icon = meta.icon; + return ; +} diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx new file mode 100644 index 0000000000..e09a165b4c --- /dev/null +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Agent } from "@paperclipai/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { agentsApi } from "@/api/agents"; +import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; + +const agent = { + id: "a1", companyId: "company-1", name: "Clippy", + role: "Engineering Lead", + adapterType: "codex_local", adapterConfig: { model: "gpt-6-sol" }, +} as unknown as Agent; +const options = [{ id: "agent:a1", label: "Clippy" }]; +const agents = new Map([[agent.id, agent]]); +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; +globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + unobserve() {} +}; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function click(label: string) { + const button = document.querySelector(`button[aria-label="${label}"]`) + ?? [...document.querySelectorAll('button[role="option"]')].find((item) => item.textContent?.trim().startsWith(label)); + expect(button).toBeDefined(); + flushSync(() => button!.click()); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); +} + +function render(onAssigneeChange: (value: string) => void, onSettingsChange: () => void, useCatalog = false) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => root!.render( + + )); +} + +afterEach(() => { + vi.restoreAllMocks(); + flushSync(() => root?.unmount()); + root = null; + container?.remove(); + container = null; +}); + +describe("composer assignee picker", () => { + it("finds assignees by their displayed role and harness", async () => { + render(vi.fn(), vi.fn()); + await click("Select assignee, model and effort"); + await click("Choose assignee"); + const input = document.querySelector('input[aria-label="Search assignees"]'); + expect(input).not.toBeNull(); + const setValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + for (const query of ["Engineering Lead", "Codex"]) { + flushSync(() => { + setValue.call(input, query); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect([...document.querySelectorAll('button[role="option"]')] + .some((option) => option.textContent?.includes("Clippy"))).toBe(true); + } + }); + + it("offers the Codex CLI catalog instead of unrelated OpenAI API models", async () => { + vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([ + { id: "gpt-6-sol", label: "GPT-6 Sol" }, + { id: "gpt-5.5", label: "GPT-5.5" }, + ]); + render(vi.fn(), vi.fn(), true); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + await click("Select assignee, model and effort"); + await click("Choose exact model"); + const options = [...document.querySelectorAll('button[role="option"]')] + .map((item) => item.textContent ?? ""); + expect(options.some((item) => item.includes("gpt-5.5"))).toBe(true); + expect(options.some((item) => item.includes("gpt-6-sol"))).toBe(true); + expect(options.some((item) => item.includes("gpt-image"))).toBe(false); + expect(options.some((item) => item.includes("text-embedding"))).toBe(false); + expect(document.body.textContent).not.toContain("Loading models…"); + }); + + it("shows an instance-declared Codex model list instead of bundled alternatives", async () => { + const loadModels = vi.spyOn(agentsApi, "adapterModels").mockResolvedValueOnce([ + { id: "private-codex", label: "Private Codex" }, + ]); + render(vi.fn(), vi.fn(), true); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + expect(loadModels).toHaveBeenCalledWith("company-1", "codex_local", { + environmentId: null, + provider: undefined, + }); + await click("Select assignee, model and effort"); + await click("Choose exact model"); + const choices = [...document.querySelectorAll('button[role="option"]')] + .map((item) => item.textContent ?? ""); + expect(choices.some((item) => item.includes("Private Codex"))).toBe(true); + expect(choices.some((item) => item.includes("GPT-6 Sol"))).toBe(false); + }); + + it("preserves settings when the selected assignee is chosen again", async () => { + const onAssigneeChange = vi.fn(); + const onSettingsChange = vi.fn(); + render(onAssigneeChange, onSettingsChange); + await click("Select assignee, model and effort"); + await click("Choose assignee"); + await click("Clippy"); + expect(onAssigneeChange).not.toHaveBeenCalled(); + expect(onSettingsChange).not.toHaveBeenCalled(); + }); + + it("offers No assignee and clears settings when selected", async () => { + const onAssigneeChange = vi.fn(); + const onSettingsChange = vi.fn(); + render(onAssigneeChange, onSettingsChange); + await click("Select assignee, model and effort"); + await click("Choose assignee"); + await click("No assignee"); + expect(onAssigneeChange).toHaveBeenCalledWith(""); + expect(onSettingsChange).toHaveBeenCalledWith({ model: null, effort: null, fast: false }); + }); +}); diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx new file mode 100644 index 0000000000..d16e52a42c --- /dev/null +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -0,0 +1,226 @@ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react"; +import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; +import { agentsApi, type AdapterModel } from "@/api/agents"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import type { InlineEntityOption } from "@/components/InlineEntitySelector"; +import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + composerCatalogProvider, composerEfforts, composerFastAvailable, DEFAULT_COMPOSER_RUN_SETTINGS, + EFFORT_LABELS, readComposerRunSettings, supportsComposerModel, + type ComposerRunSettings, +} from "./composer-run-settings"; +import "./composer-run-settings.css"; + +interface Props { + companyId: string; + assigneeValue: string; + currentAssigneeValue: string; + options: InlineEntityOption[]; + agents: ReadonlyMap; + overrides?: IssueAssigneeAdapterOverrides | null; + settings: ComposerRunSettings | null; + onSettingsChange: (settings: ComposerRunSettings) => void; + onAssigneeChange: (value: string) => void; + disabled?: boolean; + mobile?: boolean; + triggerRef?: Ref; + /** Storybook can supply a fixed catalog; the app loads it for the selected harness. */ + modelOptionsOverride?: readonly (AdapterModel & { detail?: string })[]; + renderAssigneeIdentity?: (value: string, label: string, placement: "trigger" | "option") => ReactNode; + /** Initial presentation state for embedded examples; ordinary composers start closed. */ + initialOpen?: boolean; + initialView?: "settings" | "agents" | "models"; + initialModelSearch?: string; + initialAssigneeSearch?: string; +} + +const HARNESS_LABELS: Record = { + claude_local: "Claude Code", codex_local: "Codex", opencode_local: "OpenCode", + pi_local: "Pi", kimi_local: "Kimi Code", gemini_local: "Gemini CLI", + cursor: "Cursor", cursor_cloud: "Cursor Cloud", grok_local: "Grok CLI", + hermes_local: "Hermes CLI", paperclip_runner: "Paperclip Runner", + process: "Process", http: "HTTP", openclaw_gateway: "OpenClaw Gateway", + hermes_gateway: "Hermes Gateway", +}; + +function harnessLabel(agent: Agent | undefined): string { + if (!agent) return "Choose an agent"; + const harness = HARNESS_LABELS[agent.adapterType] ?? agent.adapterType; + const provider = composerCatalogProvider(agent); + return provider === "openrouter" ? `${harness} · OpenRouter` : harness; +} + +function unavailableModelReason(agent: Agent | undefined): string { + if (!agent) return "Choose an agent to select its model and effort."; + if (agent?.adapterType === "process") return "This agent runs a command. Its harness does not expose a model or effort setting."; + if (agent?.adapterType === "http") return "This agent calls an HTTP endpoint. The destination service chooses its model."; + return "This gateway chooses its model remotely; Paperclip has no per-task model setting for it."; +} + +function AnimatedBody({ children }: { children: ReactNode }) { + const ref = useRef(null); + const [height, setHeight] = useState(null); + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + const measure = () => setHeight(element.getBoundingClientRect().height); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, []); + return
{children}
; +} + +export function ComposerRunSettingsPicker({ + companyId, assigneeValue, currentAssigneeValue, options, agents, overrides, + settings, onSettingsChange, onAssigneeChange, disabled = false, mobile: mobileProp, triggerRef, modelOptionsOverride, renderAssigneeIdentity, + initialOpen = false, initialView = "settings", initialModelSearch = "", initialAssigneeSearch = "", +}: Props) { + const [open, setOpen] = useState(initialOpen); + const [view, setView] = useState<"settings" | "agents" | "models">(initialView); + const [modelSearch, setModelSearch] = useState(initialModelSearch); + const [assigneeSearch, setAssigneeSearch] = useState(initialAssigneeSearch); + const [highlightedAssignee, setHighlightedAssignee] = useState(0); + const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 639px)").matches); + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + const query = window.matchMedia("(max-width: 639px)"); + const update = () => setNarrow(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + const mobile = mobileProp ?? narrow; + const agentId = assigneeValue.startsWith("agent:") ? assigneeValue.slice(6) : ""; + const agent = agents.get(agentId); + const modelSupported = supportsComposerModel(agent); + const provider = composerCatalogProvider(agent); + const { data: fetchedModels = [], isPending: modelsPending } = useQuery({ + queryKey: agent && modelSupported + ? queryKeys.agents.adapterModels(companyId, agent.adapterType, agent.defaultEnvironmentId ?? null, provider) + : ["agents", "composer-models", "none"], + queryFn: () => agentsApi.adapterModels(companyId, agent!.adapterType, { + environmentId: agent!.defaultEnvironmentId ?? null, provider, + }), + enabled: Boolean(agent && modelSupported && !modelOptionsOverride), + }); + // The server resolves instance-declared models first and otherwise returns the + // adapter's curated catalog. Do not replace a declared Codex list locally. + const models: readonly (AdapterModel & { detail?: string })[] = modelOptionsOverride + ?? fetchedModels; + const catalogPending = modelsPending && !modelOptionsOverride; + const base = assigneeValue === currentAssigneeValue + ? readComposerRunSettings(overrides, agent?.adapterType) + : DEFAULT_COMPOSER_RUN_SETTINGS; + const selected = settings ?? base; + const configuredModel = typeof agent?.adapterConfig.model === "string" ? agent.adapterConfig.model : ""; + const model = selected.model ?? configuredModel; + const modelName = models.find((item) => item.id === model)?.label ?? model ?? ""; + const choices = composerEfforts(agent, model, models.map((item) => item.id)); + const effort = selected.effort && choices.includes(selected.effort) ? selected.effort : null; + const effortIndex = effort ? choices.indexOf(effort) + 1 : 0; + const effortLabel = effort ? EFFORT_LABELS[effort] ?? effort : "Default"; + const fastAvailable = composerFastAvailable(agent, model); + const changed = Boolean(selected.model || selected.effort || selected.fast); + const assigneeOptions = [{ id: "", label: "No assignee", searchText: "Unassigned" }, ...options.filter((item) => item.id !== "")]; + const filteredAgents = assigneeOptions.filter((item) => { + const optionAgent = agents.get(item.id.startsWith("agent:") ? item.id.slice(6) : ""); + return `${item.label} ${item.searchText ?? ""} ${optionAgent?.role ?? ""} ${harnessLabel(optionAgent)}` + .toLowerCase().includes(assigneeSearch.trim().toLowerCase()); + }); + const query = modelSearch.trim(); + const filteredModels = models.filter((item) => + `${item.label} ${item.id}`.toLowerCase().includes(query.toLowerCase())); + const exactMatch = models.some((item) => item.id.toLowerCase() === query.toLowerCase()); + const needsProvider = agent && ["opencode_local", "pi_local", "kimi_local"].includes(agent.adapterType); + const manualValid = query.length > 0 && !/\s/.test(query) + && (!needsProvider || /^[^/]+\/.+[^/]$/.test(query)) + && (provider !== "openrouter" || /^openrouter\/[^/]+\/.+[^/]$/.test(query)); + + const previousAgentId = useRef(agentId); + useEffect(() => { + if (previousAgentId.current === agentId) return; + previousAgentId.current = agentId; + setView("settings"); + setModelSearch(""); + setAssigneeSearch(""); + }, [agentId]); + + const chooseAssignee = (value: string) => { + if (value !== assigneeValue) { + onAssigneeChange(value); + onSettingsChange(DEFAULT_COMPOSER_RUN_SETTINGS); + } + setView("settings"); + setAssigneeSearch(""); + }; + const chooseModel = (value: string | null) => { + onSettingsChange({ model: value, effort: null, fast: false }); + setView("settings"); + setModelSearch(""); + }; + const reset = () => onSettingsChange(DEFAULT_COMPOSER_RUN_SETTINGS); + const closeButton = mobile ? : null; + const trigger = ; + + const body = view === "settings" ?
+
+ + {!choices.length && modelSupported ? : null} + {closeButton} +
+ {modelSupported ? <> + + {choices.length ?
+
+ {fastAvailable ? : } + + +
+ onSettingsChange({ ...selected, effort: Number(event.target.value) === 0 ? null : choices[Number(event.target.value) - 1] })} + className="composer-run-effort-range mt-3 w-full" style={{ "--fill": `${effortIndex / choices.length * 100}%` } as CSSProperties} /> +
: null} + :
{unavailableModelReason(agent)}
} +
: view === "agents" ?
+
Choose assigneeEach agent keeps its configured harness.{closeButton}
+
{ setAssigneeSearch(event.target.value); setHighlightedAssignee(0); }} onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setHighlightedAssignee((current) => filteredAgents.length ? (current + (event.key === "ArrowDown" ? 1 : -1) + filteredAgents.length) % filteredAgents.length : 0); } + if (event.key === "Enter" && filteredAgents.length) { event.preventDefault(); chooseAssignee(filteredAgents[Math.min(highlightedAssignee, filteredAgents.length - 1)]!.id); } + }} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
+
{filteredAgents.map((item, index) => )}{!filteredAgents.length ?

No matches.

: null}
+
:
+
Choose model{harnessLabel(agent)}{closeButton}
+
setModelSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && manualValid && !exactMatch) chooseModel(query); }} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
+
+ {!query ? : null} + {filteredModels.map((item) => )} + {catalogPending ?

Loading models…

: null} + {!catalogPending && !filteredModels.length && query ?

No catalog match.

: null} +
+ {query && !exactMatch ?
{!manualValid ?

{provider === "openrouter" ? "Use openrouter/provider/model with no spaces." : "Model IDs cannot contain spaces."}

: null}
: null} +

{provider === "openrouter" ? "Custom IDs: openrouter/provider/model. Provider access is checked when the run starts." : "Custom model IDs can be pasted here. Provider access is checked when the run starts."}

+
; + + const onOpenChange = (next: boolean) => { setOpen(next); if (!next) { setView("settings"); setModelSearch(""); setAssigneeSearch(""); } }; + return mobile ? {trigger}Select assignee, model and effort{body} + : {trigger}{body}; +} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 05ef7f9955..05a2a1d074 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -9,6 +9,7 @@ import { buildSkillMentionHref, } from "@paperclipai/shared"; import { parseRunnerGoalCommand, TaskChatComposer } from "./TaskChatComposer"; +import { ComposerAddMenu } from "./ComposerAddMenu"; import { QuestionForm } from "./QuestionForm"; import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft"; import { @@ -248,6 +249,12 @@ function pressKey( }); } +function openComposerAddMenu() { + const add = container.querySelector('[data-testid="task-chat-composer-add"]')!; + flushSync(() => add.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }))); + return add; +} + function pasteFiles(files: File[]) { const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { @@ -776,7 +783,7 @@ describe("TaskChatComposer", () => { />, ); - const composer = container.firstElementChild as HTMLElement; + const composer = container.querySelector(".paperclip-task-chat-composer")!; const mode = container.querySelector( '[data-testid="task-chat-composer-mode"]', )!; @@ -792,8 +799,8 @@ describe("TaskChatComposer", () => { expect(composer.classList).toContain("dark:bg-muted"); expect(composer.classList).toContain("dark:shadow-none"); expect(composer.className).not.toContain("focus-within:ring"); - expect(mode.classList).not.toContain("border"); - expect(mode.className).not.toContain("ring-"); + expect(mode.classList).toContain("rounded-full"); + expect(mode.getAttribute("aria-label")).toBe("Remove Plan mode"); expect(runner.classList).toContain("border-0"); expect(runner.classList).not.toContain("border"); expect(runner.className).not.toContain("ring-2"); @@ -802,7 +809,7 @@ describe("TaskChatComposer", () => { it("scopes the wrapping placeholder override to the task-chat composer", () => { render(); - expect(container.firstElementChild?.classList).toContain( + expect(container.querySelector("[data-testid='task-chat-composer-input']")?.parentElement?.classList).toContain( "paperclip-task-chat-composer", ); }); @@ -865,13 +872,13 @@ describe("TaskChatComposer", () => { />, ); + expect(container.querySelector('[data-testid="task-chat-composer-add"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull(); + + pressKey("Tab", { shiftKey: true }); const chip = container.querySelector( '[data-testid="task-chat-composer-mode"]', )!; - expect(chip.getAttribute("data-pending-work-mode")).toBe("standard"); - expect(chip.textContent).toContain("Auto"); - - pressKey("Tab", { shiftKey: true }); expect(chip.getAttribute("data-pending-work-mode")).toBe("planning"); expect(chip.textContent).toContain("Plan"); @@ -893,13 +900,10 @@ describe("TaskChatComposer", () => { />, ); - const chip = container.querySelector( - '[data-testid="task-chat-composer-mode"]', - )!; editable().focus(); - expect(chip.getAttribute("aria-keyshortcuts")).toContain("Meta+Period"); - expect(chip.getAttribute("data-pending-work-mode")).toBe("standard"); + expect(container.querySelector('[data-testid="task-chat-composer-add"]')?.getAttribute("aria-keyshortcuts")).toContain("Meta+Period"); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull(); const cycleMode = () => { const event = new KeyboardEvent("keydown", { @@ -914,6 +918,7 @@ describe("TaskChatComposer", () => { }; cycleMode(); + const chip = container.querySelector('[data-testid="task-chat-composer-mode"]')!; expect(chip.getAttribute("data-pending-work-mode")).toBe("planning"); expect(chip.textContent).toContain("Plan"); @@ -922,11 +927,79 @@ describe("TaskChatComposer", () => { expect(chip.textContent).toContain("Ask"); cycleMode(); - expect(chip.getAttribute("data-pending-work-mode")).toBe("standard"); - expect(chip.textContent).toContain("Auto"); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull(); expect(onWorkModeChange).not.toHaveBeenCalled(); }); + it("selects exclusive modes from the add menu and removes the active chip", () => { + render(); + openComposerAddMenu(); + expect(document.querySelector('[data-testid="composer-add-file"]')).toBeNull(); + flushSync(() => (document.querySelector('[data-testid="composer-add-plan"]') as HTMLElement).click()); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Plan mode"); + + openComposerAddMenu(); + flushSync(() => (document.querySelector('[data-testid="composer-add-ask"]') as HTMLElement).click()); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Ask mode"); + + flushSync(() => (container.querySelector('[data-testid="task-chat-composer-mode"]') as HTMLElement).click()); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull(); + }); + + it("opens a mobile Add dialog while keeping Send at the end of the footer", () => { + render(); + + const actions = container.querySelector('[data-testid="task-chat-composer-actions"]')!; + expect(actions.lastElementChild?.lastElementChild).toBe(sendButton()); + expect(actions.firstElementChild?.contains(sendButton())).toBe(false); + expect(actions.lastElementChild?.querySelector('[data-testid="task-chat-composer-assignee"]')).not.toBeNull(); + expect(actions.querySelector('[data-testid="task-chat-composer-assignee"] [data-slot="agent-avatar"] img')).not.toBeNull(); + + flushSync(() => container.querySelector('[data-testid="task-chat-composer-add"]')!.click()); + const dialog = document.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("Files and images"); + expect(dialog?.textContent).toContain("Plan mode"); + expect(dialog?.textContent).toContain("Ask mode"); + + flushSync(() => document.querySelector('[data-testid="composer-add-plan"]')!.click()); + const mode = container.querySelector('[data-testid="task-chat-composer-mode"]')!; + expect(mode.textContent).toContain("Plan mode"); + expect(actions.contains(mode)).toBe(true); + expect(mode.querySelector(".sr-only")?.textContent).toBe("Plan mode"); + expect(document.querySelector('[role="dialog"][data-state="open"]')).toBeNull(); + }); + + it("opens the mobile file picker from the Add dialog", () => { + render(); + const input = container.querySelector('input[type="file"]')!; + const openPicker = vi.spyOn(input, "click").mockImplementation(() => {}); + flushSync(() => container.querySelector('[data-testid="task-chat-composer-add"]')!.click()); + flushSync(() => document.querySelector('[data-testid="composer-add-file"]')!.click()); + expect(openPicker).toHaveBeenCalledOnce(); + }); + + it("uses the Add dialog through the mobile shell's tablet breakpoint", () => { + const matchMedia = vi.fn((query: string) => ({ + media: query, + matches: query === "(max-width: 767px)", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList)); + vi.stubGlobal("matchMedia", matchMedia); + try { + render(); + flushSync(() => container.querySelector('[aria-label="Add to composer"]')!.click()); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + expect(matchMedia).toHaveBeenCalledWith("(max-width: 767px)"); + } finally { + vi.unstubAllGlobals(); + } + }); + it("uses the borderless Paper controls and inverse circular send button", () => { render( { />, ); - const mode = container.querySelector( - '[data-testid="task-chat-composer-mode"]', + const add = container.querySelector( + '[data-testid="task-chat-composer-add"]', )!; const assignee = container.querySelector( '[data-testid="task-chat-composer-assignee"]', )!; const send = sendButton(); - expect(mode.classList).not.toContain("border"); - expect(mode.classList).toContain("border-0"); - expect(mode.classList).toContain("status-chip"); - expect(mode.style.getPropertyValue("--sc")).toBe("var(--tc-mode-agent)"); + expect(add.classList).not.toContain("border"); + expect(add.getAttribute("aria-label")).toBe("Add to composer"); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')).toBeNull(); expect(assignee.classList).toContain("border-0"); expect(assignee.classList).toContain("shadow-none"); expect(send.classList).toContain("rounded-full"); @@ -977,10 +1049,10 @@ describe("TaskChatComposer", () => { expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined, undefined, expect.any(String)); }); - it("hides the attach button without an upload handler and shows it with one", () => { + it("shows the add menu only when at least one action is available", () => { render(); expect( - container.querySelector('[data-testid="task-chat-composer-attach"]'), + container.querySelector('[data-testid="task-chat-composer-add"]'), ).toBeNull(); render( @@ -991,10 +1063,20 @@ describe("TaskChatComposer", () => { />, ); expect( - container.querySelector('[data-testid="task-chat-composer-attach"]'), + container.querySelector('[data-testid="task-chat-composer-add"]'), ).not.toBeNull(); }); + it("opens the file picker from the add menu", () => { + render(); + const input = container.querySelector('input[type="file"]')!; + const openPicker = vi.spyOn(input, "click").mockImplementation(() => {}); + openComposerAddMenu(); + flushSync(() => document.querySelector('[data-testid="composer-add-file"]')!.click()); + expect(openPicker).toHaveBeenCalledOnce(); + }); + it("wires the editor's inline image upload to onAttachImage and returns the attachment URL", async () => { const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/shot.png", @@ -1396,6 +1478,27 @@ describe("TaskChatComposer", () => { expect(editable().textContent).toBe(""); }); + it("shows Goal in the add menu only for a supported agent and prepares the command", async () => { + const onRunnerGoalCommand = vi.fn(); + render(); + typeText("Ship the feature"); + openComposerAddMenu(); + const goal = document.querySelector('[data-testid="composer-add-goal"]'); + expect(goal).not.toBeNull(); + flushSync(() => goal!.click()); + await flushAsync(); + expect(editable().textContent).toBe("/goal Ship the feature"); + expect(onRunnerGoalCommand).not.toHaveBeenCalled(); + + render(); + openComposerAddMenu(); + expect(document.querySelector('[data-testid="composer-add-goal"]')).toBeNull(); + }); + it("commits a pending agent reassignment before starting the goal", async () => { const order: string[] = []; const onAdd = vi.fn().mockResolvedValue(undefined); @@ -1432,6 +1535,10 @@ describe("TaskChatComposer", () => { flushSync(() => option!.click()); await flushAsync(); + openComposerAddMenu(); + expect(document.querySelector('[data-testid="composer-add-goal"]')).not.toBeNull(); + flushSync(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); + typeText("/goal Ship the feature"); pressKey("Enter", { metaKey: true }); await flushAsync(); @@ -1962,7 +2069,54 @@ describe("TaskChatComposer", () => { }); describe("composer takeovers", () => { - it("replaces the editor with one action surface and exposes Skip", async () => { + it.each([false, true])("keeps the interaction card visible while sending a normal message (mobile=%s)", async (mobile) => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render( + Which environment?

, + onDismiss: vi.fn(), + onSkip: vi.fn(), + }} + />, + ); + + typeText("Continue investigating while I decide."); + expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')?.textContent).toContain("Which environment?"); + await act(async () => sendButton().click()); + expect(onAdd).toHaveBeenCalledWith("Continue investigating while I decide.", undefined, undefined, undefined, expect.any(String)); + expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')?.textContent).toContain("Which environment?"); + expect(editable().textContent).toBe(""); + }); + + it("keeps the composer mode shortcut available beneath an open question", () => { + render( + Which environment?

, + onDismiss: vi.fn(), + onSkip: vi.fn(), + }} + />, + ); + pressKey(".", { metaKey: true }); + expect(container.querySelector('[data-testid="task-chat-composer-mode"]')?.textContent).toContain("Plan"); + expect(container.querySelector('[data-testid="task-chat-composer-takeover"]')).not.toBeNull(); + }); + + it("shows a separate card above a usable editor and exposes Skip", async () => { const onSkip = vi.fn().mockResolvedValue(undefined); render( { container.querySelector('[data-testid="task-chat-composer-takeover"]') ?.textContent, ).toContain("Which environment should receive this?"); - expect(container.querySelector('[data-testid="mdx-editor"]')).toBeNull(); + const card = container.querySelector('[data-testid="task-chat-composer-takeover"]')!; + const composer = container.querySelector('.paperclip-task-chat-composer')!; + expect(card.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(container.querySelector('[data-testid="mdx-editor"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="task-chat-composer-send"]')).not.toBeNull(); expect(container.textContent).not.toContain("Input needed"); expect(container.textContent).not.toContain("Write instead"); const skip = Array.from( diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 94fb3b2d1b..36e2f7a7b0 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -5,7 +5,6 @@ import { useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, - type CSSProperties, type ReactNode, } from "react"; import { cn } from "@/lib/utils"; @@ -28,20 +27,12 @@ import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result"; import { ArrowUp, Square, - Check, ChevronDown, CircleHelp, Loader2, - Plus, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Attachment, AttachmentAction, @@ -57,11 +48,7 @@ import { MarkdownEditor, type MarkdownEditorRef, } from "@/components/MarkdownEditor"; -import { - nextWorkMode, - workModeMetaFor, - workModeMetaList, -} from "@/lib/work-mode-meta"; +import { nextWorkMode } from "@/lib/work-mode-meta"; import { InlineEntitySelector, type InlineEntityOption, @@ -74,6 +61,10 @@ import type { ActionCommandOption } from "@/context/EditorAutocompleteContext"; import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeoverContext"; import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover"; +import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; +import { ComposerAddMenu, ComposerModeChip } from "./ComposerAddMenu"; +import type { ComposerRunSettings } from "./composer-run-settings"; +import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; /** Structurally identical to IssueChatThread's module-private CommentReassignment. */ export interface CommentReassignment { @@ -104,6 +95,7 @@ interface TaskChatComposerProps { reassignment?: CommentReassignment, attachmentIds?: string[], clientRequestId?: string, + runSettings?: ComposerRunSettings, ) => Promise | void; confirmedSubmissionIds?: ReadonlySet; onStop?: () => Promise; @@ -124,6 +116,9 @@ interface TaskChatComposerProps { conversationMode?: boolean; reassignOptions?: InlineEntityOption[]; agentMap?: ReadonlyMap; + modelAgents?: ReadonlyMap; + companyId?: string | null; + assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null; userProfileMap?: ReadonlyMap< string, { label: string; image: string | null } @@ -219,17 +214,6 @@ export function parseRunnerGoalCommand(value: string): ParsedRunnerGoalCommand { return { matched: true, command: { action: "create", objective: remainder } }; } -/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */ -const MODE_HUE: Partial> = { - standard: "var(--tc-mode-agent)", - planning: "var(--tc-mode-plan)", - ask: "var(--tc-mode-ask)", -}; - -function modeHue(mode: IssueWorkMode): string { - return MODE_HUE[mode] ?? "var(--tc-mode-agent)"; -} - function identityInitials(label: string): string { const parts = label.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; @@ -295,12 +279,6 @@ function AssigneeIdentityAvatar({ return null; } -const MODE_DESCRIPTION: Partial> = { - standard: "Make changes and run work", - planning: "Draft a plan before acting", - ask: "Answer questions only, no changes", -}; - /** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */ function modePlaceholder(mode: IssueWorkMode, agentName: string, mobile: boolean): string { if (mobile) { @@ -350,6 +328,7 @@ function shouldImplicitlyReopenComment( } function parseAssigneeValue(value: string): CommentReassignment | undefined { + if (!value) return { assigneeAgentId: null, assigneeUserId: null }; if (value.startsWith("agent:")) { const id = value.slice("agent:".length); return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined; @@ -368,9 +347,8 @@ function escapeMarkdownLabel(name: string): string { /** * Composer for the redesigned thread (v7 spec): the shared MarkdownEditor * (rich lists, @-mentions, /-commands, inline pasted images) over a 32px - * comp-bar of [attach] [mode chip] … [assignee] [send]. The mode chip is a - * borderless filled control carrying the pending mode's hue; the composer chrome - * itself stays neutral. Cmd/Ctrl+. and Shift+Tab cycle modes (captured before + * comp-bar of [add] [optional mode chip] … [assignee] [send]. The add menu + * offers files, supported goals, Plan, and Ask. Cmd/Ctrl+. and Shift+Tab cycle modes (captured before * Lexical); Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter * stays a newline / next list item. Pasted or dropped images upload through * `onAttachImage` (or the `onImageUpload` fallback) and land inline at the @@ -395,6 +373,9 @@ export function TaskChatComposer({ conversationMode = false, reassignOptions, agentMap, + modelAgents, + companyId, + assigneeAdapterOverrides, userProfileMap, currentAssigneeValue = "", onPendingAssigneeChange, @@ -438,6 +419,8 @@ export function TaskChatComposer({ useState(null); const [pendingMode, setPendingMode] = useState(workMode); const [pendingAssignee, setPendingAssignee] = useState(null); + const [runSettings, setRunSettings] = useState(null); + useEffect(() => setRunSettings(null), [draftKey, currentAssigneeValue]); const [actionError, setActionError] = useState(null); const [attachments, setAttachmentState] = useState( () => @@ -620,7 +603,6 @@ export function TaskChatComposer({ return () => window.removeEventListener("beforeunload", flushDraft); }, [draftKey]); - const modeMeta = workModeMetaFor(pendingMode); const canAcceptFiles = !pause && !queuedEdit && @@ -789,6 +771,13 @@ export function TaskChatComposer({ evt.target.value = ""; } + function prepareGoal() { + const current = bodyRef.current.trim(); + changeBody(/^\/goal(?:\s|$)/.test(current) ? current : `/goal ${current}`); + setActionError(null); + requestAnimationFrame(() => editorRef.current?.focus()); + } + /** * Pasted image files fall through to the editor's image plugin (inline at * the caret); non-image files are attached to the task here. Only swallow @@ -828,9 +817,7 @@ export function TaskChatComposer({ Boolean(onStop || stopControl.stopping); const uploadPending = attachments.some((item) => item.status === "uploading"); const uploadFailed = attachments.some((item) => item.status === "error"); - const takeoverVisible = Boolean( - takeover && !pause && !queuedEdit && !submitting && !uploadPending, - ); + const takeoverVisible = Boolean(takeover && !pause); const previousTakeoverVisibleRef = useRef(takeoverVisible); useEffect(() => { if (previousTakeoverVisibleRef.current && !takeoverVisible && !queuedEdit) { @@ -891,7 +878,7 @@ export function TaskChatComposer({ showAssignee && assigneeValue !== currentAssigneeValue; if (hasReassignment && goalCommand.command.action !== "focus") { const reassignment = parseAssigneeValue(assigneeValue); - if (!reassignment || !onRunnerGoalReassign) { + if (!reassignment?.assigneeAgentId || !onRunnerGoalReassign) { setActionError("Select an agent before starting a session goal."); return; } @@ -1003,7 +990,11 @@ export function TaskChatComposer({ pendingDraftRef.current = { draftKey, attemptId, submittedBody, submittedAttachmentIds: attachmentIds }; changeBody(bodyRef.current); } - await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId); + if (runSettings) { + await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId, runSettings); + } else { + await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId); + } // Navigation does not invalidate the server receipt. Settle the captured // task before checking whether this composer is still on screen. if (draftKey) settleDraftSubmission(draftKey, attemptId, @@ -1016,6 +1007,7 @@ export function TaskChatComposer({ if (pendingAssigneeRef.current === submittedAssignee) { updatePendingAssignee(null); } + setRunSettings(null); } catch (error) { if (mountedTaskKey.current !== draftKey) return; const nextDraft = bodyRef.current; @@ -1124,19 +1116,83 @@ export function TaskChatComposer({ } return ( -
+ {takeoverVisible && takeover ? ( +
+
+
+ {!takeoverHeaderClaimed && !takeover.hideLabel ? ( + + {takeover.label} + + ) : null} +
+
+
+ {takeover.pendingCount > 1 ? ( + + ) : null} +
+ +
+
+
+ + {takeover.content} + +
+ {takeoverError ?

{takeoverError}

: null} + {!takeover.inlineSkip && !takeover.hideSkip ? ( +
{takeoverSkipButton}
+ ) : null} +
+ ) : null} +
{ + onKeyDownCapture={(e) => { // Capture mode shortcuts on the wrapper so they work while the rich // editor is focused and win over Lexical/browser bindings. Match the // period by key and code because hardware keyboards on iOS can omit // `code` for Cmd+Period. - if (disabled || queuedEdit || takeoverVisible) return; + if (disabled || queuedEdit) return; const isPeriod = e.key === "." || e.code === "Period"; const isModeShortcut = (isPeriod && (e.metaKey || e.ctrlKey)) || @@ -1147,9 +1203,9 @@ export function TaskChatComposer({ setPendingMode((mode) => nextWorkMode(mode)); } }} - onPasteCapture={handlePasteCapture} - > - {uncertainSubmission ? ( + onPasteCapture={handlePasteCapture} + > + {uncertainSubmission ? (
) : null} - {takeoverVisible && takeover ? ( -
-
-
- {!takeoverHeaderClaimed && !takeover.hideLabel ? ( - - {takeover.label} - - ) : null} -
-
-
- {takeover.pendingCount > 1 ? ( - - ) : null} -
- -
-
-
- - {takeover.content} - -
- {takeoverError ? ( -

- {takeoverError} -

- ) : null} - {!takeover.inlineSkip && !takeover.hideSkip ? ( -
- {takeoverSkipButton} -
- ) : null} -
- ) : ( - <> - {pendingTakeover || takeover ? ( - - ) : null} + + + {pendingTakeover?.label ?? takeover?.label ?? "Pending input"} + + + {pendingTakeover?.count ?? takeover?.pendingCount ?? 1} pending + + + ) : null} {pause && conversationMode ? (
@@ -1408,31 +1369,25 @@ export function TaskChatComposer({ ) : null}
+
{canAcceptFiles ? ( - <> - - - + ) : null} - + fileInputRef.current?.click() : undefined} + onGoal={!queuedEdit && !conversationMode && attachments.length === 0 && + runnerGoalCapability?.availability === "available" && onRunnerGoalCommand + ? prepareGoal : undefined} + disabled={disabled || !!uncertainSubmission} + mobile={mobile} + triggerTestId="task-chat-composer-add" + menuTestId="task-chat-composer-add-menu" + /> {queuedEdit ? ( {queuedEdit.stale @@ -1440,71 +1395,37 @@ export function TaskChatComposer({ : "Editing queued message"} ) : ( - - - - - - {workModeMetaList().map((m) => { - const Icon = m.icon; - const selected = m.value === pendingMode; - return ( - setPendingMode(m.value)} - style={ - selected - ? { - backgroundColor: `color-mix(in srgb, ${modeHue(m.value)} 12%, transparent)`, - } - : undefined - } - > - - - {m.label} - - {MODE_DESCRIPTION[m.value] ?? ""} - - - {selected ? ( - - ) : null} - - ); - })} - - + setPendingMode("standard") : undefined} + disabled={disabled || !!uncertainSubmission} testId="task-chat-composer-mode" mobile={mobile} /> )} +
-
+
- {showAssignee && !queuedEdit ? ( + {showAssignee && !queuedEdit && companyId && modelAgents ? ( + ( + + )} + disabled={disabled} + mobile={mobile} + /> + ) : showAssignee && !queuedEdit ? ( ) : null} - +
{stopControl.error ? (

{stopControl.error}

) : null} - - )} +
); } diff --git a/ui/src/components/task-chat/TaskChatComposerDock.tsx b/ui/src/components/task-chat/TaskChatComposerDock.tsx new file mode 100644 index 0000000000..b72c140d11 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatComposerDock.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface TaskChatComposerDockProps { + children: ReactNode; + mobile: boolean; + streamlined: boolean; +} + +/** The task thread and its Storybook phone previews share the same composer spacing. */ +export function TaskChatComposerDock({ children, mobile, streamlined }: TaskChatComposerDockProps) { + return
+ {children} +
; +} diff --git a/ui/src/components/task-chat/composer-run-settings.css b/ui/src/components/task-chat/composer-run-settings.css new file mode 100644 index 0000000000..597af20b9b --- /dev/null +++ b/ui/src/components/task-chat/composer-run-settings.css @@ -0,0 +1,36 @@ +.composer-run-effort-range { + display: block; + appearance: none; + height: calc(var(--spacing) * 2); + border-radius: var(--radius-lg); + background: linear-gradient(to right, var(--tc-mode-ask) var(--fill), var(--muted) var(--fill)); + cursor: pointer; +} + +.composer-run-effort-range::-webkit-slider-thumb { + appearance: none; + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + border: calc(var(--spacing) * 0.5) solid var(--popover); + border-radius: 50%; + background: var(--tc-mode-ask); + box-shadow: var(--shadow-sm); +} + +.composer-run-effort-range::-moz-range-thumb { + width: calc(var(--spacing) * 5); + height: calc(var(--spacing) * 5); + border: calc(var(--spacing) * 0.5) solid var(--popover); + border-radius: 50%; + background: var(--tc-mode-ask); + box-shadow: var(--shadow-sm); +} + +.composer-run-effort-range:focus-visible { outline: calc(var(--spacing) * 0.5) solid var(--ring); outline-offset: calc(var(--spacing) * 0.5); } +.composer-run-settings-accent { color: var(--tc-mode-ask); } +.composer-run-settings-height { overflow: hidden; transition: height var(--motion-duration-base) var(--motion-ease-out); } +.composer-mobile-dialog { width: calc(100vw - var(--spacing) * 4); max-width: calc(var(--spacing) * 96); max-height: calc(100dvh - var(--spacing) * 8); } + +@media (prefers-reduced-motion: reduce) { + .composer-run-settings-height { transition: none; } +} diff --git a/ui/src/components/task-chat/composer-run-settings.test.ts b/ui/src/components/task-chat/composer-run-settings.test.ts new file mode 100644 index 0000000000..8f57ee20b7 --- /dev/null +++ b/ui/src/components/task-chat/composer-run-settings.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import { + composerCatalogProvider, composerEfforts, composerFastAvailable, mergeComposerRunSettings, + readComposerRunSettings, supportsComposerModel, +} from "./composer-run-settings"; + +const agent = (adapterType: Agent["adapterType"], provider?: string) => ({ + adapterType, adapterConfig: provider ? { provider } : {}, +}) as Agent; + +describe("composer run settings", () => { + it("shows only effort levels known for the selected harness and model", () => { + expect(composerEfforts(agent("codex_local"), "gpt-6-astra", [])).toContain("ultra"); + expect(composerEfforts(agent("codex_local"), "custom-private-model", [])).toEqual([]); + expect(composerEfforts(agent("opencode_local"), "openrouter/x/y", ["openrouter/x/y"])).toEqual([]); + expect(composerEfforts(agent("kimi_local"), "kimi-code/k3", ["kimi-code/k3"])).toEqual(["low", "high", "max"]); + expect(composerEfforts(agent("kimi_local"), "kimi-code/kimi-for-coding-highspeed", ["kimi-code/kimi-for-coding-highspeed"])).toEqual([]); + expect(composerFastAvailable(agent("codex_local"), "gpt-6-astra")).toBe(true); + expect(composerFastAvailable(agent("codex_local"), "custom-private-model")).toBe(false); + expect(supportsComposerModel(agent("process"))).toBe(false); + expect(composerCatalogProvider({ ...agent("opencode_local"), adapterConfig: { model: "openrouter/qwen/qwen3-coder-next" } })).toBe("openrouter"); + }); + + it("preserves unrelated task overrides while changing or resetting run settings", () => { + const previous = { adapterConfig: { chrome: true, model: "old", effort: "low" }, useProjectWorkspace: true }; + expect(mergeComposerRunSettings(previous, "claude_local", { model: "claude-opus", effort: "high", fast: false })) + .toEqual({ adapterConfig: { chrome: true, model: "claude-opus", effort: "high" }, useProjectWorkspace: true }); + const reset = mergeComposerRunSettings(previous, "claude_local", { model: null, effort: null, fast: false }); + expect(reset).toEqual({ adapterConfig: { chrome: true }, useProjectWorkspace: true }); + expect(readComposerRunSettings(reset, "claude_local")).toEqual({ model: null, effort: null, fast: false }); + expect(readComposerRunSettings({ adapterConfig: { reasoningEffort: "xhigh" } }, "codex_local").effort).toBe("xhigh"); + expect(mergeComposerRunSettings(previous, "codex_local", { model: "gpt-6-astra", effort: "ultra", fast: true }, true)) + .toEqual({ adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } }); + }); +}); diff --git a/ui/src/components/task-chat/composer-run-settings.ts b/ui/src/components/task-chat/composer-run-settings.ts new file mode 100644 index 0000000000..7e1da94a42 --- /dev/null +++ b/ui/src/components/task-chat/composer-run-settings.ts @@ -0,0 +1,94 @@ +import { codexLocalReasoningEffortsForModel, isCodexLocalFastModeSupported, isCodexLocalKnownModel } from "@paperclipai/adapter-codex-local"; +import { modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local"; +import { aiConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; + +export interface ComposerRunSettings { + model: string | null; + effort: string | null; + fast: boolean; +} + +export const DEFAULT_COMPOSER_RUN_SETTINGS: ComposerRunSettings = { model: null, effort: null, fast: false }; +export const EFFORT_LABELS: Record = { + off: "Off", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", + xhigh: "Extra High", max: "Max", ultra: "Ultra", +}; + +const MODEL_ADAPTERS = new Set([ + "claude_local", "codex_local", "opencode_local", "pi_local", "kimi_local", + "gemini_local", "cursor", "cursor_cloud", "grok_local", "hermes_local", "paperclip_runner", +]); + +export function supportsComposerModel(agent: Agent | undefined): boolean { + return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType)); +} + +export function composerCatalogProvider(agent: Agent | undefined): string | undefined { + if (!agent) return undefined; + if (agent.adapterType === "paperclip_runner") return String(agent.adapterConfig.provider ?? "codex"); + if (agent.adapterType !== "opencode_local") return undefined; + const binding = aiConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data; + const configuredModel = agent.adapterConfig.model; + return binding?.provider === "openrouter" || typeof configuredModel === "string" && configuredModel.startsWith("openrouter/") + ? "openrouter" : undefined; +} + +export function composerEfforts(agent: Agent | undefined, model: string, catalogIds: readonly string[]): readonly string[] { + if (!agent || !model) return []; + if (agent.adapterType === "codex_local") { + return isCodexLocalKnownModel(model) ? codexLocalReasoningEffortsForModel(model) : []; + } + if (!catalogIds.includes(model)) return []; + if (agent.adapterType === "claude_local") return ["low", "medium", "high"]; + if (agent.adapterType === "pi_local") return ["off", "minimal", "low", "medium", "high", "xhigh"]; + if (agent.adapterType === "kimi_local" && modelSupportsEffort(model)) return KIMI_SUPPORTED_EFFORTS; + return []; +} + +export function composerFastAvailable(agent: Agent | undefined, model: string): boolean { + return Boolean(agent && agent.adapterType === "codex_local" && isCodexLocalKnownModel(model) && isCodexLocalFastModeSupported(model)); +} + +export function readComposerRunSettings(overrides: IssueAssigneeAdapterOverrides | null | undefined, adapterType: string | undefined): ComposerRunSettings { + const config = overrides?.adapterConfig ?? {}; + const effortKey = composerEffortKey(adapterType); + const effortValue = effortKey && (config[effortKey] + ?? (adapterType === "codex_local" ? config.reasoningEffort ?? config.effort : undefined)); + return { + model: typeof config.model === "string" ? config.model : null, + effort: typeof effortValue === "string" ? effortValue : null, + fast: adapterType === "codex_local" && config.fastMode === true, + }; +} + +function composerEffortKey(adapterType: string | undefined): string | null { + if (adapterType === "codex_local") return "modelReasoningEffort"; + if (adapterType === "claude_local" || adapterType === "kimi_local") return "effort"; + if (adapterType === "pi_local") return "thinking"; + if (adapterType === "opencode_local") return "variant"; + return null; +} + +export function mergeComposerRunSettings( + previous: IssueAssigneeAdapterOverrides | null | undefined, + adapterType: string | undefined, + settings: ComposerRunSettings, + reassigned = false, +): IssueAssigneeAdapterOverrides | null { + const config = { ...(reassigned ? {} : previous?.adapterConfig) }; + delete config.model; + delete config.modelReasoningEffort; + delete config.reasoningEffort; + delete config.effort; + delete config.thinking; + delete config.variant; + delete config.fastMode; + if (settings.model) config.model = settings.model; + const effortKey = composerEffortKey(adapterType); + if (settings.effort && effortKey) config[effortKey] = settings.effort; + if (settings.fast && adapterType === "codex_local") config.fastMode = true; + const useProjectWorkspace = reassigned ? undefined : previous?.useProjectWorkspace; + return Object.keys(config).length || useProjectWorkspace !== undefined + ? { ...(Object.keys(config).length ? { adapterConfig: config } : {}), ...(useProjectWorkspace !== undefined ? { useProjectWorkspace } : {}) } + : null; +} diff --git a/ui/src/index.css b/ui/src/index.css index 9923f3afb3..b01e57e48e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -304,6 +304,7 @@ --side-panel-tab-active-bg: color-mix(in oklab, var(--accent) 88%, var(--background)); --side-panel-tab-hover-bg: color-mix(in oklab, var(--accent) 58%, transparent); --radius-task-composer: 16px; + --tc-interaction-card-max-h: 50dvh; --shadow-task-composer: 0 2px 8px -2px color-mix(in oklab, var(--foreground) 7%, transparent), 0 12px 32px -8px color-mix(in oklab, var(--foreground) 12%, transparent); --profile-popover-width: 248px; @@ -353,13 +354,12 @@ --sz-turn-status-island: min(100%, 42rem); --sz-turn-status-popover: min(32rem, calc(100vw - 2rem)); - /* Mobile pinned-composer bottom offset (PAP-495). Default clears just the - safe-area inset (+20px); Layout raises it to the bottom-nav height - (--sz-calc-14) while the auto-hiding mobile nav is on screen, so the - composer's action row never hides behind the nav. Desktop and the classic - thread never override it and fall through to the safe-area default. */ + /* Mobile pinned-composer bottom offset (PAP-495). The visible-nav offset + leaves 6px below the dock; its pb-1 adds 4px for a 10px card-to-nav gap. + Desktop and the classic thread use the safe-area default. */ --tc-composer-bottom: var(--sz-calc-8); - /* Together with the dock's pb-2, match the page's p-4 side gutters. */ + --tc-composer-visible-nav-offset: calc(4rem + var(--spacing) * 1.5 + var(--sz-safe-bottom)); + /* With the dock's pb-1, leave 12px below the card when the nav hides. */ --tc-composer-hidden-nav-offset: calc(var(--sz-safe-bottom) + var(--spacing) * 2); } diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 3fa40fbb12..696b7ac101 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -19,6 +19,8 @@ import { RepositoryEditor } from "@/components/RepositoryEditor"; import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup"; import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { ComposerAddMenu, ComposerModeChip } from "@/components/task-chat/ComposerAddMenu"; +import type { IssueWorkMode } from "@paperclipai/shared"; import { TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls"; import { useState } from "react"; import { @@ -436,6 +438,32 @@ function TaskExecutionControlsExample() {
; } +function TaskPendingInputExample() { + const [open, setOpen] = useState(true); + const [pending, setPending] = useState(true); + return
+ {}} + workMode="standard" + takeover={pending && open ? { + id: "design-question", + label: "Question", + pendingCount: 1, + content:
+

Should the agent use the existing draft?

+
+ + +
+
, + onDismiss: () => setOpen(false), + onSkip: () => setPending(false), + } : null} + pendingTakeover={pending ? { count: 1, label: "Question", onOpen: () => setOpen(true) } : null} + /> +
; +} + function AgentChatPickerExample() { const [state, setState] = useState<"closed" | "empty" | "loading" | "error">("closed"); return
@@ -447,6 +475,15 @@ function AgentChatPickerExample() {
; } +function ComposerActionsExample() { + const [mode, setMode] = useState("standard"); + return
+ {}} onGoal={() => {}} /> + setMode("standard")} /> + Plus menu · removable mode chip +
; +} + export function DesignGuide() { const [wizardStep, setWizardStep] = useState(0); const [status, setStatus] = useState("todo"); @@ -531,6 +568,10 @@ export function DesignGuide() { +
+ +
+

CollectionToolbar owns shared geometry while each page owns its state and behavior. @@ -2198,6 +2239,11 @@ export function DesignGuide() {

Storybook’s Recovery notice stories show the actionable, pending, acknowledged, unavailable, failed, and mobile states using this production component.

+
+

A decision card sits above the ordinary message composer. Dismiss the card to keep a reopen control, or resolve it to clear the pending state.

+ +
+

Recovery runs in the background. Task lists keep their ordinary status without diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 30294009db..43705c1593 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1677,8 +1677,10 @@ describe("IssueDetail", () => { if (reassign) { expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, { comment: "Inspect the new file", + commentClientRequestId: undefined, assigneeAgentId: "agent-2", assigneeUserId: null, + assigneeAdapterOverrides: null, attachmentIds: [id], }); expect(mockIssuesApi.addComment).not.toHaveBeenCalled(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 766100aac2..691901b44b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2,6 +2,7 @@ import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery"; import { useUserPreferences } from "../hooks/useUserPreferences"; import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; +import { mergeComposerRunSettings, type ComposerRunSettings } from "@/components/task-chat/composer-run-settings"; import { AgentIdentity } from "@/components/AgentIdentity"; import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request"; import { agentChatDraft } from "@/lib/agent-chat-draft"; @@ -1256,6 +1257,7 @@ type IssueDetailChatTabProps = { draftKey: string; reassignOptions: Array<{ id: string; label: string; searchText?: string }>; currentAssigneeValue: string; + assigneeAdapterOverrides?: Issue["assigneeAdapterOverrides"]; suggestedAssigneeValue: string; mentions: MentionOption[]; conversationMode?: boolean; @@ -1274,6 +1276,7 @@ type IssueDetailChatTabProps = { reassignment?: CommentReassignment, attachmentIds?: string[], clientRequestId?: string, + runSettings?: ComposerRunSettings, ) => Promise; onReviewConversation: () => Promise; onImageUpload: (file: File) => Promise; @@ -1383,6 +1386,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ draftKey, reassignOptions, currentAssigneeValue, + assigneeAdapterOverrides, suggestedAssigneeValue, mentions, conversationMode, @@ -2409,6 +2413,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ enableReassign={!conversationMode} reassignOptions={reassignOptions} currentAssigneeValue={currentAssigneeValue} + assigneeAdapterOverrides={assigneeAdapterOverrides} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentions} composerPause={composerPause} @@ -4867,20 +4872,34 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS reassignment, attachmentIds, clientRequestId, + runSettings, }: { body: string; reopen?: boolean; interrupt?: boolean; - reassignment: CommentReassignment; + reassignment?: CommentReassignment; attachmentIds?: string[]; clientRequestId?: string; + runSettings?: ComposerRunSettings; }) => issuesApi.update(issueId!, { comment: body, commentClientRequestId: clientRequestId, ...(attachmentIds?.length ? { attachmentIds } : {}), - assigneeAgentId: reassignment.assigneeAgentId, - assigneeUserId: reassignment.assigneeUserId, + ...(reassignment ? { + assigneeAgentId: reassignment.assigneeAgentId, + assigneeUserId: reassignment.assigneeUserId, + } : {}), + ...(runSettings || reassignment ? { + assigneeAdapterOverrides: runSettings + ? mergeComposerRunSettings( + issue?.assigneeAdapterOverrides, + agentMap.get(reassignment?.assigneeAgentId ?? issue?.assigneeAgentId ?? "")?.adapterType, + runSettings, + Boolean(reassignment), + ) + : null, + } : {}), ...(reopen ? { status: "todo" } : {}), ...(interrupt ? { interrupt } : {}), }), @@ -6226,14 +6245,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS reassignment?: CommentReassignment, attachmentIds?: string[], clientRequestId?: string, + runSettings?: ComposerRunSettings, ) => { - if (reassignment) { + if (reassignment || runSettings) { await addCommentAndReassign.mutateAsync({ body, reopen, reassignment, attachmentIds, clientRequestId, + runSettings, }); return; } @@ -7786,6 +7807,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS projectId={issue.projectId ?? null} issueStatus={issue.status} issueAssigneeAgentId={issue.assigneeAgentId} + assigneeAdapterOverrides={issue.assigneeAdapterOverrides} issueWorkMode={issue.workMode ?? "standard"} executionRunId={issue.executionRunId ?? null} blockedBy={issue.blockedBy ?? []} diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index 943b2c7198..6d14ad8a04 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -26,7 +26,7 @@ const config: StorybookConfig = { docs: { autodocs: true, }, - viteFinal: async (baseConfig, { configType }) => + viteFinal: async (baseConfig) => mergeConfig(baseConfig, { define: { "import.meta.env.VITE_PAPERCLIP_INSTANCE_URL": JSON.stringify(paperclipInstanceOrigin), @@ -42,9 +42,7 @@ const config: StorybookConfig = { // The app's own dev server hoists one React and never hit this. dedupe: ["react", "react-dom"], alias: { - ...(configType === "PRODUCTION" ? { - "@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"), - } : {}), + "@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"), "@": path.resolve(storybookConfigDir, "../../src"), lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"), // Vite's bundled `node:crypto` polyfill omits `createHash`, which diff --git a/ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx b/ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx new file mode 100644 index 0000000000..aac6e2955e --- /dev/null +++ b/ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx @@ -0,0 +1,293 @@ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { ArrowLeft, ArrowUp, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react"; +import { AgentAvatar } from "@/components/AgentAvatar"; +import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ComposerAddMenu, ComposerModeChip } from "@/components/task-chat/ComposerAddMenu"; +import { nextWorkMode } from "@/lib/work-mode-meta"; +import type { IssueWorkMode } from "@paperclipai/shared"; +import { cn } from "@/lib/utils"; +import { composerAgentAppearance, composerAgents, effortChoices, effortLabels, fastModeAvailable, modelLabel, type ComposerAgent } from "./fixtures"; +import "./picker.css"; + +export type ComposerModelPickerPreviewProps = { + agentId?: string; + initialModel?: string; + initialEffort?: string; + initialFast?: boolean; + initialPanel?: "closed" | "settings" | "models" | "agents"; + initialSearch?: string; + initialAssigneeSearch?: string; + initialMode?: IssueWorkMode; + compact?: boolean; +}; + +type SentMessage = { text: string; agent: string; model: string | null; effort: string | null; fast: boolean }; + +function AgentMark({ agent, size = 24 }: { agent: ComposerAgent; size?: 16 | 24 }) { + return ; +} + +function ModelRow({ option, selected, onSelect }: { + option: { id: string; label: string; detail?: string }; + selected: boolean; + onSelect: (id: string) => void; +}) { + return ( + + ); +} + +function AnimatedPickerBody({ children }: { children: ReactNode }) { + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + + useLayoutEffect(() => { + const content = contentRef.current; + if (!content) return; + const measure = () => setHeight(content.getBoundingClientRect().height); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(content); + return () => observer.disconnect(); + }, []); + + return

{children}
; +} + +export function ComposerModelPickerPreview({ + agentId = "codex", initialModel, initialEffort, initialFast = false, + initialPanel = "closed", initialSearch = "", initialAssigneeSearch = "", initialMode = "standard", compact = false, +}: ComposerModelPickerPreviewProps) { + const [agent, setAgent] = useState(composerAgents.find((item) => item.id === agentId) ?? composerAgents[0]); + const [modelOverride, setModelOverride] = useState(initialModel ?? null); + const [effortOverride, setEffortOverride] = useState(initialEffort ?? null); + const [fast, setFast] = useState(initialFast); + const [pickerOpen, setPickerOpen] = useState(initialPanel !== "closed"); + const [view, setView] = useState<"settings" | "models" | "agents">(initialPanel === "closed" ? "settings" : initialPanel); + const [search, setSearch] = useState(initialSearch); + const [assigneeSearch, setAssigneeSearch] = useState(initialAssigneeSearch); + const [highlightedAssigneeIndex, setHighlightedAssigneeIndex] = useState(0); + const [draft, setDraft] = useState(""); + const [mode, setMode] = useState(initialMode); + const [attachments, setAttachments] = useState([]); + const fileInputRef = useRef(null); + const [messages, setMessages] = useState([]); + const [mobile, setMobile] = useState(() => typeof window !== "undefined" && window.matchMedia("(max-width: 639px)").matches); + + useEffect(() => { + const query = window.matchMedia("(max-width: 639px)"); + const update = () => setMobile(query.matches); + update(); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + + const model = modelOverride ?? agent.defaultModel ?? ""; + const choices = effortChoices(agent, model); + const effectiveEffort = effortOverride && choices.includes(effortOverride) ? effortOverride : null; + const effortIndex = effectiveEffort ? choices.indexOf(effectiveEffort) + 1 : 0; + const effortLabel = effectiveEffort ? effortLabels[effectiveEffort] ?? effectiveEffort : "Default"; + const fastAvailable = fastModeAvailable(agent, model); + const modelAvailable = Boolean(agent.defaultModel || agent.models.length || agent.manualPattern); + const query = search.trim(); + const filtered = agent.models.filter((option) => + `${option.label} ${option.id} ${option.detail ?? ""}`.toLowerCase().includes(query.toLowerCase()), + ); + const filteredAssignees = composerAgents.filter((item) => + `${item.name} ${item.role} ${item.harness} ${item.provider ?? ""}`.toLowerCase().includes(assigneeSearch.trim().toLowerCase()), + ); + const exactCatalogMatch = agent.models.some((option) => option.id.toLowerCase() === query.toLowerCase()); + const manualValid = query.length > 0 && !/\s/.test(query) + && (agent.provider !== "OpenRouter" || query.startsWith("openrouter/")); + + function reset() { + setModelOverride(null); + setEffortOverride(null); + setFast(false); + } + + function chooseModel(next: string | null) { + setModelOverride(next); + setEffortOverride(null); + setFast(false); + setSearch(""); + setView("settings"); + } + + function chooseAgent(next: ComposerAgent) { + setAgent(next); + reset(); + setSearch(""); + setAssigneeSearch(""); + setHighlightedAssigneeIndex(0); + setView("settings"); + } + + function send() { + if (!draft.trim()) return; + setMessages((current) => [...current, { + text: draft.trim(), agent: agent.name, model: model || null, + effort: effectiveEffort, fast: fast && fastAvailable, + }]); + setDraft(""); + } + + function handlePickerOpenChange(open: boolean) { + setPickerOpen(open); + if (!open) { + setView("settings"); + setSearch(""); + setAssigneeSearch(""); + setHighlightedAssigneeIndex(0); + } + } + + const pickerTrigger = ( + + ); + + const mobileCloseButton = mobile ? : null; + + const pickerBody = ( + view === "settings" ? ( +
+
+ + {modelAvailable && !choices.length ? : null} + {mobileCloseButton} +
+ {modelAvailable ? :
{agent.noModelReason}
} + {modelAvailable && choices.length ? ( +
+
+ {fastAvailable ? : } + + +
+ setEffortOverride(Number(event.target.value) === 0 ? null : choices[Number(event.target.value) - 1])} + className="composer-effort-range mt-3 w-full" style={{ "--fill": `${(effortIndex / choices.length) * 100}%` } as CSSProperties} /> +
+ ) : null} +
+ ) : view === "agents" ? ( +
+

Choose assignee

Each agent keeps its configured harness.

{mobileCloseButton}
+
{ setAssigneeSearch(event.target.value); setHighlightedAssigneeIndex(0); }} onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setHighlightedAssigneeIndex((current) => filteredAssignees.length ? (current + (event.key === "ArrowDown" ? 1 : -1) + filteredAssignees.length) % filteredAssignees.length : 0); } + if (event.key === "Enter" && filteredAssignees.length) { event.preventDefault(); chooseAgent(filteredAssignees[Math.min(highlightedAssigneeIndex, filteredAssignees.length - 1)]); } + }} placeholder="Search assignees…" aria-label="Search assignees" aria-controls="composer-assignees" aria-activedescendant={filteredAssignees[highlightedAssigneeIndex] ? `composer-assignee-${filteredAssignees[highlightedAssigneeIndex].id}` : undefined} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
+
+ {filteredAssignees.map((item, index) => )} + {!filteredAssignees.length ?

No matches.

: null} +
+
+ ) : ( +
+

Choose model

{agent.harness}{agent.provider ? ` · ${agent.provider}` : ""}

{mobileCloseButton}
+
setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && manualValid && !exactCatalogMatch) chooseModel(query); }} placeholder="Search or paste a model ID" aria-label="Search or paste a model ID" className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
+
+ {!query ? : null} + {filtered.map((option) => chooseModel(id)} />)} + {!filtered.length && query ?

No catalog match.

: null} +
+ {query && !exactCatalogMatch ?
{!manualValid ?

{agent.provider === "OpenRouter" ? "Use openrouter/provider/model with no spaces." : "Model IDs cannot contain spaces."}

: null}
: null} +

{agent.manualPattern ? `Custom IDs: ${agent.manualPattern}. Provider access is checked when the run starts.` : "Only models for this harness are shown."}

+
+ ) + ); + + return ( +
+
+
+ + + Agent conversation + Model and effort can be chosen for the next message + +
+ +
+
+

{agent.name} · {agent.role}

+

I can take the next step. Pick the model and effort you want me to use, then send your instructions.

+
+ {messages.map((message, index) => ( +
+

{message.text}

+

To {message.agent}{message.model ? ` · ${message.model}` : ""}{message.effort ? ` · ${effortLabels[message.effort] ?? message.effort}` : ""}{message.fast ? " · Fast" : ""}

+
+ ))} +
+ +
+