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 ? (
- <>
-
-
attachInputRef.current?.click()}
- disabled={attaching}
- title="Attach file"
- >
-
-
- >
- ) : null}
- {canToggleWorkMode ? (
-
-
- {/* Single persistent mode chip (PAP-95b mockup rev 5): yellow in
- planning, neutral in standard, caret opens the switch menu. */}
-
-
- {pendingWorkModeMeta.label}
-
-
-
-
- {workModeOptions.map((option) => {
- const Icon = option.icon;
- const active = option.value === pendingWorkMode;
- return (
- {
- setPendingWorkMode(option.value);
- setWorkModeMenuOpen(false);
- }}
- >
-
- {option.label}
- {active ? (
-
- ) : null}
-
- );
- })}
-
- 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) => { goalFocusRef.current = action.id === "composer-add-goal"; action.select(); setOpen(false); }}
+ className="flex min-h-11 w-full items-center gap-3 rounded-md px-2 text-left text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
+ {content(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
+
+ {meta.label}
+
+ ;
+}
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 ;
+}
+
+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 =
+ {renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "trigger")}
+ {assigneeOptions.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}
+ · {modelSupported ? modelName || "Harness default" : "Harness default"}
+ {effort ? {effortLabel} : null}
+
+ ;
+
+ const body = view === "settings" ?
+
+ setView("agents")}
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
+ {renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "option") ?? {agent?.name.slice(0, 1) ?? "?"} }
+ {agent?.name ?? options.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"} {harnessLabel(agent)}
+
+
+ {!choices.length && modelSupported ? : null}
+ {closeButton}
+
+ {modelSupported ? <>
+
setView("models")} className="mt-3 flex w-full items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
+ Model {modelName || "Harness default"}
+
+ {choices.length ?
+
+ {fastAvailable ? onSettingsChange({ ...selected, fast: !selected.fast })} className={cn("grid size-8 shrink-0 place-items-center rounded-md hover:bg-accent", selected.fast ? "composer-run-settings-accent bg-accent" : "text-muted-foreground")}> : }
+ {effortLabel}
+
+
+
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" ?
+
setView("settings")} className="grid size-7 place-items-center rounded-md hover:bg-accent"> Choose assignee Each 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) =>
setHighlightedAssignee(index)} onClick={() => chooseAssignee(item.id)} className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left focus-visible:outline-none", highlightedAssignee === index ? "bg-accent" : "hover:bg-accent")}>{renderAssigneeIdentity?.(item.id, item.label, "option")}{item.label} {agents.get(item.id.startsWith("agent:") ? item.id.slice(6) : "")?.role ?? ""} {harnessLabel(agents.get(item.id.startsWith("agent:") ? item.id.slice(6) : ""))} {item.id === assigneeValue ? : null} )}{!filteredAgents.length ?
No matches.
: null}
+
:
+
setView("settings")} className="grid size-7 place-items-center rounded-md hover:bg-accent"> 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 ?
chooseModel(null)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent">Use agent default {models.find((item) => item.id === configuredModel)?.label || configuredModel || "Harness default"} {selected.model === null ? : null} : null}
+ {filteredModels.map((item) =>
chooseModel(item.id)} className="flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none">{item.label} {item.id} {item.detail || item.id === configuredModel ? {item.detail ?? "Agent default"} : null}{selected.model === item.id ? : null} )}
+ {catalogPending ?
Loading models…
: null}
+ {!catalogPending && !filteredModels.length && query ?
No catalog match.
: null}
+
+ {query && !exactMatch ?
chooseModel(query)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-accent disabled:opacity-40">Use exact ID {query} {!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 ? (
+
+ {takeover.pendingCount} pending
+
+ ) : 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 ? (
-
- {takeover.pendingCount} pending
-
- ) : null}
-
-
-
-
-
-
-
-
- {takeover.content}
-
-
- {takeoverError ? (
-
- {takeoverError}
-
- ) : null}
- {!takeover.inlineSkip && !takeover.hideSkip ? (
-
- {takeoverSkipButton}
-
- ) : null}
-
- ) : (
- <>
- {pendingTakeover || takeover ? (
-
-
-
- {pendingTakeover?.label ?? takeover?.label ?? "Pending input"}
-
-
- {pendingTakeover?.count ?? takeover?.pendingCount ?? 1} pending
-
-
- ) : null}
+
+
+ {pendingTakeover?.label ?? takeover?.label ?? "Pending input"}
+
+
+ {pendingTakeover?.count ?? takeover?.pendingCount ?? 1} pending
+
+
+ ) : null}
{pause && conversationMode ? (
@@ -1408,31 +1369,25 @@ export function TaskChatComposer({
) : null}
+
-
+
- {showAssignee && !queuedEdit ? (
+ {showAssignee && !queuedEdit && companyId && modelAgents ? (
+
(
+
+ )}
+ disabled={disabled}
+ mobile={mobile}
+ />
+ ) : showAssignee && !queuedEdit ? (
) : null}
-
void (showStop ? stopControl.stop() : submit())}
@@ -1604,7 +1524,7 @@ export function TaskChatComposer({
: "Send"
}
className={cn(
- "flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-transform hover:scale-105 disabled:scale-100",
+ "flex size-8 min-h-8 min-w-8 shrink-0 aspect-square items-center justify-center rounded-full transition-transform hover:scale-105 disabled:scale-100",
streamlined
? "bg-foreground text-background disabled:bg-foreground disabled:text-background disabled:opacity-100"
: "bg-primary text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
@@ -1622,14 +1542,14 @@ export function TaskChatComposer({
)}
+
{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?
+
+ setPending(false)}>Use draft
+ setPending(false)}>Start fresh
+
+
,
+ 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 (
+ onSelect(option.id)}
+ className="flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none">
+
+ {option.label}
+ {option.id}
+
+ {option.detail ? {option.detail} : null}
+ {selected ? : null}
+
+ );
+}
+
+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 ;
+}
+
+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 = (
+
+
+ {agent.name}
+ ·
+ {modelAvailable ? modelLabel(agent, model) : "Harness default"}
+ {effectiveEffort ? {effortLabel} : null}
+
+
+ );
+
+ const mobileCloseButton = mobile ? : null;
+
+ const pickerBody = (
+ view === "settings" ? (
+
+
+
{ setAssigneeSearch(""); setHighlightedAssigneeIndex(0); setView("agents"); }} aria-label="Choose assignee" className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
+
+ {agent.name} {agent.harness}{agent.provider ? ` · ${agent.provider}` : ""}
+
+
+ {modelAvailable && !choices.length ?
: null}
+ {mobileCloseButton}
+
+ {modelAvailable ?
setView("models")} className="mt-3 flex w-full items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Choose exact model">
+ Model {modelLabel(agent, model)}
+
+ :
{agent.noModelReason}
}
+ {modelAvailable && choices.length ? (
+
+
+ {fastAvailable ? setFast((current) => !current)} aria-label="Fast mode" aria-pressed={fast} title="Fast mode · faster responses, higher usage" className={cn("grid size-8 shrink-0 place-items-center rounded-md hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", fast ? "composer-picker-accent bg-accent" : "text-muted-foreground")}> : }
+ {effortLabel}
+
+
+
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" ? (
+
+
{ setAssigneeSearch(""); setHighlightedAssigneeIndex(0); setView("settings"); }} aria-label="Back to selection" className="grid size-7 place-items-center rounded-md hover:bg-accent"> 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) =>
setHighlightedAssigneeIndex(index)} onClick={() => chooseAgent(item)} className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left focus-visible:bg-accent focus-visible:outline-none", highlightedAssigneeIndex === index ? "bg-accent" : "hover:bg-accent")}>
+
+ {item.name} {item.role}
+ {item.harness}
+ {agent.id === item.id ? : null}
+ )}
+ {!filteredAssignees.length ?
No matches.
: null}
+
+
+ ) : (
+
+
{ setView("settings"); setSearch(""); }} aria-label="Back to selection" className="grid size-7 place-items-center rounded-md hover:bg-accent"> 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 ?
chooseModel(null)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none">Use agent default {modelLabel(agent, agent.defaultModel ?? "")} {modelOverride === null ? : null} : null}
+ {filtered.map((option) =>
chooseModel(id)} />)}
+ {!filtered.length && query ? No catalog match.
: null}
+
+ {query && !exactCatalogMatch ?
chooseModel(query)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40">Use exact ID {query} {!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" : ""}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx b/ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx
new file mode 100644
index 0000000000..8f7c2a4421
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx
@@ -0,0 +1,124 @@
+import { useRef, useState } from "react";
+import { ArrowUp } from "lucide-react";
+import type { Agent, IssueWorkMode } from "@paperclipai/shared";
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { ComposerRunSettingsPicker } from "@/components/task-chat/ComposerRunSettingsPicker";
+import { ComposerAddMenu, ComposerModeChip } from "@/components/task-chat/ComposerAddMenu";
+import { nextWorkMode } from "@/lib/work-mode-meta";
+import { DEFAULT_COMPOSER_RUN_SETTINGS, mergeComposerRunSettings, type ComposerRunSettings } from "@/components/task-chat/composer-run-settings";
+import { cn } from "@/lib/utils";
+import { composerAgentAppearance, composerAgents } from "./fixtures";
+
+const agents = new Map(composerAgents.map((fixture) => [fixture.id, {
+ id: fixture.id, companyId: "storybook", name: fixture.name, role: fixture.role,
+ appearance: composerAgentAppearance(fixture.id),
+ adapterType: fixture.adapterType,
+ adapterConfig: {
+ ...(fixture.defaultModel ? { model: fixture.defaultModel } : {}),
+ ...(fixture.provider === "OpenRouter" ? { provider: "openrouter" } : {}),
+ },
+ defaultEnvironmentId: null,
+} as Agent]));
+const options = composerAgents.map((item) => ({
+ id: `agent:${item.id}`, label: item.name,
+ searchText: `${item.name} ${item.role} ${item.harness} ${item.provider ?? ""}`,
+}));
+
+export interface LiveStoryProps {
+ agentId?: string;
+ initialModel?: string;
+ initialEffort?: string;
+ initialFast?: boolean;
+ initialPanel?: "closed" | "settings" | "models" | "agents";
+ initialSearch?: string;
+ initialAssigneeSearch?: string;
+ initialMode?: IssueWorkMode;
+ mobile?: boolean;
+ compact?: boolean;
+}
+
+export function ComposerRunSettingsLiveStory({
+ agentId = "codex", initialModel, initialEffort, initialFast = false,
+ initialPanel = "closed", initialSearch = "", initialAssigneeSearch = "",
+ initialMode = "standard",
+ mobile = false, compact = false,
+}: LiveStoryProps) {
+ const [assignee, setAssignee] = useState(`agent:${agentId}`);
+ const [settings, setSettings] = useState(
+ initialModel || initialEffort || initialFast
+ ? { model: initialModel ?? null, effort: initialEffort ?? null, fast: initialFast }
+ : null,
+ );
+ const [draft, setDraft] = useState("");
+ const [mode, setMode] = useState(initialMode);
+ const [attachments, setAttachments] = useState([]);
+ const [sent, setSent] = useState>([]);
+ const fileInputRef = useRef(null);
+ const selectedAgent = composerAgents.find((item) => `agent:${item.id}` === assignee) ?? null;
+ const agent = agents.get(selectedAgent?.id ?? "codex");
+ const overrides = selectedAgent
+ ? mergeComposerRunSettings(null, selectedAgent.adapterType, settings ?? DEFAULT_COMPOSER_RUN_SETTINGS)
+ : null;
+
+ function send() {
+ if (!draft.trim()) return;
+ setSent((current) => [...current, { body: draft.trim(), agent: selectedAgent?.name ?? "No assignee", overrides }]);
+ setDraft("");
+ }
+
+ return
+
+
+
+
+ Agent conversation
+ Model and effort can be chosen for the next message
+
+
+
+
+
+
{selectedAgent?.name} · {selectedAgent?.role}
+
I can take the next step. Pick the model and effort you want me to use, then send your instructions.
+
+ {sent.map((message, index) =>
+
{message.body}
+
To {message.agent} · {JSON.stringify(message.overrides ?? "agent default")}
+
)}
+
+
+
+
+
;
+}
diff --git a/ui/storybook/prototypes/composer-model-picker/README.md b/ui/storybook/prototypes/composer-model-picker/README.md
new file mode 100644
index 0000000000..11df65023e
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/README.md
@@ -0,0 +1,23 @@
+# Composer model and effort picker
+
+The approved design is `ComposerModelPickerPreview.tsx`, and the production control is `ui/src/components/task-chat/ComposerRunSettingsPicker.tsx`. Both appear under **Tasks → Composer → Model and effort picker**: the numbered design cases cover harness and model states, while **App picker in composer** and **App picker on mobile** render the production control with the same layout. The task composer passes its selection through the issue update request with the comment, so the next run reads the saved task adapter overrides. Settings stay on the task until changed or reset.
+
+One composer control opens a picker with searchable assignees at the top, then the model and a slider for effort. Its capsule stays on the right beside Send at both desktop and mobile widths. Assignee search matches names, roles, harnesses, and providers, and supports keyboard selection. The selected assignee fixes the harness and provider profile; model search is limited to that profile. The picker supports an exact model ID for harnesses that accept one. The selected effort name sits above the slider between a conditional fast-mode icon on the left and a reset icon on the right. When effort capability is unknown, the entire effort section is omitted. The picker animates its height as content changes and uses a centered, scrollable modal on mobile. Sending a message adds an in-memory transcript bubble with the selected settings.
+
+The stories use fixture state and do not alter task execution. The current adapter model API returns only `{ id, label }`; it cannot tell the client which OpenCode/OpenRouter variants a particular model accepts. The production control therefore uses the model default for those models and for unknown custom IDs. Model capability metadata would allow more precise sliders later.
+
+## Harness coverage
+
+| Harness | Model selection | Effort | Fast mode |
+| --- | --- | --- | --- |
+| Codex | Curated/search/manual | Model-specific Codex levels | Known supported models only |
+| Claude Code | Curated/search/manual | Low, medium, high on known models | No |
+| OpenCode with OpenRouter | Search and `openrouter/provider/model` manual ID | Uses model default until variant metadata is available | No |
+| Pi | Search/manual | Off through extra high on known models | No |
+| Kimi Code, CLI engine | Search/manual | Low, high, max on advertised capable models | No |
+| Gemini, Cursor, Grok, Hermes CLI | Search/manual | Not offered | No |
+| Cursor Cloud | Manual ID, account default | Not offered | No |
+| Paperclip Runner with Codex profile | Codex catalog only | Not offered until the Runner advertises model capabilities | No |
+| Process, HTTP, OpenClaw Gateway, Hermes Gateway | No per-message model setting | Not offered | No |
+
+The fixture models reflect repository adapter contracts as of 2026-09-26; provider availability can still depend on the installed CLI, account, environment, or connection. Switching agents clears the draft run settings. The two remote gateway harnesses deliberately leave model choice with their upstream service.
diff --git a/ui/storybook/prototypes/composer-model-picker/fixtures.ts b/ui/storybook/prototypes/composer-model-picker/fixtures.ts
new file mode 100644
index 0000000000..0e7a01136c
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/fixtures.ts
@@ -0,0 +1,101 @@
+import { codexLocalReasoningEffortsForModel, isCodexLocalFastModeSupported, isCodexLocalKnownModel } from "@paperclipai/adapter-codex-local";
+import { modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
+import { AGENT_PALETTE_IDS, appearanceForPalette } from "@paperclipai/shared";
+
+export type ModelOption = { id: string; label: string; detail?: string };
+export type ComposerAgent = {
+ id: string;
+ name: string;
+ role: string;
+ harness: string;
+ adapterType: string;
+ provider?: string;
+ defaultModel?: string;
+ defaultLabel?: string;
+ models: ModelOption[];
+ manualPattern?: string;
+ noModelReason?: string;
+};
+
+const codexModels: ModelOption[] = [
+ { id: "gpt-5.6-sol", label: "GPT-5.6 Sol", detail: "Agent default" },
+ { id: "gpt-6-astra", label: "GPT-6 Astra", detail: "Extended effort range" },
+ { 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-mini", label: "GPT-5.4 Mini", detail: "Fast mode unavailable" },
+];
+
+export const composerAgents: ComposerAgent[] = [
+ { id: "codex", name: "Codie", role: "Engineering", harness: "Codex", adapterType: "codex_local", defaultModel: "gpt-5.6-sol", models: codexModels, manualPattern: "Model ID, e.g. gpt-5.6-sol" },
+ { id: "claude", name: "Clara", role: "Research", harness: "Claude Code", adapterType: "claude_local", defaultModel: "claude-sonnet-5", models: [
+ { id: "claude-sonnet-5", label: "Claude Sonnet 5" },
+ { id: "claude-opus-5", label: "Claude Opus 5" },
+ { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
+ ], manualPattern: "Claude model ID" },
+ { id: "openrouter", name: "Nora", role: "Product", harness: "OpenCode", adapterType: "opencode_local", provider: "OpenRouter", defaultModel: "openrouter/anthropic/claude-sonnet-4.6", models: [
+ { id: "openrouter/anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", detail: "OpenRouter" },
+ { id: "openrouter/google/gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview", detail: "OpenRouter" },
+ { id: "openrouter/deepseek/deepseek-v4-flash", label: "DeepSeek V4 Flash", detail: "OpenRouter" },
+ ], manualPattern: "openrouter/provider/model" },
+ { id: "pi", name: "Pia", role: "Tooling", harness: "Pi", adapterType: "pi_local", defaultModel: "openrouter/anthropic/claude-sonnet-4.6", models: [
+ { id: "openrouter/anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", detail: "OpenRouter" },
+ { id: "openai/gpt-5.6-sol", label: "GPT-5.6 Sol", detail: "OpenAI" },
+ ], manualPattern: "provider/model" },
+ { id: "kimi", name: "Kimi", role: "Planning", harness: "Kimi Code", adapterType: "kimi_local", provider: "CLI engine", defaultModel: "kimi-code/k3", models: [
+ { id: "kimi-code/k3", label: "K3", detail: "Supports effort on CLI" },
+ { id: "kimi-code/kimi-for-coding", label: "K2.8 Preview", detail: "Supports effort on CLI" },
+ { id: "kimi-code/kimi-for-coding-highspeed", label: "K2.7 Coding Highspeed", detail: "Uses model default" },
+ { id: "kimi-code/k3-256k", label: "K3 (256K)", detail: "Supports effort on CLI" },
+ ], manualPattern: "kimi-code/model" },
+ { id: "gemini", name: "Gem", role: "Analysis", harness: "Gemini CLI", adapterType: "gemini_local", defaultModel: "auto", models: [
+ { id: "auto", label: "Auto" },
+ { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
+ ], manualPattern: "Gemini model ID" },
+ { id: "cursor", name: "Cora", role: "Design", harness: "Cursor", adapterType: "cursor", defaultModel: "auto", models: [
+ { id: "auto", label: "Auto" },
+ { id: "composer-1.5", label: "Composer 1.5" },
+ { id: "sonnet-4.6", label: "Sonnet 4.6" },
+ ], manualPattern: "Cursor model ID" },
+ { id: "cursor-cloud", name: "Cloudy", role: "Remote engineering", harness: "Cursor Cloud", adapterType: "cursor_cloud", defaultLabel: "Account default", models: [], manualPattern: "Cursor Cloud model ID" },
+ { id: "runner", name: "Runa", role: "Operations", harness: "Paperclip Runner", adapterType: "paperclip_runner", provider: "Codex profile", defaultModel: "gpt-5.6-sol", models: codexModels, manualPattern: "Codex model ID" },
+ { id: "grok", name: "Grok", role: "Investigation", harness: "Grok CLI", adapterType: "grok_local", defaultModel: "grok-build", models: [{ id: "grok-build", label: "Grok Build" }], manualPattern: "Grok model ID" },
+ { id: "hermes", name: "Hermes", role: "Operations", harness: "Hermes CLI", adapterType: "hermes_local", defaultModel: "auto", models: [{ id: "auto", label: "Auto" }], manualPattern: "Hermes model ID" },
+ { id: "process", name: "Relay", role: "Automation", harness: "Process", adapterType: "process", models: [], noModelReason: "This agent runs a command. Its harness does not expose a model or effort setting." },
+ { id: "http", name: "Hook", role: "Integration", harness: "HTTP", adapterType: "http", models: [], noModelReason: "This agent calls an HTTP endpoint. The destination service chooses its model." },
+ { id: "openclaw", name: "Ollie", role: "Support", harness: "OpenClaw Gateway", adapterType: "openclaw_gateway", models: [], noModelReason: "This gateway chooses its model remotely; Paperclip has no model catalog or per-message setting for it." },
+ { id: "hermes-gateway", name: "Hera", role: "Remote operations", harness: "Hermes Gateway", adapterType: "hermes_gateway", models: [], noModelReason: "This Hermes gateway chooses its model remotely; Paperclip cannot override it here." },
+];
+
+/** Share the capsule-avatar palettes used by the agent persona stories. */
+export function composerAgentAppearance(agentId: string) {
+ const index = Math.max(0, composerAgents.findIndex((agent) => agent.id === agentId));
+ return appearanceForPalette(AGENT_PALETTE_IDS[index % AGENT_PALETTE_IDS.length]);
+}
+
+export const effortLabels: Record = {
+ minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", max: "Max", ultra: "Ultra", off: "Off",
+};
+
+export function effortChoices(agent: ComposerAgent, model: string): readonly string[] {
+ if (agent.adapterType === "codex_local" || agent.id === "runner") return isCodexLocalKnownModel(model) ? codexLocalReasoningEffortsForModel(model) : [];
+ if (!agent.models.some((option) => option.id === 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;
+ // The current model API only returns id/label. OpenCode/OpenRouter variants are
+ // model-specific, so a guessed generic slider would send unsupported values.
+ return [];
+}
+
+export function fastModeAvailable(agent: ComposerAgent, model: string): boolean {
+ return (agent.adapterType === "codex_local" || agent.id === "runner")
+ && isCodexLocalKnownModel(model)
+ && isCodexLocalFastModeSupported(model);
+}
+
+export function modelLabel(agent: ComposerAgent, model: string): string {
+ if (!model) return agent.defaultLabel ?? "Harness default";
+ return agent.models.find((option) => option.id === model)?.label ?? model;
+}
diff --git a/ui/storybook/prototypes/composer-model-picker/picker.css b/ui/storybook/prototypes/composer-model-picker/picker.css
new file mode 100644
index 0000000000..2ce405e6c3
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/picker.css
@@ -0,0 +1,46 @@
+.composer-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-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-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-effort-range:focus-visible { outline: calc(var(--spacing) * 0.5) solid var(--ring); outline-offset: calc(var(--spacing) * 0.5); }
+
+.composer-picker-accent { color: var(--tc-mode-ask); }
+
+.composer-picker-auto-height {
+ overflow: hidden;
+ transition: height var(--motion-duration-base) var(--motion-ease-out);
+}
+
+.composer-picker-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-picker-auto-height { transition: none; }
+}
diff --git a/ui/storybook/stories/agent-personas.stories.tsx b/ui/storybook/stories/agent-personas.stories.tsx
index 007972cf98..d4a7069629 100644
--- a/ui/storybook/stories/agent-personas.stories.tsx
+++ b/ui/storybook/stories/agent-personas.stories.tsx
@@ -14,7 +14,7 @@ const meta = {
title: "Agents/Personas",
component: AgentCharacter,
args: { appearance, size: 256, state: "listening", label: "Chief of Staff" },
- parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Development Storybook uses PAPERCLIP_STORYBOOK_API_URL. Published Storybook packages PNGs from the same API renderer automatically during its build, including every preset and both densities; no running API is required." } } },
+ parameters: { docs: { description: { component: "Persistent cap-v1 identities. The hero alone loads ClipLab. Development Storybook renders capsule PNGs locally on demand; published Storybook packages the same PNGs during its build, including every preset and both densities. Neither needs a running API." } } },
argTypes: {
state: { control: "select", options: CHARACTER_STATES },
size: { control: "select", options: AGENT_AVATAR_SIZES },
diff --git a/ui/storybook/stories/composer-add-menu.stories.tsx b/ui/storybook/stories/composer-add-menu.stories.tsx
new file mode 100644
index 0000000000..b8a5e92fe8
--- /dev/null
+++ b/ui/storybook/stories/composer-add-menu.stories.tsx
@@ -0,0 +1,304 @@
+import { useState, type CSSProperties } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { ChevronLeft, Ellipsis } from "lucide-react";
+import type { IssueAttachment, IssueWorkMode, RunnerGoalCapability } from "@paperclipai/shared";
+import { MobileBottomNav } from "@/components/MobileBottomNav";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
+import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures";
+
+const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
+ id: agent.id,
+ name: agent.name,
+ appearance: composerAgentAppearance(agent.id),
+}]));
+const assignees = composerAgents.map((agent) => ({
+ id: `agent:${agent.id}`,
+ label: agent.name,
+ searchText: `${agent.name} ${agent.role} ${agent.harness}`,
+}));
+
+const goalCapability: RunnerGoalCapability = {
+ availability: "available",
+ verified: true,
+ actions: ["set", "pause", "resume", "clear"],
+ autonomousUpdates: true,
+ persistentAcrossResume: true,
+ maxObjectiveChars: 4_000,
+ tokenBudgetControl: true,
+ usageReporting: true,
+};
+
+interface ComposerAddStoryProps {
+ initialMode: IssueWorkMode;
+ goalAvailable: boolean;
+ mobile: boolean;
+ mobileContext: boolean;
+ fullBleedMobileContext: boolean;
+}
+
+function ComposerAddStory({ initialMode, goalAvailable, mobile, mobileContext, fullBleedMobileContext }: ComposerAddStoryProps) {
+ const [workMode, setWorkMode] = useState(initialMode);
+ const [sent, setSent] = useState([]);
+ const [goal, setGoal] = useState(null);
+
+ async function attachFile(file: File): Promise {
+ return {
+ id: crypto.randomUUID(), companyId: "storybook", issueId: "composer-story",
+ issueCommentId: null, assetId: crypto.randomUUID(), provider: "storybook",
+ objectKey: file.name, contentType: file.type, byteSize: file.size, sha256: "storybook",
+ originalFilename: file.name, createdByAgentId: null, createdByUserId: "storybook",
+ createdAt: new Date(), updatedAt: new Date(), contentPath: URL.createObjectURL(file),
+ };
+ }
+
+ const composer = setSent((messages) => [...messages, body])}
+ workMode={workMode}
+ onWorkModeChange={setWorkMode}
+ onAttachImage={attachFile}
+ enableReassign
+ reassignOptions={assignees}
+ agentMap={agentMap}
+ currentAssigneeValue="agent:codex"
+ runnerGoalCapability={goalAvailable ? goalCapability : { ...goalCapability, availability: "unsupported", actions: [] }}
+ onRunnerGoalCommand={goalAvailable ? async (command) => {
+ if (command.action === "create") setGoal(command.objective);
+ } : undefined}
+ mobile={mobile}
+ />;
+
+ if (mobileContext) return
+
+
+ PAP-1074 · Composer on mobile
+
+
+
+ {/* Production's full-width chat tab cancels the page's p-4 with -mx-4. */}
+
+
+
Tune the composer spacing for a phone screen.
+
The bottom navigation stays visible while writing.
+ {goal ?
Goal: {goal}
: null}
+ {sent.map((body, index) =>
{body}
)}
+
+
+
{composer}
+
+
+
+
;
+
+ return
+
+
+
+ {goal ?
Goal: {goal}
: null}
+ {sent.map((body, index) =>
{body}
)}
+
+ {composer}
+
+
;
+}
+
+const meta = {
+ title: "Composer/Add menu",
+ component: ComposerAddStory,
+ parameters: {
+ layout: "fullscreen",
+ options: { showPanel: false },
+ docs: { description: { component: "The production task composer. The Add menu opens upward on desktop and as a dialog on mobile for files, supported goals, Plan mode, and Ask mode. Plan and Ask are exclusive; selecting a mode shows a removable chip. Cmd+. cycles standard, Plan, and Ask. Mobile stories include capsule agent avatars and the actual bottom navigation." } },
+ },
+ args: { initialMode: "standard", goalAvailable: true, mobile: false, mobileContext: false, fullBleedMobileContext: false },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+async function openAdd(canvasElement: HTMLElement) {
+ const page = within(canvasElement.ownerDocument.body);
+ await userEvent.click(page.getByRole("button", { name: "Add to composer" }));
+ return page;
+}
+
+export const AddMenu: Story = {
+ name: "01 · Plus menu with goal",
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await expect(page.getByRole("menuitem", { name: /Files and images/ })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: /Goal/ })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: /Plan mode/ })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: /Ask mode/ })).toBeVisible();
+ },
+};
+
+export const PlanChip: Story = {
+ name: "02 · Plan mode chip",
+ args: { initialMode: "planning" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const chip = page.getByRole("button", { name: "Remove Plan mode" });
+ const assignee = page.getByTestId("task-chat-composer-assignee");
+ await expect(chip).toBeVisible();
+ await expect(chip.getBoundingClientRect().height).toBe(assignee.getBoundingClientRect().height);
+ await expect(Math.abs(chip.getBoundingClientRect().top - assignee.getBoundingClientRect().top)).toBeLessThanOrEqual(1);
+ },
+};
+
+export const AskChip: Story = {
+ name: "03 · Ask mode chip",
+ args: { initialMode: "ask" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const chip = page.getByRole("button", { name: "Remove Ask mode" });
+ const assignee = page.getByTestId("task-chat-composer-assignee");
+ await expect(chip).toBeVisible();
+ await expect(chip.getBoundingClientRect().height).toBe(assignee.getBoundingClientRect().height);
+ await expect(Math.abs(chip.getBoundingClientRect().top - assignee.getBoundingClientRect().top)).toBeLessThanOrEqual(1);
+ },
+};
+
+export const SwitchModes: Story = {
+ name: "04 · Choose and remove a mode",
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await userEvent.click(page.getByRole("menuitem", { name: /Plan mode/ }));
+ await expect(page.getByRole("button", { name: "Remove Plan mode" })).toBeVisible();
+ await userEvent.click(page.getByRole("button", { name: "Add to composer" }));
+ await userEvent.click(page.getByRole("menuitem", { name: /Ask mode/ }));
+ await expect(page.queryByRole("button", { name: "Remove Plan mode" })).not.toBeInTheDocument();
+ await userEvent.click(page.getByRole("button", { name: "Remove Ask mode" }));
+ await expect(page.queryByRole("button", { name: /Remove .* mode/ })).not.toBeInTheDocument();
+ },
+};
+
+export const FileUpload: Story = {
+ name: "05 · Attached file",
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const input = canvasElement.querySelector('input[type="file"]')!;
+ await userEvent.upload(input, new File(["Storybook attachment"], "launch-plan.txt", { type: "text/plain" }));
+ await expect(page.getByText("launch-plan.txt")).toBeVisible();
+ },
+};
+
+export const GoalUnavailable: Story = {
+ name: "06 · Agent without goals",
+ args: { goalAvailable: false },
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await expect(page.queryByRole("menuitem", { name: /Goal/ })).not.toBeInTheDocument();
+ },
+};
+
+export const Mobile: Story = {
+ name: "07 · Mobile Add dialog",
+ args: { mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await expect(page.getByRole("dialog", { name: "Add" })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Plan mode/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Files and images/ })).toBeVisible();
+ },
+};
+
+export const GoalDraft: Story = {
+ name: "08 · Start a supported goal",
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await userEvent.click(page.getByRole("menuitem", { name: /Goal/ }));
+ await expect(page.getByRole("textbox", { name: "editable markdown" })).toHaveTextContent("/goal");
+ },
+};
+
+export const MobileWithBottomBar: Story = {
+ name: "09 · Mobile with bottom bar",
+ args: { mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-dock")).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-assignee").querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
+ },
+};
+
+export const MobilePlanWithBottomBar: Story = {
+ name: "10 · Mobile plan and attachment with bottom bar",
+ args: { initialMode: "planning", mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const input = canvasElement.querySelector('input[type="file"]')!;
+ await userEvent.upload(input, new File(["Mobile layout"], "notes.txt", { type: "text/plain" }));
+ await expect(page.getByText("notes.txt")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Remove Plan mode" })).toBeVisible();
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ const plus = page.getByRole("button", { name: "Add to composer" }).getBoundingClientRect();
+ const send = page.getByRole("button", { name: "Send" }).getBoundingClientRect();
+ const chip = page.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
+ await expect(send.width).toBe(send.height);
+ await expect(Math.abs(plus.top - send.top)).toBeLessThanOrEqual(1);
+ await expect(Math.abs(chip.top - send.top)).toBeLessThanOrEqual(1);
+ await expect(page.getByRole("button", { name: "Remove Plan mode" }).querySelector(".sr-only")?.textContent).toBe("Plan mode");
+ },
+};
+
+export const MobileAskWithBottomBar: Story = {
+ name: "10b · Mobile Ask with bottom bar",
+ args: { initialMode: "ask", mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const chip = page.getByRole("button", { name: "Remove Ask mode" }).getBoundingClientRect();
+ const send = page.getByRole("button", { name: "Send" }).getBoundingClientRect();
+ await expect(send.width).toBe(send.height);
+ await expect(Math.abs(chip.top - send.top)).toBeLessThanOrEqual(1);
+ await expect(page.getByRole("button", { name: "Remove Ask mode" }).querySelector(".sr-only")?.textContent).toBe("Ask mode");
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ },
+};
+
+export const MobileFullBleedChatWithBottomBar: Story = {
+ name: "11 · Mobile full-width chat with bottom bar",
+ args: { mobile: true, mobileContext: true, fullBleedMobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-dock")).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-assignee").querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
+ },
+};
+
+export const MobileAddDialogWithBottomBar: Story = {
+ name: "12 · Mobile Add dialog with bottom bar",
+ args: { mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ await openAdd(canvasElement);
+ await expect(page.getByRole("dialog", { name: "Add" })).toBeVisible();
+ },
+};
+
+export const MobileGoalFromAddDialog: Story = {
+ name: "13 · Mobile Goal keeps editor focus",
+ args: { mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await userEvent.click(page.getByRole("button", { name: /Goal Keep pursuing/ }));
+ const editor = page.getByRole("textbox", { name: "editable markdown" });
+ await expect(editor).toHaveTextContent("/goal");
+ await waitFor(() => expect(editor).toHaveFocus());
+ },
+};
diff --git a/ui/storybook/stories/composer-mobile-assignee.stories.tsx b/ui/storybook/stories/composer-mobile-assignee.stories.tsx
new file mode 100644
index 0000000000..1f5f8d70b1
--- /dev/null
+++ b/ui/storybook/stories/composer-mobile-assignee.stories.tsx
@@ -0,0 +1,36 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory";
+
+const meta = {
+ title: "Composer/Mobile assignee picker",
+ component: ComposerRunSettingsLiveStory,
+ parameters: {
+ layout: "fullscreen",
+ docs: { description: { component: "The same assignee, model, and effort picker shown on desktop, in its responsive mobile dialog. Agent capsule avatars use the shared persona palettes." } },
+ },
+ args: { agentId: "codex", mobile: true, compact: true, initialPanel: "agents" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const PickerOpen: Story = {
+ globals: { viewport: { value: "mobile", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByTestId("composer-mobile-dialog")).toBeVisible();
+ await expect(page.getByRole("listbox", { name: "Assignees" })).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-assignee").querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
+ },
+};
+
+export const ComposerClosed: Story = {
+ args: { initialPanel: "closed" },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+};
+
+export const SearchAssignees: Story = {
+ args: { initialAssigneeSearch: "Claude" },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+};
diff --git a/ui/storybook/stories/composer-model-picker.stories.tsx b/ui/storybook/stories/composer-model-picker.stories.tsx
new file mode 100644
index 0000000000..ec421f5faf
--- /dev/null
+++ b/ui/storybook/stories/composer-model-picker.stories.tsx
@@ -0,0 +1,293 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import { ComposerModelPickerPreview } from "../prototypes/composer-model-picker/ComposerModelPickerPreview";
+import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory";
+
+const meta = {
+ title: "Composer/Model and effort picker",
+ component: ComposerModelPickerPreview,
+ parameters: {
+ layout: "fullscreen",
+ options: { showPanel: false },
+ docs: { description: { component:
+ "The approved composer picker design and two matching stories using the production picker. The assignee capsule stays beside Send on desktop and mobile. The assignee determines the harness and catalog; changing assignees clears per-message overrides. Effort is selected only with a model-specific slider where levels are known. The picker animates its height as content changes and opens as a modal on mobile. Custom IDs are accepted for harnesses that support them, while OpenRouter requires openrouter/provider/model. A fast-mode icon sits to the left of the effort label only for supported known Codex models, and the reset icon sits to the right."
+ } },
+ },
+ args: { agentId: "codex", initialPanel: "closed" },
+ render: (args) => ,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const DefaultComposer: Story = {
+ name: "01 · Unified assignee and model picker",
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const capsule = screen.getByRole("button", { name: "Select assignee, model and effort" });
+ const send = screen.getByRole("button", { name: "Send message" });
+ await expect(send.getBoundingClientRect().left - capsule.getBoundingClientRect().right).toBeLessThanOrEqual(16);
+ },
+};
+export const EffortSlider: Story = {
+ name: "02 · Codex effort slider",
+ args: { initialPanel: "settings", initialEffort: "high" },
+};
+export const ExactEffort: Story = {
+ name: "02b · Slider at Extra High",
+ args: { initialPanel: "settings", initialEffort: "xhigh" },
+};
+export const ExactModelList: Story = {
+ name: "03 · Search exact Codex models",
+ args: { initialPanel: "models" },
+};
+export const AstraFastMode: Story = {
+ name: "04 · Astra · fast icon active",
+ args: { initialModel: "gpt-6-astra", initialEffort: "ultra", initialFast: true, initialPanel: "settings" },
+};
+export const FastModeToggle: Story = {
+ name: "04b · Toggle fast icon",
+ args: { initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const toggle = screen.getByRole("button", { name: "Fast mode" });
+ await userEvent.click(toggle);
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+ },
+};
+export const FastModeUnavailable: Story = {
+ name: "05 · Model without fast mode",
+ args: { initialModel: "gpt-5.4-mini", initialPanel: "settings" },
+};
+export const CodexCustomUnknown: Story = {
+ name: "05b · Custom Codex ID · capabilities unknown",
+ args: { initialModel: "my-private-codex-model", initialPanel: "settings" },
+};
+export const ResetToAgentDefault: Story = {
+ name: "06 · Reset model, effort and fast mode",
+ args: { initialModel: "gpt-6-astra", initialEffort: "ultra", initialFast: true, initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(screen.getByRole("button", { name: "Reset to agent default" }));
+ await expect(screen.getByTestId("selected-effort")).toHaveTextContent("Default");
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("GPT-5.6 Sol");
+ await expect(screen.getByRole("button", { name: "Fast mode" })).toHaveAttribute("aria-pressed", "false");
+ },
+};
+export const Claude: Story = {
+ name: "07 · Claude Code · low to high",
+ args: { agentId: "claude", initialPanel: "settings", initialEffort: "medium" },
+};
+export const OpenRouterSearch: Story = {
+ name: "08 · OpenRouter · search this provider",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "deepseek" },
+};
+export const OpenRouterCustomId: Story = {
+ name: "09 · OpenRouter · pasted custom ID",
+ args: { agentId: "openrouter", initialModel: "openrouter/qwen/qwen3-coder-next", initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ await expect(screen.getByRole("button", { name: "Reset to agent default" })).toBeVisible();
+ },
+};
+export const OpenRouterManualEntry: Story = {
+ name: "10 · OpenRouter · type exact model",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "openrouter/qwen/qwen3-coder-next" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(screen.getByRole("button", { name: /Use exact ID/ }));
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("openrouter/qwen/qwen3-coder-next");
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ },
+};
+export const OpenRouterPasteProposal: Story = {
+ name: "10b · OpenRouter · paste proposal",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "openrouter/qwen/qwen3-coder-next" },
+};
+export const OpenRouterInvalidId: Story = {
+ name: "10c · OpenRouter · invalid ID guidance",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "anthropic/claude-sonnet-4.6" },
+};
+export const PiThinking: Story = {
+ name: "11 · Pi · thinking levels",
+ args: { agentId: "pi", initialPanel: "settings", initialEffort: "high" },
+};
+export const KimiSupported: Story = {
+ name: "12 · Kimi K3 · low, high, max",
+ args: { agentId: "kimi", initialPanel: "settings", initialEffort: "high" },
+};
+export const KimiModelDefault: Story = {
+ name: "13 · Kimi highspeed · no effort override",
+ args: { agentId: "kimi", initialModel: "kimi-code/kimi-for-coding-highspeed", initialPanel: "settings" },
+};
+export const GeminiModelOnly: Story = {
+ name: "14 · Gemini · model only",
+ args: { agentId: "gemini", initialPanel: "settings" },
+};
+export const CursorModelOnly: Story = {
+ name: "15 · Cursor · model only",
+ args: { agentId: "cursor", initialPanel: "settings" },
+};
+export const CursorCloudManual: Story = {
+ name: "15b · Cursor Cloud · manual model ID",
+ args: { agentId: "cursor-cloud", initialPanel: "models" },
+};
+export const RunnerCodexProfile: Story = {
+ name: "16 · Runner · Codex profile",
+ args: { agentId: "runner", initialPanel: "models" },
+};
+export const GrokModelOnly: Story = {
+ name: "17 · Grok · model only",
+ args: { agentId: "grok", initialPanel: "settings" },
+};
+export const HermesManual: Story = {
+ name: "17b · Hermes CLI · manual model ID",
+ args: { agentId: "hermes", initialPanel: "models" },
+};
+export const ProcessNoModel: Story = {
+ name: "18 · Process · no model setting",
+ args: { agentId: "process", initialPanel: "settings" },
+};
+export const HttpNoModel: Story = {
+ name: "18b · HTTP · remote model",
+ args: { agentId: "http", initialPanel: "settings" },
+};
+export const GatewayNoModel: Story = {
+ name: "19 · OpenClaw · remote model",
+ args: { agentId: "openclaw", initialPanel: "settings" },
+};
+export const HermesGatewayNoModel: Story = {
+ name: "19b · Hermes Gateway · remote model",
+ args: { agentId: "hermes-gateway", initialPanel: "settings" },
+};
+export const AgentMenu: Story = {
+ name: "20 · Searchable assignee list",
+ args: { initialPanel: "agents" },
+};
+export const AssigneeSearch: Story = {
+ name: "20a · Search by assignee harness",
+ args: { initialPanel: "agents", initialAssigneeSearch: "OpenRouter" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.getByRole("searchbox", { name: "Search assignees" })).toHaveValue("OpenRouter");
+ await expect(screen.getAllByRole("option")).toHaveLength(1);
+ await expect(screen.getByRole("option", { name: /Nora/ })).toBeVisible();
+ },
+};
+export const AssigneeSearchNoMatches: Story = {
+ name: "20aa · Assignee search has no matches",
+ args: { initialPanel: "agents", initialAssigneeSearch: "unknown teammate" },
+};
+export const AssigneeSearchKeyboard: Story = {
+ name: "20ab · Choose searched assignee with Enter",
+ args: { initialPanel: "agents", initialAssigneeSearch: "OpenRouter" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.type(screen.getByRole("searchbox", { name: "Search assignees" }), "{Enter}");
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("Claude Sonnet 4.6");
+ },
+};
+export const AgentSwitchClearsOverrides: Story = {
+ name: "20b · Switching agents resets overrides",
+ args: { initialModel: "gpt-6-astra", initialEffort: "ultra", initialFast: true, initialPanel: "agents" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(screen.getByRole("option", { name: /Nora/ }));
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("Claude Sonnet 4.6");
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ await expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull();
+ },
+};
+export const Mobile: Story = {
+ name: "21 · Mobile · centered picker modal",
+ args: { agentId: "codex", initialPanel: "settings", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.getByTestId("composer-mobile-dialog")).toBeVisible();
+ await expect(screen.getByRole("slider", { name: "Effort" })).toBeVisible();
+ },
+};
+export const MobileAssigneeSearch: Story = {
+ name: "21b · Mobile assignee search",
+ args: { agentId: "codex", initialPanel: "agents", initialAssigneeSearch: "Claude", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
+export const MobileOpenRouterCustomId: Story = {
+ name: "21c · Mobile · custom model without effort",
+ args: { agentId: "openrouter", initialModel: "openrouter/qwen/qwen3-coder-next", initialPanel: "settings", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.getByTestId("composer-mobile-dialog")).toBeVisible();
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ },
+};
+export const MobileModelSearch: Story = {
+ name: "21d · Mobile · searchable model modal",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "deepseek", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
+export const Light: Story = {
+ name: "22 · Light theme",
+ args: { agentId: "claude", initialPanel: "settings" },
+ globals: { theme: "light" },
+};
+
+export const ProductionComposer: Story = {
+ name: "23 · App picker in composer",
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const capsule = screen.getByTestId("task-chat-composer-assignee");
+ const send = screen.getByRole("button", { name: "Send message" });
+ await expect(send.getBoundingClientRect().left - capsule.getBoundingClientRect().right).toBeLessThanOrEqual(16);
+ },
+};
+
+export const ProductionMobileComposer: Story = {
+ name: "23b · App picker on mobile",
+ render: () => ,
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const capsule = screen.getByTestId("task-chat-composer-assignee");
+ const send = screen.getByRole("button", { name: "Send message" });
+ await expect(send.getBoundingClientRect().left - capsule.getBoundingClientRect().right).toBeLessThanOrEqual(16);
+ },
+};
+
+export const IntermediateWidthPlan: Story = {
+ name: "24 · Narrow desktop with Plan and open picker",
+ args: { compact: true, initialMode: "planning", initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const chip = screen.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
+ const capsule = screen.getByRole("button", { name: "Select assignee, model and effort" }).getBoundingClientRect();
+ const send = screen.getByRole("button", { name: "Send message" }).getBoundingClientRect();
+ await expect(chip.height).toBe(capsule.height);
+ await expect(Math.abs(send.top - capsule.top)).toBeLessThanOrEqual(1);
+ await expect(send.left - capsule.right).toBeLessThanOrEqual(16);
+ await expect(screen.getByTestId("composer-model-popover")).toBeVisible();
+ },
+};
+
+export const ProductionIntermediateWidthPlan: Story = {
+ name: "24b · App picker at narrow desktop width",
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const chip = screen.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
+ const capsule = screen.getByTestId("task-chat-composer-assignee").getBoundingClientRect();
+ const send = screen.getByRole("button", { name: "Send message" }).getBoundingClientRect();
+ await expect(chip.height).toBe(capsule.height);
+ await expect(Math.abs(send.top - capsule.top)).toBeLessThanOrEqual(1);
+ await expect(send.left - capsule.right).toBeLessThanOrEqual(16);
+ await expect(screen.getByTestId("composer-model-popover")).toBeVisible();
+ },
+};
diff --git a/ui/storybook/stories/composer-question-flow.stories.tsx b/ui/storybook/stories/composer-question-flow.stories.tsx
new file mode 100644
index 0000000000..d044c9f009
--- /dev/null
+++ b/ui/storybook/stories/composer-question-flow.stories.tsx
@@ -0,0 +1,122 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
+import { QuestionForm, QuestionResponseSummary } from "@/components/task-chat/QuestionForm";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { Button } from "@/components/ui/button";
+
+const composerQuestions: PaperclipQuestionSet = {
+ schema: "paperclip.question_set.v1",
+ title: "Express app — scope",
+ questions: [
+ {
+ id: "storage",
+ prompt: "Does it need to store anything?",
+ answerMode: "single_select",
+ required: true,
+ options: [
+ { id: "memory", label: "No — in-memory is fine", description: "Fastest to something running; state dies on restart." },
+ { id: "sqlite", label: "SQLite file", description: "Real persistence, zero infrastructure. Good default for a first version." },
+ { id: "postgres", label: "Postgres", description: "Needs a database to point at." },
+ ],
+ customAnswer: { enabled: true },
+ },
+ {
+ id: "features",
+ prompt: "Which features should it include?",
+ helpText: "Choose all that apply, then click Next.",
+ answerMode: "multi_select",
+ required: true,
+ options: [
+ { id: "auth", label: "Sign in" },
+ { id: "search", label: "Search" },
+ { id: "uploads", label: "File uploads" },
+ ],
+ },
+ {
+ id: "timing",
+ prompt: "When should we start?",
+ answerMode: "single_select",
+ required: true,
+ options: [
+ { id: "now", label: "Now" },
+ { id: "later", label: "Later" },
+ ],
+ },
+ ],
+};
+
+function InteractiveComposerQuestions() {
+ const [response, setResponse] = useState(null);
+ const [open, setOpen] = useState(true);
+ const [reset, setReset] = useState(0);
+ return (
+
+ {response ? (
+
+ ) : null}
+
{}}
+ workMode="standard"
+ takeover={open ? {
+ id: `composer-questions-${reset}`,
+ label: composerQuestions.title!,
+ pendingCount: 1,
+ inlineSkip: true,
+ content: { setResponse(next); setOpen(false); }}
+ />,
+ onDismiss: () => setOpen(false),
+ onSkip: () => setOpen(false),
+ } : null}
+ />
+ { setResponse(null); setReset((value) => value + 1); setOpen(true); }}>
+ Restart questions
+
+
+ );
+}
+
+const meta = {
+ title: "Composer/Question flow",
+ component: InteractiveComposerQuestions,
+ parameters: {
+ layout: "padded",
+ docs: { description: { component: "A structured question card above the usable composer. Choices stay selected until Next or Submit, and Other opens a text field." } },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const QuestionsAndComposer: Story = {};
+export const AnswersSubmitted: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("radio", { name: "Other" }));
+ await expect(canvas.getByText("1 of 3")).toBeVisible();
+ await expect(canvas.getByTestId("question-other-answer-composer")).toBeVisible();
+ await userEvent.click(canvas.getByRole("radio", { name: /SQLite file/ }));
+ await expect(canvas.getByText("1 of 3")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Next" }));
+ await expect(canvas.getByText("2 of 3")).toBeVisible();
+ await userEvent.click(canvas.getByRole("checkbox", { name: "Sign in" }));
+ await userEvent.click(canvas.getByRole("checkbox", { name: "Search" }));
+ await expect(canvas.getByText("2 of 3")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Next" }));
+ await userEvent.click(canvas.getByRole("radio", { name: "Now" }));
+ await expect(canvas.getByText("3 of 3")).toBeVisible();
+ await expect(canvas.queryByText("Answers submitted")).not.toBeInTheDocument();
+ await userEvent.click(canvas.getByRole("button", { name: "Submit answers" }));
+ await expect(canvas.getByText("Answers submitted")).toBeVisible();
+ await expect(canvas.getByText("SQLite file", { exact: true })).toBeVisible();
+ await expect(canvas.getByText("Sign in, Search", { exact: true })).toBeVisible();
+ },
+};
diff --git a/ui/storybook/stories/composer-queued-messages.stories.tsx b/ui/storybook/stories/composer-queued-messages.stories.tsx
new file mode 100644
index 0000000000..b2dfe5a561
--- /dev/null
+++ b/ui/storybook/stories/composer-queued-messages.stories.tsx
@@ -0,0 +1,157 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
+import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures";
+
+const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
+ id: agent.id,
+ name: agent.name,
+ appearance: composerAgentAppearance(agent.id),
+}]));
+
+function queuedEntry(body: string, id: string, position: number): IssueQueuedCommentEntry {
+ return {
+ comment: {
+ id,
+ companyId: "storybook",
+ issueId: "composer-queue-story",
+ authorType: "user",
+ authorAgentId: null,
+ authorUserId: "storybook-user",
+ body,
+ presentation: null,
+ metadata: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ position,
+ canEdit: true,
+ canDiscard: true,
+ };
+}
+
+const initialEntries = [
+ queuedEntry("Check the mobile layout after this pass.", "queued-1", 0),
+ queuedEntry("Then update the accessibility notes.", "queued-2", 1),
+];
+
+type QueueStoryProps = {
+ protocol: IssueQueuedCommentQueue["protocol"];
+ steeringDisposition: IssueQueuedCommentQueue["steeringDisposition"];
+ initialEdit: boolean;
+ mobile: boolean;
+};
+
+function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile }: QueueStoryProps) {
+ const [entries, setEntries] = useState(initialEntries);
+ const [editingId, setEditingId] = useState(initialEdit ? "queued-1" : null);
+ const [sent, setSent] = useState([]);
+ const [notice, setNotice] = useState(null);
+ const [running, setRunning] = useState(true);
+ const queue: IssueQueuedCommentQueue = {
+ issueId: "composer-queue-story",
+ queueId: "queue-story",
+ state: "deferred",
+ targetRunId: running ? "active-run" : null,
+ revision: "story-revision",
+ protocol,
+ steeringDisposition,
+ entries,
+ };
+
+ function remove(id: string) {
+ setEntries((current) => current.filter((entry) => entry.comment.id !== id)
+ .map((entry, position) => ({ ...entry, position })));
+ }
+
+ return (
+
+
+
+
+
I’m updating the composer and checking the responsive layout.
+ {sent.map((body, index) =>
{body}
)}
+ {notice ?
{notice}
: null}
+
+
+ {entries.length ?
setEntries((current) => ids.map((id, position) => ({
+ ...current.find((entry) => entry.comment.id === id)!, position,
+ })))}
+ onSteer={async (id) => { remove(id); setNotice("Message steered into the active turn."); }}
+ onInterrupt={async () => { setRunning(false); setNotice("The active turn was interrupted."); }}
+ onDiscard={async (id) => { remove(id); setNotice("Queued message discarded."); }}
+ /> : null}
+
+ {
+ if (running) {
+ setEntries((current) => [...current, queuedEntry(body, `queued-${Date.now()}`, current.length)]);
+ setNotice("Message queued for the next turn.");
+ } else {
+ setSent((current) => [...current, body]);
+ }
+ }}
+ workMode="standard"
+ onStop={running ? async () => setRunning(false) : undefined}
+ queuedEdit={editingId ? {
+ commentId: editingId,
+ body: entries.find((entry) => entry.comment.id === editingId)?.comment.body ?? "",
+ } : null}
+ onSaveQueuedEdit={async (id, body) => {
+ setEntries((current) => current.map((entry) => entry.comment.id === id
+ ? { ...entry, comment: { ...entry.comment, body } }
+ : entry));
+ setEditingId(null);
+ }}
+ onCancelQueuedEdit={() => setEditingId(null)}
+ enableReassign
+ reassignOptions={composerAgents.map((agent) => ({ id: `agent:${agent.id}`, label: agent.name }))}
+ currentAssigneeValue="agent:codex"
+ agentMap={agentMap}
+ mobile={mobile}
+ />
+
+
+
+
+ );
+}
+
+const meta = {
+ title: "Composer/Steering and queued messages",
+ component: QueuedComposer,
+ parameters: {
+ layout: "fullscreen",
+ docs: { description: { component: "The production queued-message strip sits above the production composer. Steer, edit, reorder, discard, and legacy interrupt can be tried locally." } },
+ },
+ args: { protocol: "paperclip_runner_v1", steeringDisposition: "available", initialEdit: false, mobile: false },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const QueuedWhileRunning: Story = {};
+export const SteerIntoActiveTurn: Story = {
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await userEvent.click(page.getByTestId("task-chat-queued-steer-queued-1"));
+ await expect(page.queryByText("Check the mobile layout after this pass.")).not.toBeInTheDocument();
+ await expect(page.getByText("Message steered into the active turn.")).toBeVisible();
+ },
+};
+export const EditQueuedMessage: Story = { args: { initialEdit: true } };
+export const LegacyInterrupt: Story = { args: { protocol: "legacy" } };
+export const SteeringUnavailable: Story = { args: { steeringDisposition: "unsupported" } };
+export const MobileQueue: Story = {
+ args: { mobile: true },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+};
diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx
index a4bd74796b..371082cbda 100644
--- a/ui/storybook/stories/issue-thread-interactions.stories.tsx
+++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx
@@ -1,9 +1,4 @@
import { useEffect, useRef, useState } from "react";
-import { expect, userEvent, waitFor, within } from "storybook/test";
-import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
-import { QuestionForm, QuestionResponseSummary } from "@/components/task-chat/QuestionForm";
-import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
-import { Button } from "@/components/ui/button";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
@@ -501,121 +496,6 @@ export const SuggestedTasksRejected: Story = {
),
};
-const composerQuestions: PaperclipQuestionSet = {
- schema: "paperclip.question_set.v1",
- title: "Express app — scope",
- questions: [
- {
- id: "storage",
- prompt: "Does it need to store anything?",
- answerMode: "single_select",
- required: true,
- options: [
- { id: "memory", label: "No — in-memory is fine", description: "Fastest to something running; state dies on restart." },
- { id: "sqlite", label: "SQLite file", description: "Real persistence, zero infrastructure. Good default for a first version." },
- { id: "postgres", label: "Postgres", description: "Needs a database to point at." },
- ],
- customAnswer: { enabled: true },
- },
- {
- id: "features",
- prompt: "Which features should it include?",
- helpText: "Choose all that apply, then click Next.",
- answerMode: "multi_select",
- required: true,
- options: [
- { id: "auth", label: "Sign in" },
- { id: "search", label: "Search" },
- { id: "uploads", label: "File uploads" },
- ],
- },
- {
- id: "timing",
- prompt: "When should we start?",
- answerMode: "single_select",
- required: true,
- options: [
- { id: "now", label: "Now" },
- { id: "later", label: "Later" },
- ],
- },
- ],
-};
-
-function InteractiveComposerQuestions() {
- const [response, setResponse] = useState(null);
- const [open, setOpen] = useState(true);
- const [reset, setReset] = useState(0);
- return (
-
- {response ? (
-
- ) : null}
-
{}}
- workMode="standard"
- takeover={open ? {
- id: `composer-questions-${reset}`,
- label: composerQuestions.title!,
- pendingCount: 1,
- inlineSkip: true,
- content: { setResponse(next); setOpen(false); }}
- />,
- onDismiss: () => setOpen(false),
- onSkip: () => setOpen(false),
- } : null}
- />
- { setResponse(null); setReset((value) => value + 1); setOpen(true); }}>
- Restart questions
-
-
- );
-}
-
-/** Single choices advance, while Other, multi-select, and final submission wait. */
-export const ComposerQuestionsAutoAdvance: Story = {
- render: () => (
-
-
-
-
-
- ),
-};
-
-export const ComposerQuestionsAutoAdvanceVerified: Story = {
- ...ComposerQuestionsAutoAdvance,
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- await userEvent.click(canvas.getByRole("radio", { name: "Other" }));
- await expect(canvas.getByText("1 of 3")).toBeVisible();
- await expect(canvas.getByTestId("question-other-answer-composer")).toBeVisible();
- await userEvent.click(canvas.getByRole("radio", { name: /SQLite file/ }));
- await waitFor(() => expect(canvas.getByText("2 of 3")).toBeVisible());
- await userEvent.click(canvas.getByRole("checkbox", { name: "Sign in" }));
- await userEvent.click(canvas.getByRole("checkbox", { name: "Search" }));
- await expect(canvas.getByText("2 of 3")).toBeVisible();
- await userEvent.click(canvas.getByRole("button", { name: "Next" }));
- await userEvent.click(canvas.getByRole("radio", { name: "Now" }));
- await expect(canvas.getByText("3 of 3")).toBeVisible();
- await expect(canvas.queryByText("Answers submitted")).not.toBeInTheDocument();
- await userEvent.click(canvas.getByRole("button", { name: "Submit answers" }));
- await expect(canvas.getByText("Answers submitted")).toBeVisible();
- await expect(canvas.getByText("SQLite file", { exact: true })).toBeVisible();
- await expect(canvas.getByText("Sign in, Search", { exact: true })).toBeVisible();
- },
-};
-
export const AskUserQuestionsPending: Story = {
render: () => (
diff --git a/ui/storybook/stories/mobile-entity-pickers.stories.tsx b/ui/storybook/stories/mobile-entity-pickers.stories.tsx
index b3972ef2d1..ea06c277d4 100644
--- a/ui/storybook/stories/mobile-entity-pickers.stories.tsx
+++ b/ui/storybook/stories/mobile-entity-pickers.stories.tsx
@@ -3,7 +3,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { SearchableSelect } from "@/components/SearchableSelect";
-import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
const assignees: InlineEntityOption[] = [
{ id: "agent-product", label: "Product Lead", searchText: "planning product" },
@@ -60,25 +59,6 @@ export const ProjectPicker: Story = {
render: () => ,
};
-export const ComposerAssigneePicker: Story = {
- render: () => (
-
- undefined}
- workMode="standard"
- mobile
- enableReassign
- reassignOptions={assignees.map((option) => ({ ...option, id: `agent:${option.id}` }))}
- currentAssigneeValue=""
- />
-
- ),
- play: async () => {
- const page = within(document.body);
- await userEvent.click(await page.findByTestId("task-chat-composer-assignee"));
- },
-};
-
export const SearchableSelectModal: Story = {
render: () => (
diff --git a/ui/storybook/stories/paused-composer.stories.tsx b/ui/storybook/stories/paused-composer.stories.tsx
index 1cdcf7f807..83670ad3fa 100644
--- a/ui/storybook/stories/paused-composer.stories.tsx
+++ b/ui/storybook/stories/paused-composer.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { PausedComposerPreview } from "../prototypes/PausedTaskComposer";
const meta = {
- title: "Tasks/Composer/Paused task takeover",
+ title: "Composer/Paused task takeover",
component: PausedComposerPreview,
parameters: {
layout: "fullscreen",
diff --git a/ui/storybook/stories/session-goals.stories.tsx b/ui/storybook/stories/session-goals.stories.tsx
index 4ebf644936..b963440518 100644
--- a/ui/storybook/stories/session-goals.stories.tsx
+++ b/ui/storybook/stories/session-goals.stories.tsx
@@ -159,7 +159,7 @@ function SessionGoalStates() {
}
const meta = {
- title: "Product/Agent session goals",
+ title: "Composer/Session goals",
component: SessionGoalStates,
parameters: {
docs: {
diff --git a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx b/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx
new file mode 100644
index 0000000000..5fe19d676e
--- /dev/null
+++ b/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx
@@ -0,0 +1,255 @@
+import { useState, type CSSProperties } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { IssueThreadInteraction, IssueWorkMode } from "@paperclipai/shared";
+import { ChevronLeft, Ellipsis } from "lucide-react";
+import { MobileBottomNav } from "@/components/MobileBottomNav";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
+import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
+import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCard";
+import type { TaskChatRuntimeRequestItem } from "@/components/task-chat/task-chat-model";
+import {
+ genericPendingRequestConfirmationInteraction,
+ pendingAskUserQuestionsInteraction,
+ pendingRequestCheckboxConfirmationInteraction,
+ pendingRequestConfirmationInteraction,
+ pendingRequestItemVerdictsInteraction,
+ pendingSuggestedTasksInteraction,
+ pendingToolActionWriteInteraction,
+ issueThreadInteractionFixtureMeta,
+} from "@/fixtures/issueThreadInteractionFixtures";
+import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures";
+import { storybookAgentMap } from "../fixtures/paperclipData";
+
+const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
+ id: agent.id,
+ name: agent.name,
+ appearance: composerAgentAppearance(agent.id),
+}]));
+const assignees = composerAgents.map((agent) => ({ id: `agent:${agent.id}`, label: agent.name }));
+const mobileQuestionInteraction = {
+ ...pendingAskUserQuestionsInteraction,
+ id: "interaction-mobile-question",
+ title: "Question",
+ payload: {
+ ...pendingAskUserQuestionsInteraction.payload,
+ title: "Question",
+ questions: [{
+ ...pendingAskUserQuestionsInteraction.payload.questions[0],
+ id: "draft-choice",
+ prompt: "Should I use the existing draft?",
+ helpText: undefined,
+ options: [
+ { id: "keep", label: "Use the existing draft" },
+ { id: "fresh", label: "Start fresh" },
+ ],
+ }],
+ },
+};
+
+const mobileDetailedQuestionInteraction = {
+ ...pendingAskUserQuestionsInteraction,
+ id: "interaction-mobile-detailed-question",
+ payload: {
+ ...pendingAskUserQuestionsInteraction.payload,
+ questions: [pendingAskUserQuestionsInteraction.payload.questions[0]],
+ },
+};
+
+function InteractionAboveComposer({ interaction, mobile = false }: { interaction: IssueThreadInteraction; mobile?: boolean }) {
+ const [workMode, setWorkMode] = useState
("standard");
+ const [open, setOpen] = useState(true);
+ const [pending, setPending] = useState(true);
+ const [messages, setMessages] = useState([]);
+ const resolve = () => { setPending(false); setOpen(false); };
+ const content = (
+ resolve()}
+ onRejectInteraction={async () => resolve()}
+ onSubmitInteractionAnswers={async () => resolve()}
+ onSubmitInteractionVerdicts={async () => resolve()}
+ />
+ );
+ const composer = (
+ setMessages((current) => [...current, body])}
+ workMode={workMode}
+ onWorkModeChange={setWorkMode}
+ enableReassign
+ currentAssigneeValue="agent:codex"
+ reassignOptions={assignees}
+ agentMap={agentMap}
+ mobile={mobile}
+ takeover={pending && open ? {
+ id: interaction.id,
+ label: interaction.title ?? "Pending input",
+ pendingCount: 1,
+ content,
+ onDismiss: () => setOpen(false),
+ onSkip: resolve,
+ inlineSkip: interaction.kind === "ask_user_questions" || interaction.kind === "request_item_verdicts",
+ hideSkip: interaction.kind !== "ask_user_questions" && interaction.kind !== "request_item_verdicts",
+ hideLabel: interaction.kind === "request_confirmation" && Boolean(interaction.payload.toolAction),
+ } : null}
+ pendingTakeover={pending ? { count: 1, label: "1 pending input", onOpen: () => setOpen(true) } : null}
+ />
+ );
+
+ if (mobile) return (
+
+
+
+ PAP-1715 · Interaction review
+
+
+
+
+
+ {messages.map((body, index) =>
{body}
)}
+
+
+
{composer}
+
+
+
+
+ );
+
+ return (
+
+
+
+
+ {messages.map((body, index) =>
{body}
)}
+
+ {composer}
+
+
+ );
+}
+
+const meta = {
+ title: "Composer/Interaction above composer",
+ component: InteractionAboveComposer,
+ parameters: {
+ layout: "fullscreen",
+ options: { showPanel: false },
+ docs: { description: { component: "The production task interaction card and composer shown together. Dismissal leaves a pending indicator; Skip or a decision resolves the card. The editor remains available throughout." } },
+ },
+ args: { interaction: pendingAskUserQuestionsInteraction, mobile: false },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const AskUserQuestions: Story = {};
+export const AskUserQuestionsAndSend: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const editor = canvas.getByTestId("mdx-editor");
+ await userEvent.click(editor);
+ await userEvent.type(editor, "I can keep working while I decide.");
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
+ await expect(canvas.getByText("I can keep working while I decide.")).toBeVisible();
+ await expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible();
+ },
+};
+export const PlanReview: Story = { args: { interaction: pendingRequestConfirmationInteraction } };
+export const SimpleConfirmation: Story = { args: { interaction: genericPendingRequestConfirmationInteraction } };
+export const CheckboxConfirmation: Story = { args: { interaction: pendingRequestCheckboxConfirmationInteraction } };
+export const ItemVerdicts: Story = { args: { interaction: pendingRequestItemVerdictsInteraction } };
+export const SuggestedTasks: Story = { args: { interaction: pendingSuggestedTasksInteraction } };
+export const ToolReview: Story = { args: { interaction: pendingToolActionWriteInteraction } };
+
+const runtimeRequest: TaskChatRuntimeRequestItem = {
+ id: "runtime-question",
+ kind: "protocol",
+ surface: "runtime_request",
+ runId: "storybook-run",
+ requestId: "runtime-question",
+ requestKind: "user_input",
+ turnId: "storybook-turn",
+ requestType: "input",
+ status: "pending",
+ prompt: "The agent needs your input to continue.",
+ choices: [],
+ fields: [],
+ questionSet: {
+ schema: "paperclip.question_set.v1",
+ title: "Runtime question",
+ questions: [{
+ id: "environment",
+ prompt: "Which environment should the run target?",
+ answerMode: "single_select",
+ required: true,
+ options: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production" }],
+ }],
+ },
+};
+
+function RuntimeQuestionAboveComposer() {
+ const [open, setOpen] = useState(true);
+ return
+
+ {}}
+ workMode="standard"
+ takeover={open ? {
+ id: runtimeRequest.id,
+ label: "Runtime question",
+ pendingCount: 1,
+ content: setOpen(false)} />,
+ onDismiss: () => setOpen(false),
+ onSkip: () => setOpen(false),
+ inlineSkip: true,
+ } : null}
+ />
+
+
;
+}
+
+export const RuntimeQuestion: Story = { render: () => };
+export const MobileQuestionsWithBottomBar: Story = {
+ args: { interaction: mobileQuestionInteraction, mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible();
+ await expect(canvas.getByTestId("task-chat-composer-input")).toBeVisible();
+ await expect(canvas.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ },
+};
+export const MobileDetailedQuestionsWithBottomBar: Story = {
+ name: "Mobile detailed questions with bottom bar",
+ args: { interaction: mobileDetailedQuestionInteraction, mobile: true },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const card = canvas.getByTestId("task-chat-composer-takeover");
+ await expect(canvas.getByRole("radio", { name: /Only collapse hidden descendants/ })).toBeVisible();
+ await expect(canvas.getByRole("radio", { name: /Collapse all descendants by default/ })).toBeVisible();
+ await expect(canvas.getByRole("radio", { name: "Other" })).toBeVisible();
+ await expect(card.scrollHeight).toBeLessThanOrEqual(card.clientHeight);
+ const composer = canvas.getByTestId("task-chat-composer-input");
+ const nav = canvas.getByRole("navigation", { name: "Mobile navigation" });
+ await expect(composer).toBeVisible();
+ await expect(card.getBoundingClientRect().bottom).toBeLessThan(composer.getBoundingClientRect().top);
+ await expect(composer.getBoundingClientRect().bottom).toBeLessThan(nav.getBoundingClientRect().top);
+ },
+};
+export const MobileConfirmationWithBottomBar: Story = {
+ args: { interaction: genericPendingRequestConfirmationInteraction, mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
+export const MobileToolReviewWithBottomBar: Story = {
+ args: { interaction: pendingToolActionWriteInteraction, mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
diff --git a/ui/storybook/stories/task-execution-controls.stories.tsx b/ui/storybook/stories/task-execution-controls.stories.tsx
index c6f748934d..27d72d4340 100644
--- a/ui/storybook/stories/task-execution-controls.stories.tsx
+++ b/ui/storybook/stories/task-execution-controls.stories.tsx
@@ -318,7 +318,7 @@ function TaskExecutionExample({
}
const meta = {
- title: "Tasks/Execution Controls",
+ title: "Composer/Execution controls",
component: TaskExecutionExample,
parameters: { layout: "padded" },
} satisfies Meta;