fix(ui): use available space for composer labels (#15050)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The task composer shows the next assignee and model before a message
starts work.
> - Fixed width limits shortened both labels when the composer had
unused space.
> - People could not read the selected agent and model even when the
full text could fit.
> - This pull request makes the capsule use the available composer
width.
> - It keeps the ellipsis when Plan mode or a narrow layout causes real
space pressure.
> - The benefit is clearer run settings without damage to the compact
composer layout.

## Linked Issues or Issue Description

**What happened?**

The task composer truncated the assignee name at 6rem and the complete
assignee and model capsule at 16rem. It did this even when the composer
had more available space.

**Expected behavior**

The composer must show the complete assignee and model labels when they
fit. It must use an ellipsis only when another control or a narrow
viewport limits the available width.

**Steps to reproduce**

1. Open a task composer with a long assignee name and a long model name.
2. Use a wide desktop layout.
3. Observe that the old capsule shortened both labels while unused space
remained.

**Paperclip version or commit**

Current `master` before this change.

**Deployment mode**

Local dev (`pnpm dev`).

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific. This is a core UI bug.

**Database mode**

Not database-related.

**Access context**

Board.

**Additional context**

The Storybook cases cover a wide composer and a narrow composer with
Plan mode.

## What Changed

- Removed the fixed maximum width from the assignee and model capsule.
- Removed the fixed maximum width from the assignee label.
- Kept overflow ellipsis behavior when the parent row has insufficient
space.
- Added stable label selectors and focused component coverage.
- Added Storybook cases for complete labels and Plan-mode truncation.

## Verification

- `pnpm --filter @paperclipai/plugin-sdk build`
- `pnpm --filter @paperclipai/ui typecheck`
- `vitest run
ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx`
- `node scripts/check-token-gates.mjs`
- Storybook production build under Node.js 24.20.0
- Captured and inspected the two new Storybook cases.
- The complete repository typecheck and build reach the Rust Runner
step. This local environment does not have `cargo`. Hosted CI supplies
the Rust toolchain.
- The repository test suite reaches workspace-runtime tests. This local
runner does not allow their required temporary home directories. Hosted
CI supplies a writable test home.

## Risks

- Low risk. The change only removes fixed width limits from one flex
item.
- A very narrow composer can still shorten both labels. This is the
intended fallback.
- The Storybook constrained case verifies that Plan mode and Send remain
usable.

> 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-5 through the Paperclip Codex runner. The run used
reasoning, tool use, code execution, browser automation, and image
inspection.

## 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)
- [ ] 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
- [x] 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-03 12:39:18 -05:00
1 parent eb049aebf2
commit 1c3abf5075
4 files changed
+45 -3

No files matched your search

@@ -55,6 +55,21 @@ afterEach(() => {
});
describe("composer assignee picker", () => {
it("lets the assignee and model use the available composer width", () => {
render(vi.fn(), vi.fn());
const trigger = container!.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-assignee"]');
const assignee = trigger!.querySelector('[data-testid="task-chat-composer-assignee-label"]');
const model = trigger!.querySelector('[data-testid="task-chat-composer-model-label"]');
expect(trigger?.className).toContain("max-w-full");
expect(trigger?.className).not.toContain("max-w-64");
expect(assignee?.className).toContain("min-w-0");
expect(assignee?.className).not.toContain("max-w-24");
expect(model?.className).toContain("min-w-0");
expect(assignee?.className).toContain("truncate");
expect(model?.className).toContain("truncate");
});
it("finds assignees by their displayed role and harness", async () => {
render(vi.fn(), vi.fn());
await click("Select assignee, model and effort");
@@ -167,10 +167,10 @@ export function ComposerRunSettingsPicker({
const reset = () => onSettingsChange(DEFAULT_COMPOSER_RUN_SETTINGS);
const closeButton = mobile ? <DialogClose asChild><button type="button" aria-label="Close picker" className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent"><X className="size-4" /></button></DialogClose> : null;
const trigger = <button ref={triggerRef} type="button" disabled={disabled} aria-label="Select assignee, model and effort" data-testid="task-chat-composer-assignee"
className="flex h-8 min-w-0 max-w-64 items-center gap-1.5 rounded-full bg-muted px-2.5 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
className="flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-full bg-muted px-2.5 text-xs font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
{renderAssigneeIdentity?.(assigneeValue, agent?.name ?? "Unassigned", "trigger")}
<span className="max-w-24 truncate">{assigneeOptions.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}</span>
<span className="text-muted-foreground" aria-hidden>·</span><span className="min-w-0 truncate text-muted-foreground">{modelSupported ? modelName || "Harness default" : "Harness default"}</span>
<span data-testid="task-chat-composer-assignee-label" className="min-w-0 truncate">{assigneeOptions.find((item) => item.id === assigneeValue)?.label ?? "Unassigned"}</span>
<span className="text-muted-foreground" aria-hidden>·</span><span data-testid="task-chat-composer-model-label" className="min-w-0 truncate text-muted-foreground">{modelSupported ? modelName || "Harness default" : "Harness default"}</span>
{effort ? <span className="hidden shrink-0 text-muted-foreground sm:inline">{effortLabel}</span> : null}
<ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
</button>;
@@ -86,6 +86,9 @@ export const composerAgents: ComposerAgent[] = [
{ id: "kimi-code/kimi-for-coding", label: "K2.8 Preview" },
{ id: "kimi-code/k3", label: "K3" },
], manualPattern: "kimi-code/model" },
{ id: "long-labels", name: "Alexandra Engineering Coordinator", role: "Engineering", harness: "Codex", adapterType: "codex_local", defaultModel: "gpt-5.6-sol", models: [
{ id: "gpt-5.6-sol", label: "GPT-5.6 Sol Extended Context Preview", detail: "Agent default" },
], manualPattern: "Model ID, e.g. gpt-5.6-sol" },
];
/** Share the capsule-avatar palettes used by the agent persona stories. */
@@ -414,3 +414,27 @@ export const ProductionIntermediateWidthPlan: Story = {
await expect(screen.getByTestId("composer-model-popover")).toBeVisible();
},
};
export const ProductionLongLabelsWide: Story = {
name: "25 · Long labels with available space",
render: () => <ComposerRunSettingsLiveStory agentId="long-labels" />,
play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
const assignee = screen.getByTestId("task-chat-composer-assignee-label");
const model = screen.getByTestId("task-chat-composer-model-label");
await expect(assignee.scrollWidth).toBeLessThanOrEqual(assignee.clientWidth);
await expect(model.scrollWidth).toBeLessThanOrEqual(model.clientWidth);
},
};
export const ProductionLongLabelsConstrained: Story = {
name: "25b · Long labels constrained by Plan mode",
render: () => <ComposerRunSettingsLiveStory agentId="long-labels" compact initialMode="planning" />,
play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
const assignee = screen.getByTestId("task-chat-composer-assignee-label");
const model = screen.getByTestId("task-chat-composer-model-label");
await expect(assignee.scrollWidth > assignee.clientWidth || model.scrollWidth > model.clientWidth).toBe(true);
await expect(screen.getByRole("button", { name: "Remove Plan mode" })).toBeVisible();
},
};