From 0be2afcca641f12c132908358da9cb258f942bcb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:02:39 -0500 Subject: [PATCH] feat(ui): improve task composer controls and pending input (#14322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip lets operators assign tasks to AI agents and review their work. > - The task composer controls the next message and its assigned agent. > - Operators needed a way to choose that agent's model and effort without leaving the composer. > - The old mode selector, upload button, and input cards made the mobile composer crowded and hid normal messaging during a pending decision. > - Harnesses publish different model and effort capabilities, so the picker must follow the selected agent. > - This pull request adds one responsive composer flow, keeps pending cards visible above it, and protects Codex ACP authentication in the local test path. > - Operators can choose run settings, send a message, and answer a pending card as separate actions. ## Linked Issues or Issue Description **Subsystem affected** Task composer UI, issue thread interactions, Codex ACP credential handling, and Storybook. **Problem or motivation** The composer did not expose model or effort for the selected agent. Mobile actions wrapped poorly. Pending questions and confirmations replaced the composer. A local Codex ACP test could also reuse host authentication after the managed key was removed. **Proposed solution** Put assignee search, model search, exact model IDs, effort, and fast mode in one picker. Use a mobile dialog. Replace the direct-upload plus action and separate mode selector with an Add menu and removable Plan or Ask chips. Place pending interaction cards above the usable composer. Keep these cards pending after an ordinary message unless their creator asks for comment superseding. Replace managed ACP auth files atomically and isolate the test key from host credentials. **Roadmap alignment** ROADMAP.md does not list an overlapping composer milestone. This change improves the existing task and review flows. ## What Changed - Added the combined assignee, model, and effort picker to both task composers. Search matches agent name, role, and harness. The server uses a curated Codex list by default and honors instance-declared models. Manual IDs remain available. - Added an effort slider for known model capabilities, a conditional Codex fast control, and reset. The picker opens in a modal on mobile. - Added the Add menu for files, supported goals, Plan mode, and Ask mode. Plan and Ask are exclusive removable chips. Keyboard mode cycling remains available. - Adjusted mobile spacing, avatars, wrapping, and Send placement. Removed the composer divider. - Moved pending question, confirmation, review, and related cards above the composer. Ordinary comments now leave question and confirmation cards pending by default. The onboarding prompt retains explicit comment superseding. - Updated the Storybook composer group with responsive states and the production picker. Added UI, service, route, and browser regression coverage. - Isolated Codex ACP API-key authentication, skipped subscription auth merge and shared-home copy-back for remote API-key runs, and replaced the managed auth file atomically. ## Verification - `pnpm -r typecheck` — passed on the final local head. - `pnpm check:token-gates` — passed on the final local head. - `pnpm exec vitest run server/src/__tests__/adapter-models.test.ts ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx` — 31 tests passed, including role and harness search, declared Codex models, and filtering general OpenAI models. - `pnpm exec vitest run server/src/__tests__/issue-thread-interactions-service.test.ts` — 74 tests passed. - `pnpm exec vitest run packages/adapters/codex-local/src/server/acp.test.ts` — 42 tests passed, including remote API-key copy-back isolation. - `pnpm test:run` — attempted locally; the embedded PostgreSQL test database could not initialize on macOS. The isolated `heartbeat-run-event-sequencing` suite reproduced that environment failure. GitHub CI runs the full test matrix for this head. - `pnpm build` — passed on the final head. `pnpm build-storybook` passed after the last UI change; only server code, tests, and docs changed afterward. - Live local test drive — Codex ACP ran a task with a managed API key. The test agent was restored to its default ACP configuration afterward. - Review the interactive stories under the top-level Composer group with `pnpm storybook`. Check a narrow desktop width and mobile Plan, Ask, picker, and pending-question states. ## Risks - A pending card stays open when an ordinary comment changes the discussion. Its creator can set `supersedeOnUserComment: true` when a new comment should replace it. - Model and effort overrides persist on the task until reset or changed. An unlisted manual model ID may fail when the provider runs it. - Some harness catalogs do not report effort support. The picker hides effort for those models. - No database migration is required. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-6 via Codex. This runtime does not expose the exact model ID or context window to the task. The model used code execution and browser tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: OpenAI Codex --- DESIGN.md | 5 + doc/DEVELOPING.md | 10 + doc/SPEC-implementation.md | 8 +- doc/composer-stop.md | 4 +- .../src/acpx-engine/execute.test.ts | 49 +++ .../adapter-utils/src/acpx-engine/execute.ts | 36 +- packages/adapters/codex-local/src/index.ts | 1 + .../codex-local/src/server/acp.test.ts | 48 ++ .../adapters/codex-local/src/server/acp.ts | 36 +- scripts/storybook-agent-avatar-assets.mjs | 47 +- server/src/__tests__/adapter-models.test.ts | 79 ++-- .../chat-interaction-publications.test.ts | 7 +- .../issue-thread-interactions-service.test.ts | 69 ++- ...ssue-thread-interactions-telemetry.test.ts | 2 + ...issue-update-comment-wakeup-routes.test.ts | 42 +- server/src/adapters/registry.ts | 20 +- server/src/onboarding-assets/ceo/HEARTBEAT.md | 2 +- .../src/onboarding-assets/default/AGENTS.md | 2 +- server/src/routes/issues.ts | 20 +- .../src/services/issue-thread-interactions.ts | 8 +- .../paperclip-runner-tool-authority.ts | 2 +- tests/e2e/agent-chat-projects.spec.ts | 14 +- tests/e2e/board-attachment-receipts.spec.ts | 29 +- tests/e2e/chat-adapters-ui-messaging.spec.ts | 13 +- .../planning-mode-visual-verification.spec.ts | 8 +- tests/runner-e2e/chat-flow.ts | 7 +- ui/src/components/IssueChatThread.test.tsx | 111 +++-- ui/src/components/IssueChatThread.tsx | 145 +++---- ui/src/components/Layout.production.tsx | 10 +- ui/src/components/Layout.tsx | 8 +- ui/src/components/TaskChatThread.tsx | 30 +- .../components/task-chat/ComposerAddMenu.tsx | 109 +++++ .../ComposerRunSettingsPicker.test.tsx | 132 ++++++ .../task-chat/ComposerRunSettingsPicker.tsx | 226 ++++++++++ .../task-chat/TaskChatComposer.test.tsx | 210 +++++++-- .../components/task-chat/TaskChatComposer.tsx | 410 +++++++----------- .../task-chat/TaskChatComposerDock.tsx | 29 ++ .../task-chat/composer-run-settings.css | 36 ++ .../task-chat/composer-run-settings.test.ts | 36 ++ .../task-chat/composer-run-settings.ts | 94 ++++ ui/src/index.css | 12 +- ui/src/pages/DesignGuide.tsx | 46 ++ ui/src/pages/IssueDetail.test.tsx | 2 + ui/src/pages/IssueDetail.tsx | 30 +- ui/storybook/.storybook/main.ts | 6 +- .../ComposerModelPickerPreview.tsx | 293 +++++++++++++ .../ComposerRunSettingsLiveStory.tsx | 124 ++++++ .../composer-model-picker/README.md | 23 + .../composer-model-picker/fixtures.ts | 101 +++++ .../composer-model-picker/picker.css | 46 ++ .../stories/agent-personas.stories.tsx | 2 +- .../stories/composer-add-menu.stories.tsx | 304 +++++++++++++ .../composer-mobile-assignee.stories.tsx | 36 ++ .../stories/composer-model-picker.stories.tsx | 293 +++++++++++++ .../composer-question-flow.stories.tsx | 122 ++++++ .../composer-queued-messages.stories.tsx | 157 +++++++ .../issue-thread-interactions.stories.tsx | 120 ----- .../stories/mobile-entity-pickers.stories.tsx | 20 - .../stories/paused-composer.stories.tsx | 2 +- .../stories/session-goals.stories.tsx | 2 +- ...hat-interaction-above-composer.stories.tsx | 255 +++++++++++ .../task-execution-controls.stories.tsx | 2 +- 62 files changed, 3436 insertions(+), 716 deletions(-) create mode 100644 ui/src/components/task-chat/ComposerAddMenu.tsx create mode 100644 ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx create mode 100644 ui/src/components/task-chat/ComposerRunSettingsPicker.tsx create mode 100644 ui/src/components/task-chat/TaskChatComposerDock.tsx create mode 100644 ui/src/components/task-chat/composer-run-settings.css create mode 100644 ui/src/components/task-chat/composer-run-settings.test.ts create mode 100644 ui/src/components/task-chat/composer-run-settings.ts create mode 100644 ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx create mode 100644 ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx create mode 100644 ui/storybook/prototypes/composer-model-picker/README.md create mode 100644 ui/storybook/prototypes/composer-model-picker/fixtures.ts create mode 100644 ui/storybook/prototypes/composer-model-picker/picker.css create mode 100644 ui/storybook/stories/composer-add-menu.stories.tsx create mode 100644 ui/storybook/stories/composer-mobile-assignee.stories.tsx create mode 100644 ui/storybook/stories/composer-model-picker.stories.tsx create mode 100644 ui/storybook/stories/composer-question-flow.stories.tsx create mode 100644 ui/storybook/stories/composer-queued-messages.stories.tsx create mode 100644 ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx 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" : ""}

+
+ ))} +
+ +
+