Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 82687ec95b Keep plugin composer items in the toolbar when they fit (#9209)
* fix(app): keep plugin composer items in the toolbar when they fit

The density budget guessed a plugin item's width from a fixed label and
counted every registered item, so an icon toggle or an item rendering
nothing pushed the toolbar into its overflow state with room to spare.
An item is an arbitrary component, so its width is only known by
rendering it. Measure the toolbar form in an always-mounted slot; the
width never depends on the density it decides, so moving items to the
overflow cannot flip the answer.

Sheet rows truncated short values such as "Full access" because the
value's 45% cap resolved against a content-width row. Fill the row so
the cap resolves against the sheet's width.

* fix(app): close a plugin picker when its trigger moves to the overflow

The measuring slot keeps toolbar plugin items mounted, so a picker that
was open when the toolbar condensed lost its trigger but kept its popup,
which portals to the overlay root. Mark the slot's items inactive while
they are out of the row, the contract retained panels already use to
close their popups.

* test(app): allow the Updating messages toast when the daemon answers slowly

A freshness check unanswered after 500ms reports Updating messages by
design, but the resume spec asserted no toast regardless of how long
the daemon took. On a loaded runner a cold reload's answer took 512ms,
so the toast was correct and the spec failed. Assert no toast only when
the answer came back promptly, using answer times the gate records.

* test(app): check the Updating messages toast the same way on every run

The previous repair skipped the no-toast assertion whenever the daemon
took 300ms or more to answer, so a slow run proved nothing. The toast is
correct once a check has gone unanswered for the quiet window, so the
spec now always asserts that the toast did not appear sooner than that
after the question was sent, timed in the page. The delayed-answer path
gets its own test that holds the daemon's answer.
2026-11-10 09:08:11 +07:00
Mohamed Boudra 9442b5a1b4 chore(release): reconcile 0.15.4 into main (#9240) 2026-11-10 08:50:23 +07:00
Mohamed Boudra 6cfe903127 Lay composer toolbar controls out at their full highlight on touch (#9238)
Touch-sized, each toolbar control now lays out at its whole highlight with no gap, so neighbouring highlights touch instead of hanging into gaps through negative margins. The row's overhang puts the attach glyph and a leading label on the composer's rail, and the trailing disc keeps a small clearance from its neighbour.
2026-11-10 08:33:58 +07:00
f9779a14a5 feat(claude): add Fable 5.2 and Mythos 5.2 to the model catalog (#9233)
Adds Claude Fable 5.2 and Mythos 5.2, available from Claude Code 2.1.321, and defaults both to medium effort to match Claude Code.

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: gudcks <84506708+gudcks@users.noreply.github.com>
2026-11-10 01:09:47 +00:00
47 changed files with 998 additions and 286 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 0.15.4 - 2026-11-10
### Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort ([#9233](https://github.com/getpaseo/paseo/pull/9233) by [@gengjiawen](https://github.com/gengjiawen), [@gudcks](https://github.com/gudcks))
### Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
## 0.15.3 - 2026-11-06
### Fixed
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 0.15.4 - 2026-11-10
### 新增
- 新增 Claude Fable 5.2 和 Mythos 5.2,默认推理强度为 medium ([#9233](https://github.com/getpaseo/paseo/pull/9233),贡献者:[@gengjiawen](https://github.com/gengjiawen)、[@gudcks](https://github.com/gudcks))
### 说明
- 两个模型均需 Claude Code 2.1.321 或更高版本;仅在 Claude Code 提供访问权限时显示 Mythos 5.2
## 0.15.3 - 2026-11-06
### 修复
+3 -3
View File
@@ -70,9 +70,9 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at its full highlight (`toolbarSlot`, `toolbarContentSlot`) with no gap between controls (`TOOLBAR_CONTROL_GAP`), so neighbouring highlights touch and never overlap. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`). Touch-sized the disc keeps a 6pt clearance from its neighbour, because it is always painted and a pressed neighbour would merge with it. The attach button sits 2pt closer to the picker than its highlight would put it: the plus is all thin arms, so the full gap reads wide. That offset is set by eye.
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
@@ -144,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and touch-sized the end highlights overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Claude Fable 5.2 and Mythos 5.2 with medium default effort
Notes
- Both models require Claude Code 2.1.321 or newer; Mythos 5.2 appears only when Claude Code advertises access
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
sha256-21qteVXVoL7S/wF0aGxHNEkTX8in6UF6+wWEZi8N3HA=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.3",
"version": "0.15.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.4",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/server": "0.15.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/highlight": "0.15.4",
"@getpaseo/plugin": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.4",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -11,6 +11,7 @@ import {
seedLongMockAgentTimeline,
} from "../support/helpers/timeline-pagination";
import {
answerFreshnessCheck,
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectResumeAnsweredWithoutFetch,
@@ -21,6 +22,7 @@ import {
restoreUnchangedViewedTimeline,
restoreViewedTimelineHoldingCatchUp,
restoreViewedTimelineWithHeldResponse,
returnWithFreshnessCheckUnanswered,
waitForResumeAnswer,
reopenAppWatchingForUpdatingToast,
watchForUpdatingToast,
@@ -101,6 +103,25 @@ test.describe("Agent timeline resume", () => {
}
});
test("a check the daemon has not answered reports the update until it does", async ({ page }) => {
test.setTimeout(120_000);
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 3 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
const requests = rememberTimelineRequestCounts(gate);
await returnWithFreshnessCheckUnanswered(page, gate);
await answerFreshnessCheck(page, gate);
expectResumeAnsweredWithoutFetch(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
}
});
test("returning to a changed chat reports the update and shows the new messages", async ({
page,
}) => {
@@ -1,5 +1,11 @@
import { test } from "../support/fixtures";
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
import {
expectToolbarNeverCondensedOrClipped,
recordToolbarFramesFromLoad,
withComposerControlsExample,
withPickerComposerItem,
withToggleAndWatcherComposerItems,
} from "../support/helpers/plugin-composer-items";
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
test.setTimeout(120_000);
@@ -10,3 +16,66 @@ test("a narrow composer's overflow sheet shows a plugin item's row", async ({ pa
await composer.expectFeatureRow("Insert checklist");
});
});
test("a toolbar with room keeps a plugin toggle inline at full density", async ({ page }) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
// Wider than what renders, too narrow for two 125-wide "Plugin item" estimates.
await composer.setWindowWidth(1000);
await composer.expectToolbarWidthBetween(470, 620);
await composer.expectToggleInlineAtFullDensity();
});
});
test("a toolbar without room moves a plugin toggle to the overflow sheet and back", async ({
page,
}) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.expectToggleInlineAtFullDensity();
// Too narrow for full labels, wide enough that the controls have not folded away.
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectToggleInOverflow();
await composer.closeOverflowSheet();
await composer.setWindowWidth(1000);
await composer.expectToggleInlineAtFullDensity();
});
});
test("an open plugin picker closes when its trigger moves to the overflow", async ({ page }) => {
test.setTimeout(120_000);
await withPickerComposerItem(page, async (composer) => {
await composer.setWindowWidth(1000);
await composer.openInlinePicker();
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.expectPickerClosed();
});
});
test("the toolbar settles on one density without flipping as the window widens", async ({
page,
}) => {
test.setTimeout(120_000);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.setWindowWidth(880);
await composer.expectToolbarWidthBetween(300, 395);
await composer.startRecordingDensity();
for (let width = 880; width <= 1000; width += 8) await composer.setWindowWidth(width);
await composer.expectAtMostOneDensityChange();
});
});
test("a toolbar with room never paints condensed or clipped while the plugin loads", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1000, height: 800 });
await recordToolbarFramesFromLoad(page);
await withToggleAndWatcherComposerItems(page, async (composer) => {
await composer.expectToggleInlineAtFullDensity();
await expectToolbarNeverCondensedOrClipped(page);
});
});
@@ -1,8 +1,11 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
@@ -57,3 +60,236 @@ export async function withComposerControlsExample(
await workspace.cleanup();
}
}
const ONE_TOGGLE_PLUGIN_ID = "composer-toggle-and-watcher-e2e";
const ONE_TOGGLE_NAME = "Sandbox flag";
const ONE_TOGGLE_PLUGIN_SOURCE = `import { useComposer } from "@getpaseo/plugin/client";
import { Toggle } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
function PromptWatcher() {
useComposer();
return null;
}
function SandboxFlag() {
const [value, setValue] = useState(false);
return (
<Toggle
title="${ONE_TOGGLE_NAME}"
icon="Lock"
value={value}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "prompt-watcher", Component: PromptWatcher });
client.addComposerItem({ id: "sandbox-flag", Component: SandboxFlag });
return () => {};
}`;
const PICKER_NAME = "Review state";
const PICKER_PLUGIN_SOURCE = `import { Select } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
const OPTIONS = [
{ value: "draft", label: "Draft" },
{ value: "ready", label: "Ready" },
];
function ReviewState() {
const [value, setValue] = useState("draft");
return (
<Select
title="${PICKER_NAME}"
icon="CircleDot"
value={value}
options={OPTIONS}
onValueChange={({ value: next }) => setValue(next)}
/>
);
}
export default function contribute(client) {
client.addComposerItem({ id: "review-state", Component: ReviewState });
return () => {};
}`;
function toolbar(page: Page) {
return page.getByTestId("agent-controls-toolbar").filter({ visible: true }).first();
}
interface ToolbarFrame {
overflowButton: boolean;
clipped: boolean;
}
declare global {
interface Window {
__pluginToolbarFrames?: ToolbarFrame[];
}
}
/** From the first frame on, records whether the toolbar condensed or clipped. Call before loading. */
export async function recordToolbarFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(() => {
const frames: ToolbarFrame[] = [];
window.__pluginToolbarFrames = frames;
const tick = () => {
const row = Array.from(
document.querySelectorAll('[data-testid="agent-controls-toolbar"]'),
).find((node) => node.getClientRects().length > 0);
if (row) {
frames.push({
overflowButton: row.querySelector('[data-testid="agent-controls-features"]') !== null,
clipped: row.scrollWidth > row.clientWidth,
});
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
export async function expectToolbarNeverCondensedOrClipped(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__pluginToolbarFrames ?? []);
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.overflowButton || frame.clipped)).toEqual([]);
}
function agentFeaturesButton(page: Page) {
return page.getByRole("button", { name: "Open agent features", exact: true });
}
interface ToolbarPluginComposer {
setWindowWidth(width: number): Promise<void>;
expectToolbarWidthBetween(min: number, max: number): Promise<void>;
expectToggleInlineAtFullDensity(): Promise<void>;
expectToggleInOverflow(): Promise<void>;
closeOverflowSheet(): Promise<void>;
startRecordingDensity(): Promise<void>;
expectAtMostOneDensityChange(): Promise<void>;
openInlinePicker(): Promise<void>;
expectPickerClosed(): Promise<void>;
}
/** Runs `run` in an agent's composer with one icon toggle and one item that renders nothing. */
export function withToggleAndWatcherComposerItems(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, ONE_TOGGLE_PLUGIN_SOURCE, run);
}
/** Runs `run` in an agent's composer with one plugin `Select` in the toolbar. */
export function withPickerComposerItem(
page: Page,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
return withToolbarPlugin(page, PICKER_PLUGIN_SOURCE, run);
}
async function withToolbarPlugin(
page: Page,
pluginSource: string,
run: (composer: ToolbarPluginComposer) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "composer-measured-items-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Measured items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-measured-items-"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: ONE_TOGGLE_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(toolbar(page)).toBeVisible({ timeout: 30_000 });
await run({
setWindowWidth: (width) =>
test.step(`set the window to ${width} wide`, () =>
page.setViewportSize({ width, height: 800 })),
expectToolbarWidthBetween: (min, max) =>
test.step(`the toolbar is between ${min} and ${max} wide`, async () => {
await expect
.poll(async () => (await toolbar(page).boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(min);
expect((await toolbar(page).boundingBox())?.width ?? 0).toBeLessThanOrEqual(max);
}),
expectToggleInlineAtFullDensity: () =>
test.step("the toggle is inline and the toolbar keeps its labels", async () => {
const toggle = toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true });
await expect(toggle).toBeVisible();
await expect(agentFeaturesButton(page)).toHaveCount(0);
await expect(toolbar(page).getByTestId("agent-thinking-selector")).toHaveText("Low");
const row = await toolbar(page).boundingBox();
const box = await toggle.boundingBox();
expect(box && row && box.x + box.width <= row.x + row.width).toBe(true);
}),
expectToggleInOverflow: () =>
test.step("the toggle has moved to the overflow sheet", async () => {
await expect(
toolbar(page).getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
).toHaveCount(0);
await agentFeaturesButton(page).click();
await expect(
page.getByRole("button", { name: ONE_TOGGLE_NAME, exact: true }),
).toBeVisible();
}),
closeOverflowSheet: () =>
test.step("close the overflow sheet", async () => {
await page.keyboard.press("Escape");
await expect(page.getByTestId("agent-features-sheet")).toHaveCount(0);
}),
startRecordingDensity: () =>
test.step("record which density each frame paints", () =>
page.evaluate(() => {
const frames: boolean[] = [];
const paint = () => {
frames.push(
document.querySelector('[data-testid="agent-controls-features"]') !== null,
);
window.requestAnimationFrame(paint);
};
window.requestAnimationFrame(paint);
Object.assign(window, { __densityFrames: frames });
})),
expectAtMostOneDensityChange: () =>
test.step("the density changed once at most", async () => {
const changes = await page.evaluate(() => {
const frames = (window as unknown as { __densityFrames: boolean[] }).__densityFrames;
return frames.filter((frame, index) => index > 0 && frame !== frames[index - 1]).length;
});
expect(changes).toBeLessThanOrEqual(1);
}),
openInlinePicker: () =>
test.step("open the plugin picker from the toolbar", async () => {
await toolbar(page).getByRole("button", { name: PICKER_NAME, exact: true }).click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible();
}),
expectPickerClosed: () =>
test.step("the plugin picker has closed", () =>
expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0)),
});
} finally {
await client.removePlugin(ONE_TOGGLE_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
await workspace.cleanup();
}
}
@@ -13,6 +13,12 @@ const SUBSCRIPTION_REQUEST = "agent.timeline.set_subscription.request";
const SUBSCRIPTION_RESPONSE = "agent.timeline.set_subscription.response";
const TIMELINE_REQUEST = "fetch_agent_timeline_request";
const TIMELINE_RESPONSE = "fetch_agent_timeline_response";
/**
* `FRESHNESS_CHECK_QUIET_MS` is 500: a check unanswered that long reports Updating messages by
* design, however long the daemon takes. The app starts that wait just before it sends the question
* this helper times, so the bound stops short of 500.
*/
const QUIET_WINDOW_FLOOR_MS = 300;
export interface TimelineRequestCounts {
agentId?: string;
@@ -77,48 +83,88 @@ export async function waitForResumeAnswer(
]);
}
interface UpdatingToastTiming {
askedAt: number | null;
shownAt: number | null;
}
declare global {
interface Window {
__updatingToastTiming?: UpdatingToastTiming;
}
}
/** Runs in the page: times the freshness question and the toast's first commit on its own clock. */
function recordUpdatingToastTiming(): void {
const noteToast = (timing: UpdatingToastTiming) => {
if (document.querySelector('[data-testid="agent-updating-toast"]') === null) return;
timing.shownAt ??= performance.now();
};
const existing = window.__updatingToastTiming;
if (existing) {
existing.askedAt = null;
existing.shownAt = null;
noteToast(existing);
return;
}
const timing: UpdatingToastTiming = { askedAt: null, shownAt: null };
window.__updatingToastTiming = timing;
const send = WebSocket.prototype.send;
WebSocket.prototype.send = function sendRecordingQuestion(data) {
if (
typeof data === "string" &&
data.includes("agent.timeline.set_subscription.request") &&
data.includes('"agentIds":["')
) {
timing.askedAt ??= performance.now();
}
return send.call(this, data);
};
new MutationObserver(() => noteToast(timing)).observe(document, {
childList: true,
subtree: true,
});
noteToast(timing);
}
/** A toast may only follow an unanswered question; nothing about the daemon's speed changes that. */
async function expectToastWaitedForTheDaemon(page: Page): Promise<void> {
await waitForNextPaint(page);
const showedTooSoon = await page.evaluate((floorMs) => {
const { askedAt, shownAt } = window.__updatingToastTiming as UpdatingToastTiming;
if (shownAt === null) return false;
return askedAt === null || shownAt - askedAt < floorMs;
}, QUIET_WINDOW_FLOOR_MS);
expect(showedTooSoon, "the Updating messages toast did not wait for the daemon").toBe(false);
}
/** Records every appearance of the toast, including one that lasts a single commit. */
export async function watchForUpdatingToast(page: Page): Promise<() => Promise<void>> {
const watch = await page.evaluateHandle(() => {
let shown = false;
const check = () => {
shown ||= document.querySelector('[data-testid="agent-updating-toast"]') !== null;
};
const observer = new MutationObserver(check);
observer.observe(document.body, { childList: true, subtree: true });
check();
return {
stop() {
observer.disconnect();
return shown;
},
};
});
return async () => {
await waitForNextPaint(page);
const shown = await watch.evaluate((recorder) => recorder.stop());
await watch.dispose();
expect(shown, "the Updating messages toast appeared").toBe(false);
};
await page.evaluate(recordUpdatingToastTiming);
return () => expectToastWaitedForTheDaemon(page);
}
/** Reopens the app on this chat, recording the toast from the first paint of the new page. */
export async function reopenAppWatchingForUpdatingToast(page: Page): Promise<() => Promise<void>> {
await page.addInitScript(() => {
const recorder = window as Window & { updatingToastShown?: boolean };
new MutationObserver(() => {
recorder.updatingToastShown ||=
document.querySelector('[data-testid="agent-updating-toast"]') !== null;
}).observe(document, { childList: true, subtree: true });
});
await page.addInitScript(recordUpdatingToastTiming);
await page.reload();
return async () => {
await waitForNextPaint(page);
const shown = await page.evaluate(
() => (window as Window & { updatingToastShown?: boolean }).updatingToastShown === true,
);
expect(shown, "the Updating messages toast appeared").toBe(false);
};
return () => expectToastWaitedForTheDaemon(page);
}
/** Returns to the chat with the daemon's answer held, past the point the toast reports it. */
export async function returnWithFreshnessCheckUnanswered(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
gate.holdNextClientRequest(SUBSCRIPTION_REQUEST);
await hideTabAndReturn(page);
await gate.waitForHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toBeVisible();
}
export async function answerFreshnessCheck(page: Page, gate: DaemonWebSocketGate): Promise<void> {
gate.releaseHeldClientRequest();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
async function waitForNextPaint(page: Page): Promise<void> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"main": "index.ts",
"scripts": {
@@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
...toolbarContentSlot(),
borderRadius: theme.borderRadius.full,
},
triggerHovered: {
@@ -338,7 +338,6 @@ const styles = StyleSheet.create((theme) => ({
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
marginHorizontal: 0,
height: "auto",
},
triggerFill: {
@@ -119,15 +119,10 @@ describe("control sizing gate", () => {
});
describe("toolbar glyphs and spacing", () => {
it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => {
expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 });
expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 });
// A 20pt glyph's disc-sized frame reaches 8 into the gap on each side.
expect(toolbarSlot(20)).toEqual({
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: { xs: -8, md: 0 },
});
it("lays touch toolbar controls out at their highlight with no gap and keeps the pointer's 4px gap", () => {
expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 0, pointer: 4 });
expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 0, md: 4 });
expect(toolbarSlot()).toEqual({ width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE });
});
it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => {
@@ -101,47 +101,23 @@ export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingVal
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
/**
* The space between adjacent toolbar controls. Touch-sized it is the visible space between what
* the controls paint, because each control lays out at its painted width (`toolbarSlot`).
* The space between adjacent toolbar controls. Touch-sized there is none: each control lays out at
* its fully rounded highlight, so neighbouring highlights touch and never overlap.
*/
export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue;
export const TOOLBAR_CONTROL_GAPS = { touch: 0, pointer: 4 } satisfies SizingValue;
export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS);
/**
* A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight.
* Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches
* into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square
* control.
*/
export function toolbarSlot(shapeWidth: number): {
width: LayoutValue;
height: LayoutValue;
marginHorizontal: LayoutValue;
} {
return {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: bySizing({
touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2,
pointer: 0,
}),
};
/** A labelled toolbar control's padding on each side of its content. */
export const TOOLBAR_LABEL_PADDING = 8;
/** An icon-only toolbar control's frame: a disc-sized circle, which is also its highlight. */
export function toolbarSlot(): { width: LayoutValue; height: LayoutValue } {
return { width: TOOLBAR_DISC_SIZE, height: TOOLBAR_DISC_SIZE };
}
/**
* A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized
* its padding reaches into the gaps, so its content is what the row spaces.
*/
export function toolbarContentSlot(padding: number): {
height: LayoutValue;
paddingHorizontal: number;
marginHorizontal: LayoutValue;
} {
return {
height: TOOLBAR_DISC_SIZE,
paddingHorizontal: padding,
marginHorizontal: bySizing({ touch: -padding, pointer: 0 }),
};
/** A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. */
export function toolbarContentSlot(): { height: LayoutValue; paddingHorizontal: number } {
return { height: TOOLBAR_DISC_SIZE, paddingHorizontal: TOOLBAR_LABEL_PADDING };
}
/**
@@ -13,6 +13,8 @@ beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
// The sheet lays its rows out in a column, which is what stretches a row across it.
const SHEET_COLUMN = { display: "flex", flexDirection: "column", width: 382 } as const;
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
@@ -92,6 +94,29 @@ describe("ComposerControls", () => {
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
});
it("gives a sheet row's value the row's width, so a short value is not truncated", () => {
const toast = createToast();
render(
<div style={SHEET_COLUMN}>
<Controls
items={[
{
...base,
type: "select",
value: "full",
choices: [{ value: "full", label: "Full access" }],
onChange: () => undefined,
},
]}
surface="sheet"
toast={toast}
/>
</div>,
);
const value = screen.getByText("Full access");
expect(value.scrollWidth).toBeLessThanOrEqual(value.clientWidth);
});
it("reports a failed command the way a built-in control does, and frees the control", async () => {
let reject: (error: Error) => void = () => undefined;
const onPress = vi.fn(
@@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry";
import { toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
@@ -73,6 +73,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
accessibilityLabel={accessibilityLabel}
testID={testID}
chevron={showCaret ? undefined : null}
block={isSheet}
>
{isSheet ? (
<View style={styles.sheetGlyph}>
@@ -105,12 +106,12 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
...toolbarContentSlot(),
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
},
toolbarIconOnly: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
...toolbarSlot(),
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -50,10 +50,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
bySizing,
toolbarGlyphColor,
type ToolbarGlyphs,
TOUCH_ICON_SIZE,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { readMeasuredWidth } from "@/hooks/use-container-width";
@@ -87,6 +85,10 @@ import { composerModelFromHalves } from "@/composer/halves-model";
import { ComposerModelProvider } from "@/composer/model";
import { ComposerAgentProvider, useComposerInput } from "@/composer/session";
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
import {
PluginToolbarSlot,
usePluginToolbarWidth,
} from "@/composer/agent-controls/plugin-toolbar-slot";
import {
useAgentProfileEditor,
useAgentProfilePicker,
@@ -466,6 +468,7 @@ function ControlledAgentControls({
onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0,
);
const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId);
const { width: pluginItemsWidth, onLayout: handlePluginItemsLayout } = usePluginToolbarWidth();
const formattedThinkingOptions = useMemo(
() => toThinkingControlOptions(thinkingOptions),
@@ -488,6 +491,7 @@ function ControlledAgentControls({
() =>
(optionControls?.controls ?? []).map((control) => {
if (control.type === "toggle") return { type: "toggle" as const };
if (control.desktopTrigger === "icon") return { type: "select" as const, label: null };
const selected = readSelectOption(control, optionControls?.options ?? {});
const choice = control.choices.find((candidate) => candidate.value === selected);
return {
@@ -503,10 +507,10 @@ function ControlledAgentControls({
hasThinking: canSelectThinking,
hasMode: modeControl !== null && modeControl !== undefined,
features: featureControls,
pluginItems: pluginItemCount,
pluginItemsWidth,
fontScale,
}),
[canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemCount],
[canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemsWidth],
);
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
@@ -635,7 +639,7 @@ function ControlledAgentControls({
return (
<ComposerControlLayoutProvider value={layoutContextValue}>
<View style={styles.container} onLayout={handleLayout}>
<View style={styles.container} onLayout={handleLayout} testID="agent-controls-toolbar">
{!presentation.foldControls ? (
<DesktopAgentControlsContent
provider={provider}
@@ -661,7 +665,7 @@ function ControlledAgentControls({
handleSheetOpenChange={setOpenSelector}
modeControl={modeControl}
presentation={presentation}
pluginItemCount={pluginItemCount}
hasPluginItems={pluginItemsWidth > 0}
glyphs={layoutContextValue.glyphs}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
@@ -698,6 +702,14 @@ function ControlledAgentControls({
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
)}
{!isCompact ? (
<PluginToolbarSlot
serverId={modelSelectorServerId}
width={pluginItemsWidth}
fits={!presentation.aggregateFeatures}
onLayout={handlePluginItemsLayout}
/>
) : null}
</View>
</ComposerControlLayoutProvider>
);
@@ -732,7 +744,7 @@ interface DesktopAgentControlsContentProps extends AgentControlsContentProps {
handleDesktopModelSelect: (providerId: string, modelId: string) => void;
handleToolbarOpenChange: (id: string | null) => void;
presentation: ComposerControlPresentation;
pluginItemCount: number;
hasPluginItems: boolean;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
@@ -766,7 +778,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
handleSheetOpenChange,
modeControl,
presentation,
pluginItemCount,
hasPluginItems,
glyphs,
activeSheet,
handleOpenSheet,
@@ -782,7 +794,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
[t],
);
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
const hasFeatures = featureItems.length > 0 || pluginItemCount > 0;
const hasFeatures = featureItems.length > 0 || hasPluginItems;
return (
<>
{canSelectModel ? (
@@ -864,17 +876,14 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
</AdaptiveModalSheet>
</>
) : (
<>
<ComposerControls
items={featureItems}
surface="toolbar"
disabled={disabled}
openId={openSelector}
onOpenIdChange={handleToolbarOpenChange}
onActionComplete={onDropdownClose}
/>
<PluginComposerItems serverId={modelSelectorServerId} surface="toolbar" />
</>
<ComposerControls
items={featureItems}
surface="toolbar"
disabled={disabled}
openId={openSelector}
onOpenIdChange={handleToolbarOpenChange}
onActionComplete={onDropdownClose}
/>
)}
</>
);
@@ -1452,18 +1461,15 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
// Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past
// what the row spaces, so the clip reaches as far.
// Clips pickers that run out of room.
overflow: "hidden",
paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }),
marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }),
},
modelControl: {
minWidth: 0,
flexShrink: 1,
},
modeIconBadge: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
...toolbarSlot(),
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -47,7 +47,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
};
@@ -116,7 +116,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
},
}),
@@ -129,7 +129,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
},
}),
@@ -142,7 +142,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1.25,
},
}),
@@ -158,7 +158,7 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItems: 0,
pluginItemsWidth: 0,
fontScale: 1,
},
};
@@ -180,34 +180,61 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("condenses to make room for plugin items and keeps them in the overflow", () => {
describe("plugin items", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
geometry: POINTER,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
},
};
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
pluginItemsWidth: 0,
};
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
).toBe("full");
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
).toBe("condensed");
expect(
resolveComposerControlDensity({
...base,
availableWidth: 250,
controls: { ...base.controls, pluginItems: 2 },
}),
).toBe("tight");
it("budgets the width the items measured, so an icon toggle fits where a label would not", () => {
expect(
resolveComposerControlDensity({
...base,
controls: { ...controls, pluginItemsWidth: 28 },
}),
).toBe("full");
expect(
resolveComposerControlDensity({
...base,
controls: { ...controls, pluginItemsWidth: 125 },
}),
).toBe("condensed");
});
it("reserves no overflow button for items that render nothing", () => {
// 264 for the condensed row; an overflow button would take it to 296.
const tight = { ...base, availableWidth: 290, currentDensity: "tight" as const };
expect(resolveComposerControlDensity({ ...tight, controls })).toBe("condensed");
expect(
resolveComposerControlDensity({
...tight,
controls: { ...controls, pluginItemsWidth: 28 },
}),
).toBe("tight");
});
it("holds either density at the full floor while the items' width stays the same", () => {
// 412 is the full floor with a 28-wide item, and 12 the hysteresis.
const atFloor = { ...base, availableWidth: 412 - 12 };
const withItem = { ...controls, pluginItemsWidth: 28 };
expect(resolveComposerControlDensity({ ...atFloor, controls: withItem })).toBe("full");
expect(
resolveComposerControlDensity({
...atFloor,
currentDensity: "condensed",
controls: withItem,
}),
).toBe("condensed");
});
});
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
@@ -221,15 +248,38 @@ describe("composer control layout", () => {
caretSize: 14,
});
expect(TOUCH).toMatchObject({
controlSize: 20,
controlSize: 36,
glyphs: {
icon: { size: 20, strokeWidth: 1.5, tone: "foreground" },
label: { size: 20, strokeWidth: 1.5, tone: "foreground" },
},
controlGap: 20,
controlGap: 0,
labelPadding: 8,
});
});
it("budgets an icon-only select as the icon it draws", () => {
// Codex's service tier: a select whose toolbar trigger is an icon. 476 with its icon, 543 with
// the label "Priority", against a 502-wide toolbar.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "select" as const, label: null }, { type: "toggle" as const }],
fontScale: 1,
pluginItemsWidth: 28,
};
expect(
resolveComposerControlDensity({
availableWidth: 502,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("full");
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 400,
@@ -240,7 +290,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
},
};
@@ -249,15 +299,15 @@ describe("composer control layout", () => {
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they
// paint, 20pt apart, so icon-only thinking, mode and features need 200.
// Codex: model, thinking, mode and two toggle features. Touch controls lay out at their
// 36pt highlight with no gap, so icon-only thinking, mode and features need 108.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
};
it("keeps the model family label with icon-only controls on an empty draft", () => {
@@ -316,7 +366,7 @@ describe("composer control layout", () => {
hasMode: true,
features: [],
fontScale: 1,
pluginItems: 0,
pluginItemsWidth: 0,
},
};
@@ -1,6 +1,7 @@
import {
TOOLBAR_CONTROL_GAPS,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZES,
TOOLBAR_LABEL_PADDING,
toolbarGlyphsFor,
type ControlSizing,
type ToolbarGlyphs,
@@ -26,12 +27,15 @@ export interface ComposerControlPresence {
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
/** Plugin composer items. Each is budgeted like a select with a short label. */
pluginItems: number;
/** The plugin items' measured width in the toolbar row; 0 when none of them renders anything. */
pluginItemsWidth: number;
fontScale: number;
}
export type ComposerFeatureControlPresence = { type: "toggle" } | { type: "select"; label: string };
export type ComposerFeatureControlPresence =
| { type: "toggle" }
/** `label` is what the toolbar trigger draws; null for an icon-only trigger. */
| { type: "select"; label: string | null };
export interface ComposerControlPresentation {
showCarets: boolean;
@@ -50,31 +54,24 @@ export interface ComposerToolbarGeometry {
glyphs: ToolbarGlyphs;
controlGap: number;
iconLabelGap: number;
/** A labelled control's padding on each side, as far as it takes room in the row. */
/** A labelled control's padding on each side. */
labelPadding: number;
caretSize: number;
}
/**
* The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a
* control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an
* icon-only control takes its glyph's width and a picker's padding takes no room.
*/
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
const glyphs = toolbarGlyphsFor(sizing);
const isTouch = sizing === "touch";
return {
controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer,
glyphs,
controlSize: TOOLBAR_DISC_SIZES[sizing],
glyphs: toolbarGlyphsFor(sizing),
controlGap: TOOLBAR_CONTROL_GAPS[sizing],
iconLabelGap: 4,
labelPadding: isTouch ? 0 : 8,
labelPadding: TOOLBAR_LABEL_PADDING,
caretSize: 14,
};
}
const DENSITY_HYSTERESIS = 12;
const PLUGIN_ITEM_LABEL = "Plugin item";
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
@@ -103,7 +100,7 @@ function resolveFeatureControlWidth(
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle") return geometry.controlSize;
if (feature.type === "toggle" || feature.label === null) return geometry.controlSize;
return (
geometry.controlSize +
geometry.iconLabelGap +
@@ -122,7 +119,7 @@ function resolveCondensedFloor(
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
if (controls.features.length > 0 || controls.pluginItems > 0) {
if (controls.features.length > 0 || controls.pluginItemsWidth > 0) {
widths.push(geometry.controlSize);
}
return sumControlWidths(widths, geometry);
@@ -142,11 +139,7 @@ function resolveFullFloor(
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
}
for (let index = 0; index < controls.pluginItems; index++) {
widths.push(
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale, geometry),
);
}
if (controls.pluginItemsWidth > 0) widths.push(controls.pluginItemsWidth);
return sumControlWidths(widths, geometry);
}
@@ -162,7 +155,7 @@ function resolveTightFloor(
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0 || controls.pluginItems > 0) {
if (controls.features.length > 0 || controls.pluginItemsWidth > 0) {
widths.push(geometry.controlSize);
}
return sumControlWidths(widths, geometry);
@@ -27,7 +27,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import {
TOUCH_ICON_SIZE,
toolbarContentSlot,
toolbarSlot,
type ToolbarGlyphs,
@@ -535,12 +534,12 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
...toolbarContentSlot(theme.spacing[2]),
...toolbarContentSlot(),
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
},
triggerIconOnly: {
...toolbarSlot(TOUCH_ICON_SIZE.md),
...toolbarSlot(),
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -0,0 +1,82 @@
import { useCallback, useState } from "react";
import { type LayoutChangeEvent, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { TOOLBAR_CONTROL_GAP } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import { PluginComposerItems } from "@/plugins/composer-items";
/** A retained tab under `display: none` reports 0 by 0; see `styles.slot` for why 0 wide is real. */
function readSlotWidth(event: LayoutChangeEvent): number | null {
const { width, height } = event.nativeEvent.layout;
return height > 0 ? width : null;
}
/** What the plugin items take up in the toolbar row, in points, as `PluginToolbarSlot` reports it. */
export function usePluginToolbarWidth(): {
width: number;
onLayout: (event: LayoutChangeEvent) => void;
} {
const [width, setWidth] = useState(0);
const onLayout = useCallback((event: LayoutChangeEvent) => {
const next = readSlotWidth(event);
if (next !== null) setWidth(next);
}, []);
return { width, onLayout };
}
/**
* The plugin items in their toolbar form, measured whether or not the toolbar shows them. An item
* is an arbitrary component, so its width is known only from rendering it, and it is always the
* toolbar form that is measured: the width never depends on the density it decides, so moving the
* items to the overflow sheet cannot change the answer. Out of the row, the same node stays
* mounted and measured, unseen and out of reach, and inactive so an open picker closes with it.
*/
export function PluginToolbarSlot({
serverId,
width,
fits,
onLayout,
}: {
serverId: string | null;
width: number;
/** Whether the toolbar has room for the items at the width they measured. */
fits: boolean;
onLayout: (event: LayoutChangeEvent) => void;
}) {
// An empty slot stays out of the row, or the row's gap would reserve room for nothing.
const inline = fits && width > 0;
return (
<View
style={[styles.slot, !inline && styles.measuring]}
onLayout={onLayout}
testID="plugin-toolbar-items"
accessibilityElementsHidden={!inline}
importantForAccessibility={inline ? "auto" : "no-hide-descendants"}
aria-hidden={!inline}
>
<RetainedPanelActivity active={inline}>
<PluginComposerItems serverId={serverId} surface="toolbar" />
</RetainedPanelActivity>
</View>
);
}
const styles = StyleSheet.create({
slot: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: TOOLBAR_CONTROL_GAP,
// Keeps a slot whose items render nothing distinguishable from one that is not rendered.
minHeight: 1,
},
measuring: {
position: "absolute",
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
...(isWeb ? ({ visibility: "hidden" } as const) : null),
},
});
+3 -4
View File
@@ -42,7 +42,6 @@ import {
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { COMPOSER_TOOLBAR_SHAPES } from "@/composer/input/toolbar-rails";
import { usePanelStore } from "@/stores/panel-store";
import { usePointerCapabilities } from "@/hooks/pointer-capabilities";
import { useShallow } from "zustand/shallow";
@@ -2640,7 +2639,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[3],
},
cancelButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
...toolbarSlot(),
alignItems: "center",
justifyContent: "center",
},
@@ -2658,7 +2657,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: TOOLBAR_CONTROL_GAP,
},
contextWindowMeterSlot: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.ring),
...toolbarSlot(),
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -2672,7 +2671,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
},
realtimeVoiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc),
...toolbarSlot(),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
+12 -9
View File
@@ -40,12 +40,12 @@ import {
type ToolbarGlyph,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { bySizing } from "@/components/ui/control-sizing";
import {
COMPOSER_PADDING_BOTTOM,
COMPOSER_PADDING_HORIZONTAL,
COMPOSER_CORNER_RADIUS,
COMPOSER_PADDING_TOP,
COMPOSER_TOOLBAR_SHAPES,
composerToolbarOverhang,
resolveComposerLeadingEdge,
} from "@/composer/input/toolbar-rails";
@@ -1983,7 +1983,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const voiceButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.voiceButton,
isDictating && styles.voiceButtonRecording,
Boolean(hovered || pressed) && !isDictating && styles.iconButtonHovered,
Boolean(pressed) && isDictating && styles.discPressed,
!isDictationStartEnabled && styles.buttonDisabled,
@@ -2238,7 +2237,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{sendButton}
<View style={styles.trailingDisc}>{sendButton}</View>
</View>
</View>
),
@@ -2470,8 +2469,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
gap: TOOLBAR_CONTROL_GAP,
},
// Touch-sized the row's highlights touch, but the disc is always painted, so a pressed neighbour
// keeps clear of it instead of merging with it.
trailingDisc: {
marginLeft: bySizing({ touch: theme.spacing[1.5], pointer: 0 }),
},
// Touch-sized the plus is all thin arms, so beside the picker's glyph the same gap reads wider.
// Its highlight can overlap the picker's: only one control is ever pressed.
attachButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.attach),
...toolbarSlot(),
marginRight: bySizing({ touch: -2, pointer: 0 }),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -2483,14 +2490,11 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
},
voiceButton: {
...toolbarSlot(COMPOSER_TOOLBAR_SHAPES.dictation),
...toolbarSlot(),
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
voiceButtonRecording: {
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
},
voiceButtonRecordingDisc: {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
@@ -2501,7 +2505,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
sendButton: {
minWidth: TOOLBAR_DISC_SIZE,
marginHorizontal: toolbarSlot(COMPOSER_TOOLBAR_SHAPES.disc).marginHorizontal,
height: TOOLBAR_DISC_SIZE,
alignItems: "center",
justifyContent: "center",
@@ -2,16 +2,17 @@ import { describe, expect, it } from "vitest";
import { composerToolbarOverhang, resolveComposerLeadingEdge } from "./toolbar-rails";
describe("composer toolbar rails", () => {
it("keeps touch glyphs and labels on the text rails and nests a trailing disc into the corner", () => {
for (const edge of ["attach", "dictation", "voiceMode", "label", "none"] as const) {
expect(composerToolbarOverhang(edge, "touch")).toBe(0);
}
it("hangs each end highlight far enough to put its ink on the text rail and nests a trailing disc into the corner", () => {
// The 36pt highlight around the plus's ink (14/24 of a 20pt glyph, plus its 1.5 stroke).
expect(composerToolbarOverhang("attach", "touch")).toBeCloseTo((36 - (14 * 20) / 24 - 1.5) / 2);
expect(composerToolbarOverhang("label", "touch")).toBe(8);
expect(composerToolbarOverhang("none", "touch")).toBe(0);
// A 36pt disc centred in the 44pt row sits 8 from the bottom edge, so it sits 8 from the side.
expect(composerToolbarOverhang("disc", "touch")).toBe(8);
});
it("keeps the pointer row's flat overhang", () => {
for (const edge of ["attach", "dictation", "voiceMode", "disc", "label", "none"] as const) {
for (const edge of ["attach", "disc", "label", "none"] as const) {
expect(composerToolbarOverhang(edge, "pointer")).toBe(6);
}
});
@@ -1,14 +1,14 @@
import {
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_SIZES,
TOOLBAR_RINGS,
TOOLBAR_LABEL_PADDING,
toolbarGlyphsFor,
type ControlSizing,
} from "@/components/ui/control-geometry";
import { bySizing } from "@/components/ui/control-sizing";
/** The control at one end of the composer toolbar, which decides how far the row hangs out. */
export type ComposerToolbarEdge = "attach" | "label" | "dictation" | "voiceMode" | "disc" | "none";
export type ComposerToolbarEdge = "attach" | "label" | "disc" | "none";
/**
* The composer's padding. Its sides are the rails: the text column and the toolbar's outer
@@ -32,23 +32,12 @@ export const COMPOSER_PADDING_BOTTOM = bySizing({
pointer: COMPOSER_PADDING.pointer.bottom,
});
/** How much of Lucide's 24-unit grid each toolbar glyph's ink spans, centred in its box. */
const GLYPH_INK_SPANS = { plus: 14, mic: 14, audioLines: 20 } as const;
function touchGlyphInkWidth(span: number): number {
/** The plus glyph's ink: 14 of Lucide's 24 units, plus its stroke. */
function attachGlyphInkWidth(): number {
const glyph = toolbarGlyphsFor("touch").icon;
return (span * glyph.size) / 24 + glyph.strokeWidth;
return (14 * glyph.size) / 24 + glyph.strokeWidth;
}
/** The width each toolbar control paints when touch-sized; the row spaces these. */
export const COMPOSER_TOOLBAR_SHAPES = {
attach: touchGlyphInkWidth(GLYPH_INK_SPANS.plus),
dictation: touchGlyphInkWidth(GLYPH_INK_SPANS.mic),
voiceMode: touchGlyphInkWidth(GLYPH_INK_SPANS.audioLines),
ring: TOOLBAR_RINGS.touch.size,
disc: TOOLBAR_DISC_SIZES.touch,
} as const;
/** Pointer-sized the row hangs a flat 6px into the padding, which puts each 16pt glyph box on the rail. */
const POINTER_OVERHANG = 6;
@@ -68,10 +57,20 @@ export const COMPOSER_CORNER_RADIUS = bySizing({
pointer: 16,
});
/**
* Touch-sized each end control's highlight hangs past the rail far enough to put what it paints on
* the rail. The trailing disc paints its whole highlight, so it nests into the corner instead.
*/
const TOUCH_OVERHANGS: Record<ComposerToolbarEdge, number> = {
attach: (TOOLBAR_DISC_SIZES.touch - attachGlyphInkWidth()) / 2,
label: TOOLBAR_LABEL_PADDING,
disc: DISC_CORNER_NEST,
none: 0,
};
/** How far the composer toolbar hangs past the text column at one end. */
export function composerToolbarOverhang(edge: ComposerToolbarEdge, sizing: ControlSizing): number {
if (sizing === "pointer") return POINTER_OVERHANG;
return edge === "disc" ? DISC_CORNER_NEST : 0;
return sizing === "pointer" ? POINTER_OVERHANG : TOUCH_OVERHANGS[edge];
}
/** The control that starts the toolbar on the left. */
@@ -26,7 +26,7 @@ function useServerPlugins(serverId: string | null): InstalledPlugin[] {
);
}
/** How many composer items the host's plugins registered; the toolbar budgets room for them. */
/** How many composer items the host's plugins registered, whether or not each renders anything. */
export function usePluginComposerItemCount(serverId: string | null): number {
const plugins = useServerPlugins(serverId);
return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0);
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.3",
"version": "0.15.4",
"description": "Paseo CLI - control your AI coding agents from the command line",
"license": "Apache-2.0",
"bin": {
@@ -29,9 +29,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/server": "0.15.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+10 -6
View File
@@ -62,10 +62,15 @@ const EXPECTED_CLAUDE_MODELS = [
model: "Opus 5",
descriptionFragment: "Previous release",
},
{
id: "claude-fable-5-2",
model: "Fable 5.2",
descriptionFragment: "Most powerful",
},
{
id: "claude-fable-5-1",
model: "Fable 5.1",
descriptionFragment: "Most powerful",
descriptionFragment: "Previous release",
},
{
id: "claude-fable-5",
@@ -199,11 +204,10 @@ function assertClaudeModels(data: ProviderModel[]): void {
);
}
const fable51Index = data.findIndex((model) => model.id === "claude-fable-5-1");
const fable5Index = data.findIndex((model) => model.id === "claude-fable-5");
assert.strictEqual(
fable5Index,
fable51Index + 1,
const fable52Index = data.findIndex((model) => model.id === "claude-fable-5-2");
assert.deepStrictEqual(
data.slice(fable52Index, fable52Index + 3).map((model) => model.id),
["claude-fable-5-2", "claude-fable-5-1", "claude-fable-5"],
"Fable models should stay adjacent and newest-first",
);
assert(
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.15.3",
"version": "0.15.4",
"description": "Paseo client SDK package",
"license": "Apache-2.0",
"files": [
@@ -49,8 +49,8 @@
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
},
"dependencies": {
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.3",
"version": "0.15.4",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.3",
"version": "0.15.4",
"license": "Apache-2.0",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.3",
"version": "0.15.4",
"description": "Paseo plugin SDK package",
"license": "Apache-2.0",
"files": [
@@ -78,8 +78,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -89,8 +89,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.15.3",
"version": "0.15.4",
"description": "Paseo shared protocol schemas and wire types",
"license": "Apache-2.0",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.15.3",
"version": "0.15.4",
"description": "Paseo relay for bridging daemon and client connections",
"license": "Apache-2.0",
"files": [
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.15.3",
"version": "0.15.4",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
@@ -109,11 +109,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@getpaseo/client": "0.15.4",
"@getpaseo/highlight": "0.15.4",
"@getpaseo/plugin": "0.15.4",
"@getpaseo/protocol": "0.15.4",
"@getpaseo/relay": "0.15.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1614,6 +1614,8 @@ describe("ClaudeAgentSession features", () => {
test.each([
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"],
["Fable 5.2", "claude-fable-5-2", { type: "adaptive", display: "summarized" }, "medium"],
["Mythos 5.2", "claude-mythos-5-2", { type: "adaptive", display: "summarized" }, "medium"],
["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined],
["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"],
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
@@ -5,7 +5,7 @@ export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle",
key: "fast_mode",
label: "Fast",
description: "Lower latency Opus responses at higher token cost",
description: "Lower latency responses at higher token cost",
tooltip: "Toggle fast mode",
icon: "zap",
};
@@ -58,6 +58,17 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
id: "claude-mythos-5-2",
label: "Mythos 5.2",
requiresDiscovery: true,
description: "Mythos 5.2 · Requires verified access",
minimumClaudeCodeVersion: "2.1.321",
defaultThinkingOptionId: "medium",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-mythos-5-1",
label: "Mythos 5.1",
@@ -66,10 +77,20 @@ export const CLAUDE_MODEL_MANIFEST = [
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-fable-5-2",
label: "Fable 5.2",
description: "Fable 5.2 · Most powerful model",
minimumClaudeCodeVersion: "2.1.321",
defaultThinkingOptionId: "medium",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-fable-5-1",
label: "Fable 5.1",
description: "Fable 5.1 · Most powerful model",
description: "Fable 5.1 · Previous release",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
@@ -61,17 +61,23 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient {
function createCatalogClient(claudeCodeVersion = "2.1.321"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
resolvedModel: "claude-mythos-5-2",
displayName: "Mythos",
description: "",
},
{
value: "claude-mythos-5-1",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos 5.1",
description: "",
},
],
});
}
@@ -82,7 +88,9 @@ describe("getClaudeModels", () => {
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5-5",
"claude-opus-5",
"claude-mythos-5-2",
"claude-mythos-5-1",
"claude-fable-5-2",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
@@ -118,7 +126,9 @@ describe("getClaudeModels", () => {
new Map([
["claude-opus-5-5", 1_000_000],
["claude-opus-5", 1_000_000],
["claude-mythos-5-2", 1_000_000],
["claude-mythos-5-1", 1_000_000],
["claude-fable-5-2", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
["claude-fable-5[1m]", 1_000_000],
@@ -163,6 +173,12 @@ describe("getClaudeModels", () => {
expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
});
it("gates Fable 5.2 on Claude Code 2.1.321 without changing the default", () => {
expect(getClaudeModels("2.1.320").map((model) => model.id)).not.toContain("claude-fable-5-2");
expect(getClaudeModels("2.1.321").map((model) => model.id)).toContain("claude-fable-5-2");
expect(getClaudeModels("2.1.321").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
});
it("derives thinking options from model effort capabilities", () => {
const models = new Map(getClaudeModels().map((model) => [model.id, model]));
@@ -232,6 +248,8 @@ describe("getClaudeModels", () => {
["claude-sonnet-5-20260101", true, "high"],
["claude-fable-5", false, "high"],
["claude-fable-5-1", false, "high"],
["claude-fable-5-2", false, "medium"],
["claude-mythos-5-2", false, "medium"],
["claude-haiku-5-5", true, "medium"],
["claude-haiku-5-5-20261007", true, "medium"],
["claude-haiku-4-5", false, undefined],
@@ -284,6 +302,31 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
},
);
it("offers Mythos 5.2 in place of Mythos 5.1 when Claude Code resolves mythos to 5.2", async () => {
const configDir = await createClaudeConfigDir({});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.321",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-2",
displayName: "Mythos",
description: "",
},
],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.filter((model) => model.id.startsWith("claude-mythos-"))).toEqual([
findClaudeModel("claude-mythos-5-2"),
]);
});
it("preserves the curated catalog and explicitly configured models after discovery failure", async () => {
const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" });
const client = new ClaudeAgentClient({
@@ -299,8 +342,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
cwd: configDir,
force: false,
});
expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual(
getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"),
expect(models.filter((model) => !model.id.startsWith("claude-mythos-"))).toEqual(
getClaudeModels("2.1.293").filter((model) => !model.id.startsWith("claude-mythos-")),
);
expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({
...findClaudeModel("claude-mythos-5-1"),
@@ -360,7 +403,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
});
expect(models.map((model) => model.id)).toEqual(
getClaudeModels("2.1.293")
.filter((model) => model.id !== "claude-mythos-5-1")
.filter((model) => !model.id.startsWith("claude-mythos-"))
.map((model) => model.id),
);
});
@@ -785,6 +828,44 @@ describe("Claude Fable 5.1 catalog", () => {
});
});
describe("Claude Fable 5.2 catalog", () => {
it("offers one 1M model with medium default effort, always-on thinking, and fast mode", () => {
const models = getClaudeModels().filter((model) => model.id.startsWith("claude-fable-5-2"));
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "claude-fable-5-2",
label: "Fable 5.2",
contextWindowMaxTokens: 1_000_000,
defaultThinkingOptionId: "medium",
});
expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(claudeManifestModelSupportsFastMode("claude-fable-5-2")).toBe(true);
});
it.each([
"claude-fable-5-2[1m]",
"claude-fable-5-2-20261110",
"claude-fable-5-2-20261110[1m]",
"Fable 5.2",
])("resolves %s to the single catalog entry", (id) => {
expect(findClaudeModel(id)?.id).toBe("claude-fable-5-2");
});
it.each(["claude-fable-5-2", "anthropic/claude-fable-5-2", "us.anthropic.claude-fable-5-2-v1:0"])(
"keeps runtime model %s from collapsing onto Fable 5",
(id) => {
expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-fable-5-2");
},
);
});
describe("Claude Opus 5.5 catalog", () => {
it("offers one Opus 5.5 entry with a 1M context window", () => {
const opus55Models = getClaudeModels()
@@ -920,7 +1001,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
describe("Claude Mythos 5.1 catalog", () => {
it("offers one 1M model with high default effort and always-on thinking", () => {
const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos"));
const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos-5-1"));
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "claude-mythos-5-1",
@@ -965,6 +1046,61 @@ describe("Claude Mythos 5.1 catalog", () => {
);
});
describe("Claude Mythos 5.2 catalog", () => {
it("stays hidden on Claude Code older than 2.1.321 even when advertised", async () => {
const configDir = await createClaudeConfigDir({});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.320",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-2",
displayName: "Mythos",
description: "",
},
],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.some((model) => model.id === "claude-mythos-5-2")).toBe(false);
});
it("offers one 1M model with medium default effort, always-on thinking, and fast mode", () => {
const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos-5-2"));
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "claude-mythos-5-2",
label: "Mythos 5.2",
contextWindowMaxTokens: 1_000_000,
defaultThinkingOptionId: "medium",
});
expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(claudeManifestModelSupportsFastMode("claude-mythos-5-2")).toBe(true);
});
it.each(["anthropic/claude-mythos-5-2", "us.anthropic.claude-mythos-5-2-v1:0"])(
"normalizes runtime identity without granting first-party capabilities: %s",
(id) => {
expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-2");
expect(normalizeClaudeManifestModelId(id)).toBeNull();
},
);
});
describe("Claude model discovery process", () => {
it("does not send a prompt or persist a session and closes after discovery", async () => {
let closed = false;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.15.3",
"version": "0.15.4",
"private": true,
"type": "module",
"scripts": {
+4 -3
View File
@@ -1984,9 +1984,10 @@ controls render disabled, `setPrompt` and `insertText` throw, and the agent comm
"The composer is locked while the agent is being created." `composer.agent.options` and
`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`.
A composer item counts toward how much fits before Paseo knows what it renders. An item that
renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a
control.
Paseo measures what an item renders in the toolbar to decide what fits, so an item that renders
nothing takes no room and adds no overflow button. On wide composers the item stays mounted while
it is in the overflow, and the overflow sheet mounts a second instance while it is open, so keep
effects safe to run twice. On a phone an item mounts only while the overflow sheet is open.
The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`, plus an