Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ddfed6afe9 test(server): reproduce queued steer loss at turn completion
Keep controlled completion timing cases and the passing idle-dispatch control for issue #8679. These diagnostic tests intentionally expose dropped delivery; retry semantics remain a maintainer decision because unavailable steering does not establish whether the provider accepted the message.
2026-11-04 10:21:10 +00:00
Mohamed BoudraandElsa Granger 4991efddf8 feat(app): follow iPhone Duo vertical bars and fold divisions (#8741)
* feat(app): follow system vertical bars and fold divisions

Keep custom navigation controls outside system content areas and use active division regions for temporary panel allocation. Normal sidebar preferences resume when the division disappears.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): show window actions in the active portal scope

The bottom-sheet provider creates its own portal context. Register the window action host within that context so symbols reach the root host.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): hide workspace bar actions behind compact panels

Compact panels keep the workspace route focused while covering its content. Hide its portal actions until the workspace panel is active again.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): continue the sidebar surface across divisions

Keep the fold free of controls while painting it as part of the sidebar, with the shared divider at the workspace edge.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): omit tab count beside the full tab rail

The full tab rail already exposes navigation. Use the same compact/full header selection for the vertical action list.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): preserve folded navigation and bar action lifetime

Use resolved sidebar visibility for desktop activity, and keep bar controls mounted while compact panels cover them so pending menu work survives. Carry circular button chrome inside the portal and exercise the rendered boundary in Chromium.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): own script actions across header placement changes

Keep one script action instance on the workspace screen so pending restarts survive removal of either menu. Pass activity through the bar portal so covered menus close their overlays without taking workflow state with them.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): exercise script ownership through the workspace

Replace synthetic ownership fixtures with a real workspace, script menu and isolated terminal worker. Hold terminal exit until the compact header is removed so moving pending state back into the menu fails the regression. Exercise portal activity with production menu and header controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-04 11:05:45 +01:00
Mohamed Boudra fcbfb4f0d3 Size controls for touch on iPad and other touch screens (#8748)
* feat(app): size controls for touch on any touch device, not just compact layouts

One gate picks touch or pointer sizing: touch when the layout is compact or
the device has a touch screen. The touch fact is read once, so attaching a
mouse or keyboard to a tablet never shrinks its controls. Stylesheets take
`bySizing({ touch, pointer })`, which resolves to a breakpoint value, so the
existing `{ xs, md }` consumers follow the gate without call-site changes.

Touch values follow the iOS HIG 44pt hit region: controls, icon buttons,
rows, bars and menu rows are 44 on touch; `sm` controls are 32. Glyphs move
to a touch ladder (14/18/20/24). Pointer sizes are unchanged.

Merges duplicated header control heights, density's private icon ladder and
the compact-only icon button sizes into control-geometry.

* feat(app): size the composer for touch through control geometry

The composer toolbar, attach / dictation / realtime / send buttons, context
meter and model triggers take TOOLBAR_CONTROL_SIZES (28 pointer, 44 touch)
instead of private 28 constants. The density budget, button glyphs and the
fullscreen glyph rail offset resolve the same gate, replacing the isWeb /
isNative glyph branches. Track rows join the menu row height.

Desktop geometry is unchanged. On touch the toolbar steps down density
earlier: iPad portrait goes from condensed to tight.

* feat(app): fold composer controls before the model label truncates

The composer toolbar walks one width budget: full, condensed, tight,
compact, then icon-only. Tight keeps the model at its family label;
below that, thinking, mode and features fold into the model control's
sheet (the phone toolbar), and only then does the model drop its label.
A touch iPad in portrait with an attachment now reads
"GPT-6-Astra Medium" instead of "GP…".

The context meter glyph and combobox rows and icons take the density
ladder. Desktop is pixel-identical in the content area.

* refactor(app): keep the sizing gate free of the theme

constants/layout sized its bars through control-geometry, which loads the
theme and runs Platform.select at import. The gate (resolveControlSizing,
bySizing, TOUCH_TARGET) moves to control-sizing.ts with no theme import;
control-geometry re-exports it, so its callers are unchanged.

* test(app): expect touch sizing in the compact Changes and plugin sidebar specs

The compact Changes options button is a 44pt icon button with a 20pt glyph,
and its framed actions trigger is 32 tall. In the compact sidebar the nav
block is capped at half the height, so 44pt rows put the plugin header row
below the cap; the spec scrolls it into view as a user would.

* fix(app): close composer menus when the controls fold or unfold

Folding swaps the toolbar controls for the model sheet. A menu opened on the
separate controls stayed marked open across the fold, so widening the
composer again reopened Thinking without a click.
2026-11-04 11:04:52 +01:00
Monarch MaisuriyaandClaude Opus 5.5 6221b0c8b7 Fork from earlier replies when a plugin card was updated later (#8746)
The fork checkpoint check refused a fork when any projected row spanned the
selected reply. A plugin card re-appended with the same id keeps its first
position and stretches to its latest update, so it spanned every reply in
between, although the fork never copies plugin cards. The check now looks only
at the row kinds the fork copies, taken from the same constant as the copy.

Closes #8745

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:02:25 +00:00
218 changed files with 3956 additions and 8266 deletions
+5 -3
View File
@@ -68,9 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
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 in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px 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()`.
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()`.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
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.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -150,7 +152,7 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
+3 -62
View File
@@ -30,65 +30,6 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow. `options` on the snapshot is what
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
Editing a provider default in `config.json` affects agents the next time they open.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts its query, and replacing a started query would end its background work, so after the
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
before it refused, it reports the moved values once as `options_changed` after the call has
ended, because the stored options must match what runs.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
agent snapshot for a running or closed agent. A draft and a profile form send everything they
would create with (seed, the provider's last-used control values once the provider has said it
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
replaces provider and options in one step, while choosing a provider by hand starts over. A
profile saves its stored options plus the form's edits, so options no control draws survive an
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
create that carries options first lists the controls for its own provider, model, mode and
thinking level, since which options are controls differs by model, and sends those as
`featureValues`; callers do not prime anything.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
@@ -174,7 +115,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -537,7 +478,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly controls?: AgentOptionControl[];
readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -559,7 +500,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
applyOptions?(options: ProviderOptions): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
+4 -3
View File
@@ -95,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
## Resume behavior
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.options ?? null;
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
provider: featureProviderId,
model: featureModelId,
modeId: "bypassPermissions",
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
]);
const provider = await seedModelProvider({
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
expect(createAgentRequest).toMatchObject({
config: {
provider: featureProviderId,
providerOptions: { fast_mode: true },
featureValues: { fast_mode: true },
},
});
} finally {
@@ -928,11 +928,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
throw new Error("Compact Changes toolbar geometry could not be measured");
}
expect(actionsBox.width).toBe(48);
expect(actionsBox.height).toBe(28);
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
expect(actionsBox.height).toBe(32);
expect(optionsBox.width).toBe(44);
expect(optionsBox.height).toBe(44);
expect(glyphBox.width).toBe(20);
expect(glyphBox.height).toBe(20);
await expect(actions).not.toContainText("Commit");
await expect(actions.locator("svg")).toHaveCount(2);
@@ -1,5 +1,6 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
await workspace.cleanup();
}
});
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
await openRunningScriptPage(page, "lint");
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
try {
await menuRow(page, "Restart").click();
await page.setViewportSize({ width: 1280, height: 844 });
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
} finally {
resumeWorker();
}
}
async function expectScriptTerminalReplaced(
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
previousId: string,
) {
await expect
.poll(
async () => {
const after = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
},
{ timeout: 15_000 },
)
.toBe(1);
}
test("pending script restart survives removal of the compact header", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await openWorkspaceWithScripts(page);
try {
await runScriptFromScriptsPage(page, "lint");
const before = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
expect(before.terminals).toHaveLength(1);
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
} finally {
await workspace.cleanup();
}
});
@@ -5,6 +5,7 @@ import {
recordComposerToolbarFrames,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const SETTLE_MS = 1_000;
@@ -92,3 +93,34 @@ test.describe("Composer control density across tab switches", () => {
}
});
});
test.describe("Composer controls folding on a touch device", () => {
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-fold-",
title: "Folding chat",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
const thinkingMenu = page.getByTestId("combobox-desktop-container");
await thinking.click();
await expect(thinkingMenu).toBeVisible();
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
await page.setViewportSize({ width: 640, height: 834 });
await expect(thinking).toHaveCount(0);
await page.setViewportSize({ width: 1194, height: 834 });
await expect(thinking).toBeVisible();
await expect(thinkingMenu).toHaveCount(0);
} finally {
await session.cleanup();
}
});
});
@@ -317,7 +317,10 @@ test.describe("Plugin sidebar items", () => {
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
// The compact nav block is capped at half the sidebar and scrolls past it.
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await compactDeploys.scrollIntoViewIfNeeded();
await expect(compactDeploys).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
@@ -6,6 +6,11 @@ env APP_ID=sh.paseo.debug
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
press "id=\"sidebar-close\""
# The close target must clear system controls, including a trailing status column.
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
@@ -1,3 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { realpathSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -380,3 +382,21 @@ export function observeSetupPanelTraffic(page: Page) {
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
/** Hold this isolated daemon's terminal worker so script exit cannot race a layout change. */
export async function pauseWorkspaceTerminalWorker(terminalId: string): Promise<() => void> {
const home = process.env.E2E_PASEO_HOME;
if (!home) throw new Error("Missing isolated E2E home");
const directory = path.join(home, "terminal-worker");
for (const name of await readdir(directory)) {
if (!name.endsWith(".json")) continue;
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ id: string }>;
};
if (!record.terminals.some((terminal) => terminal.id === terminalId)) continue;
process.kill(record.pid, "SIGSTOP");
return () => process.kill(record.pid, "SIGCONT");
}
throw new Error(`No isolated terminal worker owns ${terminalId}`);
}
@@ -0,0 +1,4 @@
{
"platforms": ["ios"],
"ios": { "modules": ["PaseoWindowLayoutModule"] }
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'PaseoWindowLayout'
s.version = '0.1.0'
s.summary = 'System window layout geometry for Paseo'
s.description = 'Publishes system bar and division geometry'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '16.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,110 @@
import ExpoModulesCore
import UIKit
public final class PaseoWindowLayoutModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoWindowLayout")
View(WindowLayoutView.self) {
Events("onGeometryChange")
}
}
}
final class WindowLayoutView: ExpoView {
let onGeometryChange = EventDispatcher()
private var updateScheduled = false
private var previousPayload: [String: Any]?
private var divisions: [CGRect] = []
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) {
(view: WindowLayoutView, _) in
view.setNeedsUpdateConstraints()
view.scheduleUpdate()
}
}
#endif
}
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func layoutSubviews() {
super.layoutSubviews()
if bounds != measuredBounds {
measuredBounds = bounds
setNeedsUpdateConstraints()
}
scheduleUpdate()
}
override func updateConstraints() {
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
case .trailing: edge = "trailing"
default: edge = "none"
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
for: .bar(onEdge: edge == "trailing" ? .right : .left, extent: 44)
)
}
}
#endif
super.updateConstraints()
scheduleUpdate()
}
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateScheduled = false
self.publish()
}
}
private func publish() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
let bar = barGuide?.layoutFrame
var barPayload: Any = NSNull()
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
previousPayload = payload
onGeometryChange(payload)
}
private func rect(_ frame: CGRect) -> [String: CGFloat] {
["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
export function normalizeAgentCommandsCwd(cwd: string): string {
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking",
draftConfig.thinkingOptionId ?? null,
"features",
draftConfig.providerOptions ?? null,
draftConfig.featureValues ?? null,
] as const;
}
@@ -1,189 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import { isControlActive } from "./option-controls";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "./draft-options-model";
const SPEED: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
};
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
function answer(
provider: string,
requestKey: string,
options: Record<string, unknown>,
controls = [SPEED, WEB_SEARCH],
) {
return { provider, requestKey, controls, options };
}
describe("draft options model", () => {
it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => {
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } });
model.applyResolved(answer("codex", "k1", { service_tier: "priority" }));
const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" });
expect(view?.options).toEqual({ service_tier: "priority" });
expect(view && isControlActive(SPEED, view.options)).toBe(true);
// The daemon still gets the stored value to resolve, and so does create.
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
});
it("applying a profile from another provider keeps its options through the provider change", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}));
model.applyProfile({
provider: "codex",
options: { sandbox_mode: "read-only", service_tier: "fast" },
});
// The form follows the profile to its provider.
model.observeProvider("codex");
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
service_tier: "fast",
});
expect(model.getState().resolved).toBeNull();
});
it("choosing another provider by hand starts the options over", () => {
const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } });
model.setOption("web_search", true);
model.observeProvider("claude");
expect(ownDraftOptions(model.getState())).toBeUndefined();
expect(model.getState().resolved).toBeNull();
});
it("never pairs a manually chosen provider with the previous provider's options", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(
answer("claude", "k1", { fast_mode: true }, [
{ type: "toggle", key: "fast_mode", label: "Fast" },
]),
);
model.setOption("fast_mode", true);
const form = { provider: "claude", cwd: "/repo" };
expect(draftControlsRequest(model.getState(), form)).toMatchObject({
provider: "claude",
providerOptions: { fast_mode: true },
});
// The form shows OpenCode on its next render; the model has not observed it yet.
expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull();
model.observeProvider("opencode");
const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" });
expect(request).toEqual({ provider: "opencode", cwd: "/repo" });
});
it("stops sending a toggle once the model in use draws no control for it", () => {
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
const model = openDraftOptions({ provider: "claude", seed: { sandbox_mode: "read-only" } });
model.applyResolved(answer("claude", "opus", { fast_mode: false }, [FAST_MODE]));
model.setOption("fast_mode", true);
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
fast_mode: true,
});
// The form moves to a model without Fast; its answer lists no such control.
model.applyResolved(answer("claude", "haiku", {}, []));
expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only" });
// Back on a model that draws the control, the toggle applies again. The daemon's answer
// for the request without it said Fast was off; the request that carries it follows.
model.applyResolved(answer("claude", "opus-again", { fast_mode: false }, [FAST_MODE]));
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
fast_mode: true,
});
});
it("always sends an option no control has drawn", () => {
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}, [FAST_MODE]));
model.setOption("token_budget", 5);
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
model.applyResolved(answer("claude", "k2", {}, []));
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
});
it("keeps a draft's seed when the first provider is chosen", () => {
const model = openDraftOptions({ provider: null, seed: { web_search: true } });
model.observeProvider("claude");
expect(ownDraftOptions(model.getState())).toEqual({ web_search: true });
});
it("sends a remembered value to the daemon, and draws only what comes back", () => {
const model = openDraftOptions({ provider: "codex" });
model.setRemembered({ service_tier: "fast", retired: true });
// Nothing is known about the provider's controls yet.
expect(ownDraftOptions(model.getState())).toBeUndefined();
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
// The answer to the first request does not show the remembered value as if it applied.
expect(
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
).toEqual({ service_tier: "default" });
model.applyResolved(answer("codex", "k2", { service_tier: "priority" }));
expect(
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
).toEqual({ service_tier: "priority" });
});
it("does not let a remembered value override the seed", () => {
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } });
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
model.setRemembered({ service_tier: "fast" });
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" });
});
it("lays a toggle over the previous answer until the new one arrives", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", { web_search: false }));
model.setOption("web_search", true);
expect(
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
).toEqual({ web_search: true });
model.applyResolved(answer("claude", "k2", { web_search: true }));
expect(
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
).toEqual({ web_search: true });
});
it("draws nothing for a provider the answer is not for", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}));
expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe(
undefined,
);
});
});
@@ -1,193 +0,0 @@
import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
import { withoutLostControls } from "./lost-controls";
/**
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
* default or legacy key is resolved here.
*
* - `seed`: options the draft started from (a profile, a forked agent).
* - `remembered`: the provider's last-used control values.
* - `edits`: toggles made in this draft.
*
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
* and the options in one step, so the provider change that follows does not clear them.
*/
export interface ResolvedDraftOptions {
provider: string;
/** Names the request that produced this answer; see `viewDraftOptions`. */
requestKey: string;
controls: AgentOptionControl[];
options: ProviderOptions;
}
export interface DraftOptionsState {
provider: string | null;
seed: ProviderOptions;
remembered: ProviderOptions;
edits: ProviderOptions;
resolved: ResolvedDraftOptions | null;
/** Every key an answer for this provider has drawn as a control. */
drawn: ReadonlySet<string>;
}
export interface DraftOptionsModel {
getState: () => DraftOptionsState;
subscribe: (listener: () => void) => () => void;
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
observeProvider: (provider: string | null) => void;
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
setRemembered: (remembered: ProviderOptions) => void;
setOption: (key: string, value: unknown) => void;
applyResolved: (resolved: ResolvedDraftOptions) => void;
}
export function openDraftOptions(input: {
provider: string | null;
seed?: ProviderOptions;
}): DraftOptionsModel {
let state: DraftOptionsState = {
provider: input.provider,
seed: input.seed ?? {},
remembered: {},
edits: {},
resolved: null,
drawn: new Set(),
};
const listeners = new Set<() => void>();
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
const next = update(state);
if (next === state) return;
state = next;
for (const listener of listeners) listener();
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
observeProvider: (provider) =>
publish((current) => {
if (provider === current.provider) return current;
// The first provider a draft picks does not discard what it was opened with.
if (current.provider === null) return { ...current, provider };
return {
...current,
provider,
seed: {},
edits: {},
resolved: null,
drawn: new Set(),
};
}),
applyProfile: (profile) =>
publish((current) => ({
...current,
provider: profile.provider,
seed: profile.options,
edits: {},
resolved: current.provider === profile.provider ? current.resolved : null,
drawn: current.provider === profile.provider ? current.drawn : new Set(),
})),
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
setOption: (key, value) =>
publish((current) =>
Object.is(current.edits[key], value)
? current
: { ...current, edits: { ...current.edits, [key]: value } },
),
applyResolved: (resolved) =>
publish((current) => ({
...current,
resolved,
drawn: new Set([...current.drawn, ...resolved.controls.map((control) => control.key)]),
})),
};
}
/**
* Everything the draft would create with, for the daemon to resolve. A last-used value applies
* once the provider has told us it still draws a control for that key, and never over the seed.
* An edit of a key an earlier answer drew as a control stops applying when the latest answer
* draws none for it (the model changed to one without that control) and applies again when a
* control for it returns. An edit of a key no answer has drawn is an option without a control
* and always applies.
*/
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
const remembered = Object.fromEntries(
Object.entries(state.remembered).filter(
([key]) => controlKeys.has(key) && !(key in state.seed),
),
);
const edits = withoutLostControls(state.edits, state.drawn, controlKeys);
const own = { ...state.seed, ...remembered, ...edits };
return Object.keys(own).length > 0 ? own : undefined;
}
export type DraftControlsRequest = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
>;
/** What the draft's form has chosen; the options come from the model. */
export interface DraftControlsForm {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
}
/**
* The listing request for the draft's form, built from one snapshot of the model. The form
* reaches a new provider before the model has observed it, and the model still holds the
* previous provider's options until it does; that render has no request, so a request never
* pairs one provider with another's options.
*/
export function draftControlsRequest(
state: DraftOptionsState,
form: DraftControlsForm,
): DraftControlsRequest | null {
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
const providerOptions = ownDraftOptions(state);
return {
provider: form.provider,
cwd: form.cwd,
...(form.modeId ? { modeId: form.modeId } : {}),
...(form.modelId ? { model: form.modelId } : {}),
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
...(providerOptions ? { providerOptions } : {}),
};
}
/**
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
* wait for the round trip. It never carries a seed or remembered value the daemon has not
* resolved.
*/
export function viewDraftOptions(
state: DraftOptionsState,
current: { provider: string | null; requestKey: string },
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
const { resolved } = state;
if (!resolved || resolved.provider !== current.provider) return undefined;
return {
controls: resolved.controls,
options:
resolved.requestKey === current.requestKey
? resolved.options
: { ...resolved.options, ...state.edits },
};
}
@@ -1,17 +0,0 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
/**
* The options that still apply after an answer from the daemon. A key an earlier answer drew as a
* control and the latest answer does not (the model changed to one without that control) is
* dropped, and applies again if a control for it returns. A key no answer has drawn is an option
* without a control and always applies.
*/
export function withoutLostControls(
options: ProviderOptions,
drawn: ReadonlySet<string>,
current: ReadonlySet<string>,
): ProviderOptions {
return Object.fromEntries(
Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)),
);
}
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
} from "./option-controls";
const speed: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
describe("option controls", () => {
it("reads a select from options and falls back to the control's default", () => {
if (speed.type !== "select") throw new Error("select expected");
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
expect(readSelectOption(speed, {})).toBe("default");
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
});
it("reads a toggle from options and is off when unset", () => {
if (fast.type !== "toggle") throw new Error("toggle expected");
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
expect(readToggleOption(fast, {})).toBe(false);
});
it("is active when the option differs from the control's default", () => {
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
expect(isControlActive(speed, {})).toBe(false);
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
expect(isControlActive(fast, {})).toBe(false);
});
it("prefers a control's tooltip over its label", () => {
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
"Toggle plan mode",
);
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
});
});
@@ -1,47 +0,0 @@
import type {
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* The composer's option controls. A control only describes how to draw one option key; the
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
*/
export interface ComposerOptionControls {
controls: AgentOptionControl[];
options: ProviderOptions;
onSetOption: (key: string, value: unknown) => void;
}
export function readToggleOption(
control: AgentOptionToggleControl,
options: ProviderOptions,
): boolean {
const value = options[control.key];
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
}
/** The selected choice's value, or the control's default when the option names no choice. */
export function readSelectOption(
control: AgentOptionSelectControl,
options: ProviderOptions,
): string | null {
const value = options[control.key];
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
return value;
}
return control.defaultValue ?? null;
}
/** Whether the option is set to something other than the control's default. */
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
if (control.type === "toggle") return readToggleOption(control, options);
const value = readSelectOption(control, options);
return value !== null && value !== control.defaultValue;
}
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
return control.tooltip ?? control.label;
}
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
}),
),
).toEqual({
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
});
});
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
modelId: "",
modeId: "",
thinkingOptionId: "",
options: {},
featureValues: {},
});
});
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
}),
),
),
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
});
});
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
it("omits an empty feature map so the agent keeps its own feature values", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
).not.toHaveProperty("options");
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("featureValues");
});
it("never carries a provider, which a running agent cannot change", () => {
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
modelId: string;
modeId: string;
thinkingOptionId: string;
options: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
function trimmed(value: string | undefined): string {
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
modelId: trimmed(profile.model),
modeId: trimmed(profile.modeId),
thinkingOptionId: trimmed(profile.thinkingOptionId),
options: profile.options ?? {},
featureValues: profile.featureValues ?? {},
};
}
@@ -52,6 +52,8 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
};
}
@@ -1,7 +1,7 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import {
buildControlsRequestKey,
buildFeatureRequestKey,
openAgentProfileForm,
type AgentProfileFormModel,
} from "./profile-form-model";
@@ -70,21 +70,6 @@ function optionValues(options: readonly { value: string }[]): string[] {
return options.map((option) => option.value);
}
const FAST = {
controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }],
options: { fast_mode: false },
};
const NO_CONTROLS = { controls: [], options: {} };
function toggledOnOpus(): AgentProfileFormModel {
const model = openWithCatalog({ mode: "create" });
model.setName("Fast");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.setOption("fast_mode", true);
return model;
}
describe("openAgentProfileForm", () => {
it("starts a create form empty and cannot submit", () => {
const model = openWithCatalog({ mode: "create" });
@@ -188,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { webSearch: true },
featureValues: { webSearch: true },
notes: "Visual work only.",
},
});
@@ -244,11 +229,10 @@ describe("openAgentProfileForm", () => {
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
model.setOption("webSearch", true);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
model.setProvider("codex", { label: "Codex" });
const state = model.getState();
@@ -258,9 +242,8 @@ describe("openAgentProfileForm", () => {
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull();
expect(state.options).toEqual({});
expect(state.optionEdits).toEqual({});
expect(state.controls).toEqual([]);
expect(state.featureValues).toEqual({});
expect(state.features).toEqual([]);
expect(state.name).toBe("Test writer");
});
@@ -351,28 +334,23 @@ describe("openAgentProfileForm", () => {
});
});
const WEB_SEARCH_CONTROLS = {
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }],
options: { webSearch: false },
};
describe("controls", () => {
it("asks for controls per provider/model/mode/thinking combination", () => {
describe("features", () => {
it("asks for features per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().controlsRequest).toEqual({
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think",
});
const firstKey = model.getState().controlsRequestKey;
const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
expect(model.getState().controlsRequest).toEqual({
expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-haiku-4-5",
modeId: "plan",
@@ -383,233 +361,77 @@ describe("openAgentProfileForm", () => {
it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const key = model.getState().controlsRequestKey ?? "";
expect(model.getState().controlsResolution).toBe("pending");
const key = model.getState().featureRequestKey ?? "";
expect(model.getState().featureResolution).toBe("pending");
model.applyControls("stale-key", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().controls).toEqual([]);
model.applyFeatures("stale-key", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().features).toEqual([]);
model.applyControls(key, {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(true);
model.applyFeatures(key, [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(true);
});
it("reflects a set option back into the rendered controls", () => {
it("reflects a set feature value back into the rendered feature", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Searcher");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setOption("webSearch", true);
model.setFeatureValue("webSearch", true);
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" });
expect(model.getState().controlOptions).toEqual({ webSearch: true });
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("keeps stored options while the controls are still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
});
it("keeps options no control draws, so editing a profile does not drop them", () => {
it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
options: { webSearch: true, sandbox_mode: "read-only" },
featureValues: { webSearch: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setOption("webSearch", false);
expect(model.getState().submitValue?.options).toEqual({
webSearch: false,
sandbox_mode: "read-only",
});
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
});
it("draws the options the daemon resolved from the stored ones, then the edits", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
});
model.applyProviderCatalog(ENTRIES);
// The request carries the stored options so the daemon can resolve the legacy tier.
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority" },
});
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
// Nothing was edited, so the stored value is saved as it was.
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
});
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
it("prunes stored values the provider no longer reports", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "Fast",
provider: "codex",
options: { service_tier: "fast", sandbox_mode: "read-only" },
},
});
model.applyProviderCatalog(ENTRIES);
const speed = {
controls: [
{
type: "select" as const,
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority", sandbox_mode: "read-only" },
};
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
model.setOption("service_tier", "default");
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
expect(model.getState().controls).toHaveLength(1);
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
// The request carries everything the profile would save, so the daemon resolves it.
expect(model.getState().controlsRequest?.providerOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
expect(model.getState().controlsResolution).toBe("pending");
model.applyControls(model.getState().controlsRequestKey ?? "", {
...speed,
options: { service_tier: "default", sandbox_mode: "read-only" },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().controlOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
});
it("draws no controls for the previous model while another model's answer is pending", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controls).toEqual([]);
});
it("does not save a toggle once the model that drew it is replaced by one without it", () => {
const model = toggledOnOpus();
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
expect(model.getState().controlsRequest?.providerOptions).toBeUndefined();
});
it("saves a toggle again when the model that draws it returns", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
});
it("does not save a stored toggle profile either", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "Fast",
name: "UI work",
provider: "claude",
model: "claude-opus-5",
options: { fast_mode: true },
featureValues: { webSearch: true, retiredFlag: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
});
it("never drops an option no control has drawn", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Fast");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.setOption("token_budget", 8000);
model.setOption("fast_mode", true);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 });
});
it("keeps a toggle when the daemon cannot list controls", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(false);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(false);
});
});
@@ -642,15 +464,15 @@ describe("openAgentProfileForm", () => {
});
});
describe("buildControlsRequestKey", () => {
describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => {
expect(buildControlsRequestKey(null)).toBeNull();
expect(buildFeatureRequestKey(null)).toBeNull();
});
it("separates the fields so a value cannot bleed across positions", () => {
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildControlsRequestKey({ provider: "claude", model: "a" }),
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildFeatureRequestKey({ provider: "claude", model: "a" }),
);
});
});
@@ -1,14 +1,13 @@
import type {
AgentOptionControl,
ProviderOptions,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentSelectOption,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { withoutLostControls } from "@/agent-controls/lost-controls";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
/**
@@ -58,15 +57,11 @@ export interface AgentProfileFormSnapshot {
}
/**
* The inputs a controls listing needs. Controls are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request. The
* profile's own options ride along so the daemon can resolve them (a legacy
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
* for the life of one provider, so they are not part of the key.
* The inputs a feature listing needs. Features are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request.
*/
export interface AgentProfileControlsRequest {
export interface AgentProfileFeatureRequest {
provider: string;
providerOptions?: ProviderOptions;
model?: string;
modeId?: string;
thinkingOptionId?: string;
@@ -78,7 +73,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showControlsField: boolean;
showFeaturesField: boolean;
}
export interface AgentProfileFormState {
@@ -91,22 +86,13 @@ export interface AgentProfileFormState {
modelId: string;
modeId: string;
thinkingOptionId: string;
/** The options the profile was opened with, including ones no control draws. */
options: ProviderOptions;
/** Changes made in this form, over `options`. */
optionEdits: ProviderOptions;
featureValues: Record<string, unknown>;
providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[];
controls: AgentOptionControl[];
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
controlOptions: ProviderOptions;
/** Every key an answer for this provider has drawn as a control. */
drawnControlKeys: ReadonlySet<string>;
/** The keys the latest answer draws as controls. */
answeredControlKeys: ReadonlySet<string>;
features: AgentFeature[];
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
@@ -114,9 +100,9 @@ export interface AgentProfileFormState {
thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus;
controlsResolution: AgentProfileResolutionStatus;
controlsRequest: AgentProfileControlsRequest | null;
controlsRequestKey: string | null;
featureResolution: AgentProfileResolutionStatus;
featureRequest: AgentProfileFeatureRequest | null;
featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean;
@@ -132,12 +118,9 @@ export interface AgentProfileFormModel {
/** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */
applyControls: (
requestKey: string,
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
) => void;
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
/** Resolve a request that produced no usable features (provider error). */
applyControlsUnavailable: (requestKey: string) => void;
applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void;
@@ -145,7 +128,7 @@ export interface AgentProfileFormModel {
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
setOption: (key: string, value: unknown) => void;
setFeatureValue: (featureId: string, value: unknown) => void;
setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void;
}
@@ -329,39 +312,19 @@ function refineDisplay(
return current ?? { label: selectedId };
}
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
if (!state.provider) {
return null;
}
const ownOptions = ownOptionsOf(state);
return {
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
};
}
/**
* Everything the profile would save as its options; the daemon resolves them for the form. A
* toggle the model in use does not draw is not saved (see `withoutLostControls`).
*/
function ownOptionsOf(
state: Pick<
AgentProfileFormState,
"options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys"
>,
): ProviderOptions {
return withoutLostControls(
{ ...state.options, ...state.optionEdits },
state.drawnControlKeys,
state.answeredControlKeys,
);
}
/** The part of a request that decides which controls exist; options only change their values. */
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
if (!request) {
return null;
}
@@ -373,25 +336,12 @@ function buildControlsScopeKey(request: AgentProfileControlsRequest | null): str
].join("|");
}
export function buildControlsRequestKey(
request: AgentProfileControlsRequest | null,
): string | null {
if (!request) {
return null;
}
const scope = buildControlsScopeKey(request);
return request.providerOptions
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
: scope;
}
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim();
const notes = state.notes.trim();
if (!name || !state.provider) {
return null;
}
const options = ownOptionsOf(state);
return {
name,
...(state.icon ? { icon: state.icon } : {}),
@@ -400,16 +350,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(options).length > 0 ? { options } : {}),
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}),
};
}
function resolveControlsStatus(
controlsRequestKey: string | null,
function resolveFeatureStatus(
featureRequestKey: string | null,
featuresAreCurrent: boolean,
): AgentProfileResolutionStatus {
if (controlsRequestKey === null) {
if (featureRequestKey === null) {
return "idle";
}
return featuresAreCurrent ? "complete" : "pending";
@@ -442,29 +392,25 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modelId,
modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "",
options: { ...profile.options },
optionEdits: {},
featureValues: { ...profile.featureValues },
providerOptions: [],
modelOptions: [],
modeOptions: [],
thinkingOptions: [],
controls: [],
controlOptions: {},
drawnControlKeys: new Set(),
answeredControlKeys: new Set(),
features: [],
providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle",
controlsResolution: "idle",
controlsRequest: null,
controlsRequestKey: null,
featureResolution: "idle",
featureRequest: null,
featureRequestKey: null,
disclosure: {
showModelField: false,
showModeField: false,
showThinkingField: false,
showControlsField: false,
showFeaturesField: false,
},
isSubmitting: false,
submitError: null,
@@ -476,45 +422,28 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedControlsKey: string | null = null;
let resolvedScopeKey: string | null = null;
let resolvedControls: AgentOptionControl[] = [];
let resolvedOptions: ProviderOptions = {};
let drawnKeys: ReadonlySet<string> = new Set();
let answeredKeys: ReadonlySet<string> = new Set();
let resolvedFeatureKey: string | null = null;
let resolvedFeatures: AgentFeature[] = [];
let listeners = new Set<() => void>();
let closed = false;
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
const models = resolveModels(entries, incoming.provider);
const modes = resolveModes(entries, incoming.provider);
const next: AgentProfileFormState = {
...seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
}),
drawnControlKeys: drawnKeys,
answeredControlKeys: answeredKeys,
};
const next = seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
});
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const controlsRequest = buildControlsRequest(next);
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
const featureRequest = buildFeatureRequest(next);
const featureRequestKey = buildFeatureRequestKey(featureRequest);
const featuresAreCurrent =
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey;
// A toggle changes only the options, so the controls stay up while the daemon answers and the
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none
// until its own answer arrives.
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey;
const controls = controlsApply ? resolvedControls : [];
let controlOptions: ProviderOptions = next.options;
if (controlsApply) {
controlOptions = featuresAreCurrent
? resolvedOptions
: { ...resolvedOptions, ...next.optionEdits };
}
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
const features = featuresAreCurrent
? applyFeatureValues(resolvedFeatures, next.featureValues)
: [];
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = {
...next,
@@ -522,8 +451,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking),
controls,
controlOptions,
features,
providerDisplay: refineDisplay(
next.providerDisplay,
next.provider,
@@ -551,9 +479,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
})(),
),
catalogResolution,
controlsResolution,
controlsRequest,
controlsRequestKey,
featureResolution,
featureRequest,
featureRequestKey,
};
// A field appears once the catalog can populate it, and stays visible while
@@ -564,7 +492,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showControlsField: hasProvider && controls.length > 0,
showFeaturesField: hasProvider && features.length > 0,
};
const canSubmit =
withOptions.name.trim().length > 0 &&
@@ -603,26 +531,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
catalogResolution = "complete";
publish((current) => current);
},
applyControls: (requestKey, resolved) => {
if (requestKey !== state.controlsRequestKey) {
applyFeatures: (requestKey, features) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [...resolved.controls];
resolvedOptions = resolved.options;
answeredKeys = new Set(resolved.controls.map((control) => control.key));
drawnKeys = new Set([...drawnKeys, ...answeredKeys]);
publish((current) => current);
resolvedFeatureKey = requestKey;
resolvedFeatures = [...features];
publish((current) => ({
...current,
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
},
applyControlsUnavailable: (requestKey) => {
if (requestKey !== state.controlsRequestKey) {
applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [];
resolvedOptions = {};
resolvedFeatureKey = requestKey;
resolvedFeatures = [];
publish((current) => current);
},
setName: (value) => publish((current) => ({ ...current, name: value })),
@@ -637,8 +562,6 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
// Everything below the provider is provider-scoped: a model id, mode id,
// thinking id or feature id from the old provider means nothing here.
// Clearing them is enough — `derive` seeds the new provider's defaults.
drawnKeys = new Set();
answeredKeys = new Set();
return {
...current,
provider: providerId,
@@ -649,8 +572,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
options: {},
optionEdits: {},
featureValues: {},
};
}),
setModel: (modelId, display) =>
@@ -676,10 +598,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setOption: (key, value) =>
setFeatureValue: (featureId, value) =>
publish((current) => ({
...current,
optionEdits: { ...current.optionEdits, [key]: value },
featureValues: { ...current.featureValues, [featureId]: value },
})),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
}
const featureCount = Object.keys(input.profile.options ?? {}).length;
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
}
@@ -117,7 +117,9 @@ export function useAgentProfilePicker(
modelId: resolved.modelId,
modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}),
).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
}, [entries, input.model]);
}
export function useAgentProfileFormControls(input: {
export function useAgentProfileFormFeatures(input: {
serverId: string;
model: AgentProfileFormModel;
state: AgentProfileFormState;
@@ -35,14 +35,14 @@ export function useAgentProfileFormControls(input: {
const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.controlsRequest;
const requestKey = input.state.controlsRequestKey;
const request = input.state.featureRequest;
const requestKey = input.state.featureRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
// page's data while the next request is in flight, which would hand the
// features of one provider/model combination to the key of another.
const controlsQuery = useFetchQuery({
queryKey: ["agentProfileControls", input.serverId, requestKey],
const featuresQuery = useFetchQuery({
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request),
@@ -51,28 +51,28 @@ export function useAgentProfileFormControls(input: {
if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) {
throw new Error(payload.error);
}
return { controls: payload.controls ?? [], options: payload.options ?? {} };
return payload.features ?? [];
},
});
const { data, isError } = controlsQuery;
const { data, isError } = featuresQuery;
const { model } = input;
useEffect(() => {
if (!requestKey || !data) {
return;
}
model.applyControls(requestKey, data);
model.applyFeatures(requestKey, data);
}, [data, model, requestKey]);
useEffect(() => {
if (!requestKey || !isError) {
return;
}
model.applyControlsUnavailable(requestKey);
model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]);
}
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -25,7 +24,7 @@ import type {
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormControls,
useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
@@ -141,7 +140,7 @@ function OpenAgentProfileEditModal({
const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormControls({ serverId, model, state });
useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>(
() => ({
@@ -328,17 +327,16 @@ function OpenAgentProfileEditModal({
/>
) : null}
{state.disclosure.showControlsField ? (
{state.disclosure.showFeaturesField ? (
<Field
label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field"
>
<View style={styles.featureCard} testID="agent-profile-features-card">
{state.controls.map((control, index) => (
<AgentProfileControlRow
key={control.key}
control={control}
options={state.controlOptions}
{state.features.map((feature, index) => (
<AgentProfileFeatureRow
key={feature.id}
feature={feature}
isFirst={index === 0}
disabled={state.isSubmitting}
size={controlSize}
@@ -401,16 +399,14 @@ function OpenAgentProfileEditModal({
);
}
function AgentProfileControlRow({
control,
options,
function AgentProfileFeatureRow({
feature,
isFirst,
disabled,
size,
model,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: ControlSize;
@@ -421,75 +417,74 @@ function AgentProfileControlRow({
[isFirst],
);
const handleToggle = useCallback(
(value: boolean) => model.setOption(control.key, value),
[control.key, model],
(value: boolean) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const handleSelect = useCallback(
(value: string) => model.setOption(control.key, value),
[control.key, model],
(value: string) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const selectOptions = useMemo<SelectFieldOption<string>[]>(
() =>
control.type === "select"
? control.choices.map((choice) => ({
id: choice.value,
value: choice.value,
label: choice.label,
...(choice.description ? { description: choice.description } : {}),
testID: `agent-profile-feature-option-${control.key}-${choice.value}`,
feature.type === "select"
? feature.options.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
}))
: [],
[control],
[feature],
);
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (control.type !== "select" || !selectedValue) {
if (feature.type !== "select" || !feature.value) {
return null;
}
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
return { label: choice?.label ?? selectedValue };
}, [control, selectedValue]);
const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: option?.label ?? feature.value };
}, [feature]);
if (control.type === "select") {
if (feature.type === "select") {
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField
label={control.label}
value={selectedValue}
label={feature.label}
value={feature.value}
selectedDisplay={selectedDisplay}
options={selectOptions}
onChange={handleSelect}
placeholder={control.label}
emptyText={control.label}
placeholder={feature.label}
emptyText={feature.label}
disabled={disabled}
{...(control.description ? { hint: control.description } : {})}
title={control.label}
{...(feature.description ? { hint: feature.description } : {})}
title={feature.label}
size={size}
testID={`agent-profile-feature-field-${control.key}`}
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/>
</View>
);
}
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}>
{control.label}
{feature.label}
</Text>
{control.description ? (
{feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}>
{control.description}
{feature.description}
</Text>
) : null}
</View>
<Switch
value={readToggleOption(control, options)}
value={feature.value === true}
onValueChange={handleToggle}
disabled={disabled}
accessibilityLabel={control.label}
testID={`agent-profile-feature-switch-${control.key}`}
accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${feature.id}`}
/>
</View>
);
+1 -2
View File
@@ -1304,8 +1304,7 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.options !== right.options) reasons.push("agent.options");
if (left.controls !== right.controls) reasons.push("agent.controls");
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
+48 -20
View File
@@ -1,4 +1,12 @@
import { useHardwareKeyboardPresent } from "@/keyboard";
import {
WindowLayoutProvider,
WindowLayoutActionsHost,
WindowLayoutSurface,
WindowLayoutDivisionGap,
useWindowLayout,
resolveDesktopDivisionLayout,
} from "@/window-layout";
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -59,6 +67,7 @@ import {
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
@@ -355,9 +364,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
@@ -418,6 +434,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isCompactLayout,
isMounted: desktopSidebarMounted,
isOpen: isDesktopAgentListOpen,
divisionLayout,
canShare: canDesktopAppSidebarShare({
contentMinimumWidth: appContentMinimumWidth,
requestedSidebarWidth: sidebarWidth,
@@ -441,18 +458,19 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChromeStyle = useMemo(
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
[insets.left, insets.right, isCompactLayout],
);
const workspaceChrome = (
<View style={workspaceChromeStyle}>
<WindowLayoutSurface compact={isCompactLayout} style={rowStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
<WindowLayoutDivisionGap
layout={divisionLayout}
sidebarVisible={desktopSidebarVisible}
compact={isCompactLayout}
/>
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
<WindowChromeRegion
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
@@ -460,7 +478,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</WorkspaceExplorerOpenGesture>
</View>
</WindowLayoutSurface>
);
// Native panel gesture hosts outlive appearance keys, like native navigators.
@@ -548,8 +566,7 @@ function SidebarChrome({
}) {
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
@@ -875,20 +892,31 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootOverlayProviders({ children }: { children: ReactNode }) {
return (
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>
{children}
<WindowLayoutActionsHost />
</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
);
}
function RootProviders({ children }: { children: ReactNode }) {
return (
<KeyboardActionDispatcherProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
<WindowLayoutProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<RootOverlayProviders>{children}</RootOverlayProviders>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</WindowLayoutProvider>
</KeyboardActionDispatcherProvider>
);
}
@@ -40,7 +40,16 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
cwd: "/repo",
model: "agent-model",
thinkingOptionId: "think-hard",
options: { "web-search": true, effort: "high" },
features: [
{ type: "toggle", id: "web-search", label: "Web search", value: true },
{
type: "select",
id: "effort",
label: "Effort",
value: "high",
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null,
labels: {},
...overrides,
@@ -105,7 +114,7 @@ describe("buildDraftAgentSetup", () => {
modeId: "mode-current",
model: "agent-model",
thinkingOptionId: "think-hard",
providerOptions: {
featureValues: {
"web-search": true,
effort: "high",
},
@@ -65,12 +65,17 @@ export function resolveClientSlashCommand(input: {
}
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options },
featureValues,
};
}
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import type {
AgentMode,
AgentOptionControl,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions";
import {
@@ -35,7 +31,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
thinking: TestIcon,
planMode: TestIcon,
mode: () => TestIcon,
control: () => TestIcon,
feature: () => TestIcon,
};
function provider(input: {
@@ -69,8 +65,8 @@ function provider(input: {
};
}
function toggleControl(key: string, icon?: string): AgentOptionControl {
return { type: "toggle", key, label: key, icon };
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", id, label: id, value, icon };
}
function makeSource(
@@ -79,7 +75,7 @@ function makeSource(
models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
features?: Partial<AgentControlContributionSource["features"]>;
} = {},
): AgentControlContributionSource {
return {
@@ -108,11 +104,10 @@ function makeSource(
select: () => undefined,
...overrides.modes,
},
optionControls: {
controls: [],
options: {},
onSetOption: () => undefined,
...overrides.optionControls,
features: {
list: [],
set: () => undefined,
...overrides.features,
},
};
}
@@ -138,7 +133,7 @@ describe("Command Center agent-control contributions", () => {
const contributions = buildAgentControlContributions({
...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] },
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
features: { list: [toggleFeature("plan_mode", true)] },
}),
modes: undefined,
});
@@ -328,10 +323,9 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
optionControls: {
controls: [toggleControl("plan_mode", "list-todo")],
options: { plan_mode: true },
onSetOption: (id, value) => {
features: {
list: [toggleFeature("plan_mode", true, "list-todo")],
set: (id, value) => {
calls.push([id, value]);
},
},
@@ -347,10 +341,9 @@ describe("Command Center agent-control contributions", () => {
const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions(
makeSource({
optionControls: {
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
options: { fast_mode: false, auto_accept: true },
onSetOption: (id, value) => {
features: {
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
set: (id, value) => {
calls.push([id, value]);
},
},
@@ -383,7 +376,7 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
features: { list: [toggleFeature("fast_mode")] },
}),
);
const ranks = new Map<string, number>();
@@ -1,10 +1,9 @@
import type {
AgentFeature,
AgentMode,
AgentOptionControl,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -62,7 +61,7 @@ export interface AgentControlContributionIcons {
thinking: CommandCenterIcon;
planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon;
control(control: AgentOptionControl): CommandCenterIcon;
feature(feature: AgentFeature): CommandCenterIcon;
}
interface CommandCenterChoice {
@@ -106,7 +105,10 @@ export interface AgentControlContributionSource {
defaultModeId: string | null;
select(modeId: string): void | Promise<void>;
};
optionControls: ComposerOptionControls;
features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
}
function buildContributions(
@@ -240,19 +242,18 @@ function toggleChoices(input: {
}
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls;
const planControl = controls.find(
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
const planFeature = source.features.list.find(
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
);
let choices: CommandCenterChoice[] = [];
if (planControl?.type === "toggle") {
if (planFeature?.type === "toggle") {
choices = toggleChoices({
source,
key: "plan",
icon: source.icons.control(planControl),
isOn: readToggleOption(planControl, options),
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
icon: source.icons.feature(planFeature),
isOn: planFeature.value,
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
});
} else if (source.modes) {
const modes = source.modes;
@@ -279,19 +280,18 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
}
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls;
const fastControl = controls.find(
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
const fastFeature = source.features.list.find(
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
);
const choices =
fastControl?.type === "toggle"
fastFeature?.type === "toggle"
? toggleChoices({
source,
key: "fast",
icon: source.icons.control(fastControl),
isOn: readToggleOption(fastControl, options),
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
icon: source.icons.feature(fastFeature),
isOn: fastFeature.value,
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
})
: [];
return {
@@ -1,6 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getAgentFeatureIcon,
@@ -9,7 +14,6 @@ import {
ThinkingIcon,
} from "@/agent-controls/icons";
import { useProviderIcons } from "@/components/provider-icons";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import {
buildAgentControlContributions,
@@ -40,15 +44,15 @@ export interface AgentControlCommandCenterSource {
selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>;
};
/** Absent when the surface cannot change options (the agent has no client). */
optionControls: ComposerOptionControls | null;
features: {
list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
}
const NO_OPTION_CONTROLS: ComposerOptionControls = {
controls: [],
options: {},
onSetOption: () => undefined,
};
function ignoreAgentFeatureChange(): void {
return;
}
function resolveDefaultModeId(
provider: string | null | undefined,
@@ -83,7 +87,7 @@ export function useAgentControlCommandCenterActions(input: {
}): void {
const { t } = useTranslation();
const { controls } = input;
const { optionControls, models, modes, thinking } = controls;
const { features, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo(
() =>
@@ -100,7 +104,7 @@ export function useAgentControlCommandCenterActions(input: {
getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
),
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
},
models: {
providers: models.providers,
@@ -119,7 +123,10 @@ export function useAgentControlCommandCenterActions(input: {
modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
),
optionControls: optionControls ?? NO_OPTION_CONTROLS,
features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
}),
[
getProviderIcon,
@@ -127,7 +134,8 @@ export function useAgentControlCommandCenterActions(input: {
controls.provider,
controls.providerDefinitions,
controls.serverId,
optionControls,
features.list,
features.set,
models.providers,
models.select,
models.selectedModelId,
@@ -13,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -292,7 +293,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 0,
},
trigger: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -9,6 +9,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
@@ -306,14 +307,14 @@ export function ContextWindowMeter({
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -1,13 +1,97 @@
import { describe, expect, it } from "vitest";
import {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
type WindowLayout,
} from "@/window-layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => {
const geometry: WindowLayout = {
bounds: { x: 0, y: 0, width: 951, height: 669 },
verticalBarEdge: "trailing",
verticalBar: { x: 875, y: 160, width: 44, height: 450 },
divisions: [{ x: 465, y: 0, width: 21, height: 669 }],
};
it("places workspace action symbols in the reported trailing bar", () => {
expect(resolveWorkspaceActionPlacement(geometry)).toBe("vertical-bar");
expect(resolveWorkspaceActionPlacement(null)).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "none" })).toBe(
"header",
);
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBar: null })).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "leading" })).toBe(
"header",
);
});
it("shows the tab count only for the compact header selection", () => {
expect(resolveWorkspaceBarActions("compact")).toEqual(["tab-count", "overflow", "explorer"]);
expect(resolveWorkspaceBarActions("full")).toEqual(["overflow", "explorer"]);
});
it("allocates both panels around a live division without clamping to saved sidebar widths", () => {
const input = {
geometry,
windowWidth: 951,
windowHeight: 669,
leftInset: 0,
rightInset: 84,
};
expect(resolveDesktopDivisionLayout(input)).toEqual({
sidebarWidth: 465,
gap: 21,
contentWidth: 381,
});
expect(
resolveDesktopDivisionLayout({ ...input, geometry: { ...geometry, divisions: [] } }),
).toBeNull();
expect(
resolveDesktopDivisionLayout({ ...input, windowWidth: 669, windowHeight: 951 }),
).toBeNull();
expect(resolveDesktopDivisionLayout({ ...input, geometry: null })).toBeNull();
expect(
resolveDesktopDivisionLayout({
...input,
geometry: { ...geometry, divisions: [{ x: 0, y: 325, width: 951, height: 19 }] },
}),
).toBeNull();
});
it("activates a revealed folded sidebar while preserving closed desktop intent", () => {
expect(resolveSidebarActivity(true, false, false)).toBe(true);
expect(resolveSidebarActivity(false, false, false)).toBe(false);
expect(resolveSidebarActivity(true, true, false)).toBe(false);
expect(resolveSidebarActivity(true, true, true)).toBe(true);
});
it("temporarily reveals navigation for a division without changing the saved closed intent", () => {
const visibility = {
chromeEnabled: true,
isCompactLayout: false,
isMounted: true,
isOpen: false,
canShare: false,
};
expect(
resolveDesktopSidebarVisibility({
...visibility,
divisionLayout: { sidebarWidth: 465, gap: 21, contentWidth: 381 },
}),
).toBe(true);
expect(resolveDesktopSidebarVisibility({ ...visibility, divisionLayout: null })).toBe(false);
expect(visibility.isOpen).toBe(false);
});
it("keeps a retained sidebar hidden while app chrome is suppressed", () => {
expect(
resolveDesktopSidebarVisibility({
@@ -1,6 +1,7 @@
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import type { DesktopDivisionLayout } from "@/window-layout";
const MIN_DESKTOP_CENTER_WIDTH = 400;
@@ -10,13 +11,13 @@ export function resolveDesktopSidebarVisibility(input: {
isMounted: boolean;
isOpen: boolean;
canShare: boolean;
divisionLayout?: DesktopDivisionLayout | null;
}): boolean {
return (
input.chromeEnabled &&
!input.isCompactLayout &&
input.isMounted &&
input.isOpen &&
input.canShare
(input.divisionLayout != null || (input.isOpen && input.canShare))
);
}
@@ -85,3 +86,11 @@ export function canDesktopAppSidebarShare(input: {
input.contentMinimumWidth
);
}
export function resolveSidebarActivity(
visible: boolean,
compact: boolean,
mobileActive: boolean,
): boolean {
return visible && (!compact || mobileActive);
}
+38 -23
View File
@@ -26,6 +26,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -528,6 +529,7 @@ function MobileSidebar({
insetsBottom,
closeSidebar,
}: MobileSidebarProps) {
const insets = useSafeAreaInsets();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
@@ -542,10 +544,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
style={styles.mobileCloseButton(insets.right)}
/>
),
[labels.closeSidebar, closeSidebar],
[labels.closeSidebar, closeSidebar, insets.right],
);
const mobileSidebarInsetStyle = useMemo(
@@ -666,13 +668,22 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const visibleSidebarWidth =
divisionLayout?.sidebarWidth ??
resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
@@ -687,6 +698,7 @@ function DesktopSidebar({
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(divisionLayout === null)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => {
scheduleOnRN(showResizeGrip);
@@ -708,13 +720,14 @@ function DesktopSidebar({
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
.onEnd((_event, success) => {
if (success) runOnJS(setSidebarWidth)(resizeWidth.value);
})
.onFinalize(() => {
scheduleOnRN(hideResizeGrip);
}),
[
divisionLayout,
hideResizeGrip,
resizeWidth,
setSidebarWidth,
@@ -725,7 +738,7 @@ function DesktopSidebar({
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
width: divisionLayout?.sidebarWidth ?? resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
@@ -738,10 +751,10 @@ function DesktopSidebar({
);
const desktopSidebarBorderStyle = useMemo(
() => [
styles.desktopSidebarBorder,
styles.desktopSidebarBorder(divisionLayout !== null),
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
[insetsTop, insets.bottom, divisionLayout],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -812,12 +825,14 @@ function DesktopSidebar({
handleOpenHostSettings={handleOpenHostSettings}
/>
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
{divisionLayout === null ? (
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
) : null}
</View>
</Animated.View>
);
@@ -883,14 +898,14 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButton: {
marginRight: theme.spacing[2],
},
desktopSidebarBorder: {
borderRightWidth: 1,
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
}),
sidebarDragArea: {
position: "relative",
},
@@ -1,7 +1,7 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, mapLayout, type ControlSize } from "@/components/ui/control-geometry";
import { settingsStyles } from "@/styles/settings";
@@ -49,7 +49,7 @@ const styles = StyleSheet.create((theme) => ({
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[2] * 2),
},
nestedText: {
fontSize: theme.fontSize.sm,
@@ -154,6 +154,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { bySizing } from "@/components/ui/control-geometry";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -2737,12 +2738,12 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6,
},
projectKebabButton: {
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
// Larger on touch so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px pointer box and the row at its 44px height.
width: bySizing({ touch: 32, pointer: 24 }),
height: bySizing({ touch: 32, pointer: 24 }),
marginRight: bySizing({ touch: -4, pointer: 0 }),
marginVertical: bySizing({ touch: -4, pointer: 0 }),
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -36,6 +36,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
import { bySizing } from "@/components/ui/control-geometry";
import {
workspaceServiceLabelKey,
type WorkspaceServiceSummary,
@@ -438,18 +439,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({
trigger: {
// Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
// Larger on touch so the kebab is an easy tap target.
width: bySizing({ touch: 32, pointer: 18 }),
height: bySizing({ touch: 32, pointer: 18 }),
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
marginLeft: 2,
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// Center the touch box on the 20px title line it is anchored to.
marginTop: bySizing({ touch: -6, pointer: 0 }),
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: { xs: -14, md: -7 },
marginRight: bySizing({ touch: -14, pointer: -7 }),
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
+9 -9
View File
@@ -64,6 +64,8 @@ import {
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { density, densityIconBox } from "@/styles/density";
import { DensityIcon } from "@/components/ui/density-icon";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
@@ -203,7 +205,7 @@ export function SearchInput({
return (
<View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} />
<DensityIcon icon={Search} size="md" color="foregroundMuted" />
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
@@ -250,21 +252,19 @@ export function ComboboxItem({
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles();
let leadingContent: ReactElement | null = null;
if (leadingSlot) {
leadingContent = <View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>;
} else if (kind === "directory") {
leadingContent = (
<View style={styles.comboboxItemLeadingSlot}>
<Folder size={16} color={theme.colors.foregroundMuted} />
<DensityIcon icon={Folder} size="md" color="foregroundMuted" />
</View>
);
} else if (kind === "file") {
leadingContent = (
<View style={styles.comboboxItemLeadingSlot}>
<File size={16} color={theme.colors.foregroundMuted} />
<DensityIcon icon={File} size="md" color="foregroundMuted" />
</View>
);
}
@@ -308,7 +308,7 @@ export function ComboboxItem({
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
{selected ? <DensityIcon icon={Check} size="md" color="foregroundMuted" /> : null}
</View>
{trailingSlot}
</View>
@@ -1682,7 +1682,7 @@ const styles = StyleSheet.create((theme) => ({
comboboxItem: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
minHeight: density.rowHeight,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -1713,7 +1713,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.55,
},
comboboxItemTrailingSlot: {
width: 16,
width: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
},
@@ -1733,7 +1733,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
comboboxItemLeadingSlot: {
width: 16,
width: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
},
@@ -1,11 +1,20 @@
import { describe, expect, it } from "vitest";
import {
ALERT_ICON_SIZE,
bySizing,
CONTROL_HEIGHTS,
controlHeightForLayout,
controlSizingForLayout,
createControlGeometry,
HEADER_CONTROL_HEIGHT,
ICON_BUTTON_SIZES,
iconSizeFor,
resolveControlSizing,
switchGeometryForLayout,
getControlInteractionPhase,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -47,6 +56,52 @@ const theme = {
},
} as unknown as Theme;
describe("control sizing gate", () => {
it("is touch-sized in the compact layout or on a touch screen, and pointer-sized otherwise", () => {
expect(resolveControlSizing({ compactLayout: true, touchScreen: true })).toBe("touch");
expect(resolveControlSizing({ compactLayout: true, touchScreen: false })).toBe("touch");
expect(resolveControlSizing({ compactLayout: false, touchScreen: true })).toBe("touch");
expect(resolveControlSizing({ compactLayout: false, touchScreen: false })).toBe("pointer");
});
it("gives a touch screen its touch sizes at every width", () => {
const sizes = { touch: 44, pointer: 32 };
// An iPad: touch-sized in the docked desktop layout too.
expect(bySizing(sizes, true)).toEqual({ xs: 44, md: 44 });
// A mouse-driven window: touch-sized only once it narrows into the compact layout.
expect(bySizing(sizes, false)).toEqual({ xs: 44, md: 32 });
});
it("keeps a device without a touch screen on pointer sizes from md up", () => {
// The unit runtime has no touch screen, which is the desktop that must not change.
expect(controlSizingForLayout(false)).toBe("pointer");
expect(controlSizingForLayout(true)).toBe("touch");
expect(CONTROL_HEIGHTS.md).toEqual({ xs: 44, md: 32 });
expect(CONTROL_HEIGHTS.sm).toEqual({ xs: 32, md: 28 });
expect(HEADER_CONTROL_HEIGHT).toEqual({ xs: 32, md: 26 });
});
it("follows the HIG on touch: a 44pt target, no control under 28pt, larger glyphs", () => {
expect(TOUCH_TARGET).toBe(44);
expect(controlHeightForLayout("md", true)).toBe(TOUCH_TARGET);
expect(ICON_BUTTON_SIZES.large.touch).toBe(TOUCH_TARGET);
expect(ICON_BUTTON_SIZES.small.touch).toBe(TOUCH_TARGET);
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
);
expect(controlHeightForLayout(size, false)).toBeGreaterThanOrEqual(28);
}
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(iconSizeFor(size).touch).toBeGreaterThan(iconSizeFor(size).pointer);
}
});
});
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
@@ -98,18 +153,17 @@ describe("control geometry", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toBe(28);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlSm.minHeight).toEqual({ xs: 32, md: 28 });
// The default keeps a 44 touch target in the compact layout and is 32 from the md
// breakpoint up.
expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 });
for (const layout of ["xs", "md"] as const) {
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical[layout] * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight[layout]);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical[layout] * 2 +
@@ -125,24 +179,25 @@ describe("control geometry", () => {
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toEqual(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentSm.minHeight).toBe(24);
expect(geometry.segmentedSegmentSm.minHeight).toEqual({ xs: 28, md: 24 });
expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 });
});
it("keeps one size contract across buttons, fields, and segmented controls", () => {
const geometry = createControlGeometry(theme);
expect(geometry.buttonSm.minHeight).toBe(28);
expect(geometry.buttonSm.minHeight).toEqual({ xs: 32, md: 28 });
expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.buttonSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.buttonSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(geometry.fieldControlSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(controlHeightForLayout("md", true)).toBe(44);
expect(controlHeightForLayout("md", false)).toBe(32);
expect(controlHeightForLayout("sm", true)).toBe(28);
expect(controlHeightForLayout("sm", true)).toBe(32);
expect(controlHeightForLayout("sm", false)).toBe(28);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelSm.fontSize).toBe(12);
@@ -177,13 +232,15 @@ describe("control geometry", () => {
thumbTravel: 18,
reservedHeight: 44,
});
// Tight chrome keeps the pointer-sized switch on every layout.
for (const isCompact of [true, false]) {
expect(switchGeometryForLayout("sm", isCompact)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
}
// Tight chrome keeps the pointer-sized track on every layout and reserves its row's height.
expect(switchGeometryForLayout("sm", true)).toMatchObject({
trackWidth: 34,
reservedHeight: 32,
});
expect(switchGeometryForLayout("sm", false)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
});
it("gives alert title and description one font size and a tight gap per size", () => {
@@ -1,5 +1,14 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
bySizing,
controlSizingForLayout,
mapLayout,
TOUCH_TARGET,
type ControlSizing,
type LayoutValue,
type SizingValue,
} from "./control-sizing";
/**
* Controls come in two sizes. `md` is the default and the right size almost everywhere;
@@ -26,46 +35,78 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const SMALL_CONTROL_HEIGHT = 28;
// The default control keeps a full touch target in the compact layout and tightens where a
// pointer drives it.
const DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT = 44;
const DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT = 32;
export const HEADER_CONTROL_HEIGHT = 26;
export {
bySizing,
controlSizingForLayout,
mapLayout,
resolveControlSizing,
TOUCH_TARGET,
type ControlSizing,
type ControlSizingFacts,
type LayoutValue,
type SizingValue,
} from "./control-sizing";
// Touch `md` is the iOS HIG's 44pt default control; touch `sm` stays above its 28pt minimum.
const CONTROL_SIZES = {
sm: { touch: 32, pointer: 28 },
md: { touch: TOUCH_TARGET, pointer: 32 },
} satisfies Record<ControlSize, SizingValue>;
/** Pointer-sized header controls run tighter than `sm` so they fit the 36pt header. */
const POINTER_HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_INSET = 2;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
function byLayout(compact: number, wide: number): LayoutValue {
return { xs: compact, md: wide };
}
function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return byLayout(map(value.xs), map(value.md));
}
/**
* The height of every button, field, and segmented control of a size. Exported so a row that
* hosts one of those controls can size itself from the same numbers instead of guessing a
* height the control then outgrows. `md` depends on the layout, so it only works inside a
* Unistyles stylesheet.
* height the control then outgrows. Breakpoint values, so they only work inside a Unistyles
* stylesheet.
*/
export const CONTROL_HEIGHTS = {
sm: SMALL_CONTROL_HEIGHT,
md: byLayout(DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT, DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT),
} satisfies Record<ControlSize, number | LayoutValue>;
sm: bySizing(CONTROL_SIZES.sm),
md: bySizing(CONTROL_SIZES.md),
} satisfies Record<ControlSize, LayoutValue>;
/** A framed control in a header row, such as a split button. Touch-sized it is an `sm` control. */
export const HEADER_CONTROL_HEIGHT = bySizing({
touch: CONTROL_SIZES.sm.touch,
pointer: POINTER_HEADER_CONTROL_HEIGHT,
});
/**
* Square icon-only buttons. Touch-sized, every icon button is one touch target; pointer-sized,
* header buttons are larger than toolbar buttons.
*/
export const ICON_BUTTON_SIZES = {
large: { touch: TOUCH_TARGET, pointer: POINTER_HEADER_CONTROL_HEIGHT },
small: { touch: TOUCH_TARGET, pointer: 20 },
} satisfies Record<"large" | "small", SizingValue>;
/**
* A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled
* pill. Touch-sized it is one touch target.
*/
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
*/
export type IconSize = keyof typeof ICON_SIZE;
export const TOUCH_ICON_SIZE: Record<IconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
export function iconSizeFor(size: IconSize): SizingValue {
return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] };
}
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
const height = CONTROL_HEIGHTS[size];
if (typeof height === "number") return height;
return isCompact ? height.xs : height.md;
return CONTROL_SIZES[size][controlSizingForLayout(isCompact)];
}
export const controlIconSize: Record<ControlSize, number> = {
@@ -96,7 +137,8 @@ const SWITCH_TOUCH = switchSize(44, 26, 22);
* animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint.
*/
export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry {
const track = size === "md" && isCompact ? SWITCH_TOUCH : SWITCH_POINTER;
const touchSized = controlSizingForLayout(isCompact) === "touch";
const track = size === "md" && touchSized ? SWITCH_TOUCH : SWITCH_POINTER;
return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) };
}
@@ -225,10 +267,8 @@ export function createControlGeometry(theme: Theme) {
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.sm,
fieldTextSmLineHeight,
controlBorderWidth,
paddingVertical: mapLayout(CONTROL_HEIGHTS.sm, (height) =>
fieldVerticalPadding(height, fieldTextSmLineHeight, controlBorderWidth),
),
borderRadius: theme.borderRadius.md,
};
@@ -258,7 +298,7 @@ export function createControlGeometry(theme: Theme) {
},
buttonMd: {
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: byLayout(theme.spacing[6], theme.spacing[4]),
paddingHorizontal: bySizing({ touch: theme.spacing[6], pointer: theme.spacing[4] }),
borderRadius: theme.borderRadius.full,
},
// A button with only an icon is a circle of its size's height.
@@ -314,7 +354,7 @@ export function createControlGeometry(theme: Theme) {
padding: 0,
},
segmentedSegmentSm: {
minHeight: CONTROL_HEIGHTS.sm - SEGMENTED_INSET * 2,
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height - SEGMENTED_INSET * 2),
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.full,
},
@@ -0,0 +1,68 @@
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
// The sizing gate, apart from the control sizes so that low-level modules such as
// `constants/layout` can size by it without loading the theme. Import it through
// `@/components/ui/control-geometry`, which re-exports it.
/**
* Every size comes in a touch value and a pointer value. Touch values follow Apple's Human
* Interface Guidelines for iOS and iPadOS: a 44pt default control and hit region, nothing
* smaller than its 28pt minimum. Pointer values sit one step down the same button ladder.
*/
export type ControlSizing = "touch" | "pointer";
export interface ControlSizingFacts {
/** The layout is below the `md` breakpoint. */
compactLayout: boolean;
/** The device has a touch screen; see `hasTouchScreen`. */
touchScreen: boolean;
}
/**
* The one sizing gate: touch-sized in the compact layout or on any device with a touch screen,
* pointer-sized otherwise. A touch screen keeps touch sizing when a mouse or keyboard is attached.
*/
export function resolveControlSizing({
compactLayout,
touchScreen,
}: ControlSizingFacts): ControlSizing {
if (compactLayout || touchScreen) return "touch";
return "pointer";
}
// Read once, so attaching a mouse or keyboard later never flips the sizing.
const DEVICE_HAS_TOUCH_SCREEN = hasTouchScreen();
/** The sizing on this device for a layout, for code that sizes outside a stylesheet. */
export function controlSizingForLayout(compactLayout: boolean): ControlSizing {
return resolveControlSizing({ compactLayout, touchScreen: DEVICE_HAS_TOUCH_SCREEN });
}
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
export type SizingValue = Record<ControlSizing, number>;
/**
* A size as a Unistyles breakpoint value. The compact layout always takes the touch value; from
* `md` up the device decides.
*/
export function bySizing(
sizes: SizingValue,
touchScreen: boolean = DEVICE_HAS_TOUCH_SCREEN,
): LayoutValue {
return {
xs: sizes[resolveControlSizing({ compactLayout: true, touchScreen })],
md: sizes[resolveControlSizing({ compactLayout: false, touchScreen })],
};
}
/** Derives a breakpoint value from another, such as a row as tall as its control plus padding. */
export function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return { xs: map(value.xs), md: map(value.md) };
}
/** The smallest touch target, and the size of every touch-sized icon button, row, and bar. */
export const TOUCH_TARGET = 44;
@@ -1,17 +1,24 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import {
bySizing,
controlSizingForLayout,
ICON_BUTTON_SIZES,
TOUCH_ICON_SIZE,
type SizingValue,
} from "@/components/ui/control-geometry";
import { ICON_SIZE } from "@/styles/theme";
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20;
/** Width and height of a large icon button on compact layouts. */
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
// Touch-sized, every icon button is one target with one glyph size.
const GLYPH_SIZES = {
large: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.md },
small: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.sm },
} satisfies Record<IconButtonChromeSize, SizingValue>;
export interface IconButtonChromeState {
hovered?: boolean;
@@ -22,7 +29,7 @@ export interface IconButtonChromeState {
function resolveIconButtonFrame(size: IconButtonChromeSize, compact: boolean) {
if (size === "large") return styles.large;
return compact ? styles.smallCompact : styles.small;
return controlSizingForLayout(compact) === "touch" ? styles.smallTouch : styles.small;
}
interface IconButtonChromeOptions {
@@ -92,24 +99,17 @@ export function iconButtonChromeFrameStyle(
}
export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
if (size === "large") return ICON_SIZE.md;
return compact ? 18 : 14;
return GLYPH_SIZES[size][controlSizingForLayout(compact)];
}
export function smallIconButtonChromeFrameSize(compact = false): number {
return compact ? COMPACT_SMALL_ICON_BUTTON_SIZE : SMALL_ICON_BUTTON_SIZE;
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
}
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
width: bySizing(ICON_BUTTON_SIZES.large),
height: bySizing(ICON_BUTTON_SIZES.large),
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -119,8 +119,8 @@ const styles = StyleSheet.create((theme) => ({
outlineColor: "transparent",
},
small: {
width: SMALL_ICON_BUTTON_SIZE,
height: SMALL_ICON_BUTTON_SIZE,
width: ICON_BUTTON_SIZES.small.pointer,
height: ICON_BUTTON_SIZES.small.pointer,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -129,9 +129,9 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
smallCompact: {
width: COMPACT_SMALL_ICON_BUTTON_SIZE,
height: COMPACT_SMALL_ICON_BUTTON_SIZE,
smallTouch: {
width: ICON_BUTTON_SIZES.small.touch,
height: ICON_BUTTON_SIZES.small.touch,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -1,2 +1,5 @@
/** Shared geometry for menu rows and controls that align with them. */
export const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
import { density } from "@/styles/density";
/** A menu row is a tight row: touch-sized wherever controls are (see `resolveControlSizing`). */
export const MENU_ITEM_HEIGHT = density.tightRowHeight;
@@ -2,6 +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 { TOOLBAR_CONTROL_HEIGHT } 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";
@@ -96,7 +97,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
const styles = StyleSheet.create((theme) => ({
toolbarControl: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -107,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "transparent",
},
toolbarIconOnly: {
width: 28,
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -4,7 +4,7 @@ import { StyleSheet, View } from "react-native";
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
return (
<View
style={size >= 20 ? styles.native : styles.web}
style={size >= 20 ? styles.touch : styles.pointer}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
@@ -15,14 +15,14 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode;
}
const styles = StyleSheet.create({
web: {
pointer: {
width: 16,
height: 16,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
native: {
touch: {
width: 20,
height: 20,
flexShrink: 0,
+265 -229
View File
@@ -25,13 +25,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { Settings2 } from "lucide-react-native";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CombinedModelSelector } from "@/components/combined-model-selector";
@@ -54,8 +47,7 @@ import {
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentOptionControl,
ProviderOptions,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentProvider,
@@ -63,10 +55,14 @@ import type {
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getFeatureHighlightColor,
isFeatureActive,
getFeatureTooltip,
getAgentControlHintKey,
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { DensityIcon } from "@/components/ui/density-icon";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -76,11 +72,10 @@ import {
type AgentControlCommandCenterSource,
} from "@/command-center/agent-control-registration";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isNative } from "@/constants/platform";
import {
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
type ComposerControlDensity,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
@@ -107,7 +102,6 @@ interface AgentControlOption {
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
const EMPTY_OPTIONS: ProviderOptions = {};
interface ControlledAgentControlsProps {
provider: string;
@@ -129,7 +123,8 @@ interface ControlledAgentControlsProps {
onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void;
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -156,7 +151,8 @@ export interface DraftAgentControlsProps {
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -217,8 +213,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
}
function getFeatureIconColor(
control: AgentOptionControl,
options: ProviderOptions,
feature: AgentFeature,
palette: {
blue: { 400: string };
green: { 400: string };
@@ -226,11 +221,11 @@ function getFeatureIconColor(
},
foregroundMuted: string,
): string {
if (!isControlActive(control, options)) {
if (!isFeatureActive(feature)) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(control.key)) {
switch (getFeatureHighlightColor(feature.id)) {
case "blue":
return palette.blue[400];
case "green":
@@ -248,20 +243,20 @@ function resolveHasAnyControl({
providerOptions,
canSelectModel,
thinkingOptions,
controls,
features,
hasMode,
}: {
providerOptions: AgentControlOption[] | undefined;
canSelectModel: boolean;
thinkingOptions: AgentControlOption[] | undefined;
controls: readonly AgentOptionControl[] | undefined;
features: AgentFeature[] | undefined;
hasMode: boolean;
}) {
return (
Boolean(providerOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length) ||
Boolean(controls?.length) ||
Boolean(features?.length) ||
hasMode
);
}
@@ -399,8 +394,7 @@ type AgentControlsSlice = {
cwd: string | null;
runtimeModelId: string | null;
model: string | null | undefined;
options: ProviderOptions | undefined;
controls: AgentOptionControl[] | undefined;
features: AgentFeature[] | undefined;
runtimeThinkingOptionId: string | null;
thinkingOptionId: string | null | undefined;
lastUsage: unknown;
@@ -420,8 +414,7 @@ function selectAgentControlsSlice(
cwd: currentAgent.cwd,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
options: currentAgent.options,
controls: currentAgent.controls,
features: currentAgent.features,
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
@@ -468,6 +461,10 @@ function buildAgentProviderModels(
return map;
}
function densityFolds(density: ComposerControlDensity): boolean {
return resolveComposerControlPresentation(density).foldControls;
}
function buildOpenChangeHandler(
selector: AgentControlSelector,
setOpenSelector: (next: AgentControlSelector | null) => void,
@@ -501,7 +498,8 @@ function ControlledAgentControls({
onEditAgentProfiles,
onCreateAgentProfile,
onEditAgentProfile,
optionControls,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
@@ -510,14 +508,20 @@ function ControlledAgentControls({
modelSelectorServerId = null,
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
// Control sizes follow the window layout, as the toolbar's stylesheet does. A narrow pane in a
// wide window (`isCompactLayout`) changes which labels show, never how large a control is.
const toolbarSizing = controlSizingForLayout(isCompactFormFactor);
const toolbarGeometry = useMemo(() => composerToolbarGeometry(toolbarSizing), [toolbarSizing]);
const { fontScale } = useWindowDimensions();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full";
// A compact composer always folds its controls into the model sheet; the width budget only
// decides whether the model keeps its label.
const minimumDensity: ComposerControlDensity = isCompact ? "compact" : "full";
const initialDensity = minimumDensity;
const [density, setDensity] = useState<ComposerControlDensity>(initialDensity);
const densityRef = useRef<ComposerControlDensity>(initialDensity);
const availableWidthRef = useRef(0);
@@ -553,21 +557,20 @@ function ControlledAgentControls({
providerOptions,
canSelectModel,
thinkingOptions,
controls: optionControls?.controls,
features,
hasMode: modeControl !== null && modeControl !== undefined,
});
const featureControls = useMemo(
() =>
(optionControls?.controls ?? []).map((control) => {
if (control.type === "toggle") return { type: "toggle" as const };
const selected = readSelectOption(control, optionControls?.options ?? {});
const choice = control.choices.find((candidate) => candidate.value === selected);
(features ?? []).map((feature) => {
if (feature.type === "toggle") return { type: "toggle" as const };
const selectedOption = feature.options.find((option) => option.id === feature.value);
return {
type: "select" as const,
label: choice?.label ?? control.label,
label: selectedOption?.label ?? feature.label,
};
}),
[optionControls],
[features],
);
const controlPresence = useMemo(
() => ({
@@ -582,10 +585,10 @@ function ControlledAgentControls({
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
() => ({
glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"),
glyphSize: toolbarGeometry.glyphSize,
presentation,
}),
[presentation],
[presentation, toolbarGeometry],
);
const updateDensityForWidth = useCallback(
@@ -594,12 +597,20 @@ function ControlledAgentControls({
availableWidth,
currentDensity: densityRef.current,
controls: controlPresence,
geometry: toolbarGeometry,
minimumDensity,
});
if (nextDensity === densityRef.current) return;
// Folding swaps the toolbar controls for the model sheet and back, so whatever menu or
// sheet was open belonged to controls that are gone.
if (densityFolds(nextDensity) !== densityFolds(densityRef.current)) {
setOpenSelector(null);
setActiveSheet(null);
}
densityRef.current = nextDensity;
setDensity(nextDensity);
},
[controlPresence],
[controlPresence, minimumDensity, toolbarGeometry],
);
const handleLayout = useCallback(
@@ -641,10 +652,9 @@ function ControlledAgentControls({
selected={args.selected}
active={args.active}
onPress={args.onPress}
iconColor={theme.colors.foreground}
/>
),
[theme.colors.foreground],
[],
);
const handleOpenChange = useCallback(
@@ -710,8 +720,8 @@ function ControlledAgentControls({
const handleCloseSheet = useCallback(() => {
setActiveSheet(null);
if (!isCompact) onDropdownClose?.();
}, [isCompact, onDropdownClose]);
if (!presentation.foldControls) onDropdownClose?.();
}, [onDropdownClose, presentation.foldControls]);
const handleSelectThinkingAndClose = useCallback(
(thinkingOptionId: string) => {
@@ -742,7 +752,7 @@ function ControlledAgentControls({
return (
<ComposerControlLayoutProvider value={layoutContextValue}>
<View style={styles.container} onLayout={handleLayout}>
{!isCompact ? (
{!presentation.foldControls ? (
<DesktopAgentControlsContent
provider={provider}
providerOptions={providerOptions}
@@ -751,7 +761,8 @@ function ControlledAgentControls({
selectedModelId={selectedModelId}
thinkingOptions={formattedThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls}
features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile}
@@ -799,7 +810,8 @@ function ControlledAgentControls({
provider={provider}
selectedModelId={selectedModelId}
selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls}
features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile}
@@ -827,6 +839,7 @@ function ControlledAgentControls({
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
glyphSize={layoutContextValue.glyphSize}
showLabels={presentation.showModelLabel}
modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
@@ -844,7 +857,8 @@ interface DesktopAgentControlsContentProps {
selectedModelId?: string;
thinkingOptions?: AgentControlOption[];
selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -905,7 +919,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
selectedModelId,
thinkingOptions,
selectedThinkingOptionId,
optionControls,
features,
onSetFeature,
onApplyAgentProfile,
onEditAgentProfiles,
onCreateAgentProfile,
@@ -1061,7 +1076,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
{presentation.aggregateFeatures && optionControls?.controls.length ? (
{presentation.aggregateFeatures && features?.length ? (
<>
<Pressable
onPress={handleOpenFeatures}
@@ -1081,29 +1096,27 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onClose={handleCloseSheet}
testID="agent-features-sheet"
>
{optionControls.controls.map((control) => (
{features.map((feature) => (
<SheetFeatureItem
key={`feature-${control.key}`}
control={control}
options={optionControls.options}
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleNestedOpenChange}
onSetOption={optionControls.onSetOption}
onSetFeature={onSetFeature}
/>
))}
</AdaptiveModalSheet>
</>
) : (
optionControls?.controls.map((control) => (
features?.map((feature) => (
<DesktopFeatureItem
key={`feature-${control.key}`}
control={control}
options={optionControls.options}
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleOpenChange}
onSetOption={optionControls.onSetOption}
onSetFeature={onSetFeature}
onActionComplete={onDropdownClose}
/>
))
@@ -1116,7 +1129,8 @@ interface SheetAgentControlsContentProps {
provider: string;
selectedModelId?: string;
selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -1149,6 +1163,7 @@ interface SheetAgentControlsContentProps {
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
glyphSize: number;
showLabels: boolean;
modelSelectorServerId: string | null;
canSwitchProvider: boolean;
}
@@ -1159,7 +1174,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
provider,
selectedModelId,
selectedThinkingOptionId,
optionControls,
features,
onSetFeature,
onApplyAgentProfile,
onEditAgentProfiles,
onCreateAgentProfile,
@@ -1187,12 +1203,12 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
renderThinkingOption,
modeControl,
glyphSize,
showLabels,
modelSelectorServerId,
canSwitchProvider,
} = props;
const thinkingAnchorRef = useRef<View | null>(null);
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
const hasThinking = comboboxThinkingOptions.length > 0;
@@ -1243,15 +1259,14 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
{(optionControls?.controls ?? []).map((control) => (
{(features ?? []).map((feature) => (
<SheetFeatureItem
key={`feature-${control.key}`}
control={control}
options={sheetOptions}
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleOpenChange}
onSetOption={optionControls?.onSetOption}
onSetFeature={onSetFeature}
/>
))}
</View>
@@ -1277,6 +1292,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
glyphSize={glyphSize}
showLabels={showLabels}
canSwitchProvider={canSwitchProvider}
>
{sheetControls}
@@ -1285,24 +1301,22 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
}
function DesktopFeatureItem({
control,
options,
feature,
disabled,
openSelector,
handleOpenChange,
onSetOption,
onSetFeature,
onActionComplete,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
disabled: boolean;
openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void;
onSetFeature?: (featureId: string, value: unknown) => void;
onActionComplete?: () => void;
}) {
const { theme } = useUnistyles();
const featureSelector: AgentControlSelector = `feature-${control.key}`;
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo(
@@ -1315,117 +1329,120 @@ function DesktopFeatureItem({
);
const handleTogglePress = useCallback(() => {
if (control.type === "toggle") {
onSetOption?.(control.key, !readToggleOption(control, options));
if (feature.type === "toggle") {
onSetFeature?.(feature.id, !feature.value);
onActionComplete?.();
}
}, [control, onActionComplete, onSetOption, options]);
}, [feature, onActionComplete, onSetFeature]);
const handleSelectOption = useCallback(
(choiceValue: string) => {
onSetOption?.(control.key, choiceValue);
(optionId: string) => {
onSetFeature?.(feature.id, optionId);
},
[control.key, onSetOption],
[feature.id, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(
() =>
control.type === "select"
? control.choices.map((choice) => ({ id: choice.value, label: choice.label }))
feature.type === "select"
? feature.options.map((option) => ({ id: option.id, label: option.label }))
: [],
[control],
[feature],
);
const FeatureIcon = getAgentFeatureIcon(control.icon);
const iconColor = getFeatureIconColor(
control,
options,
theme.colors.palette,
theme.colors.foregroundMuted,
);
if (control.type === "toggle") {
if (feature.type === "toggle") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
icon={FeatureIcon}
iconColor={iconColor}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={control.label}
label={feature.label}
showToolbarLabel={false}
disabled={disabled}
onPress={handleTogglePress}
accessibilityLabel={getControlTooltip(control)}
testID={`agent-feature-${control.key}`}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
const selectedValue = readSelectOption(control, options);
const selectedChoice = control.choices.find((choice) => choice.value === selectedValue);
const iconOnly = control.desktopTrigger === "icon";
const tooltip = iconOnly
? `${control.label}: ${selectedChoice?.label ?? control.label}`
: getControlTooltip(control);
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={iconColor}
surface="toolbar"
label={control.label}
value={selectedChoice?.label ?? control.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={tooltip}
testID={`agent-feature-${control.key}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxOptions}
value={String(selectedValue)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
desktopPlacement="top-start"
/>
</>
);
if (feature.type === "select") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
const iconOnly = feature.desktopTrigger === "icon";
const tooltip = iconOnly
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
: getFeatureTooltip(feature);
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={tooltip}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
desktopPlacement="top-start"
/>
</>
);
}
return null;
}
function SheetFeatureItem({
control,
options,
feature,
disabled,
openSelector,
handleOpenChange,
onSetOption,
onSetFeature,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
disabled: boolean;
openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void;
onSetFeature?: (featureId: string, value: unknown) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${control.key}`;
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo(
@@ -1436,71 +1453,97 @@ function SheetFeatureItem({
() => handleFeatureOpenChange(openSelector !== featureSelector),
[featureSelector, handleFeatureOpenChange, openSelector],
);
const sheetHeader = useMemo<SheetHeader>(() => ({ title: control.label }), [control.label]);
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
const handleSelectOption = useCallback(
(choiceValue: string) => {
onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue);
(optionId: string) => {
onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
},
[control.key, control.type, onSetOption],
[feature.id, feature.type, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
if (control.type === "select") {
return control.choices.map((choice) => ({ id: choice.value, label: choice.label }));
if (feature.type === "select") {
return feature.options.map((option) => ({ id: option.id, label: option.label }));
}
return [
{ id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") },
];
}, [control, t]);
}, [feature, t]);
const FeatureIcon = getAgentFeatureIcon(control.icon);
const iconColor = getFeatureIconColor(
control,
options,
theme.colors.palette,
theme.colors.foregroundMuted,
);
let selectedValue: string;
let displayValue: string;
if (control.type === "toggle") {
const isOn = readToggleOption(control, options);
selectedValue = String(isOn);
displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off");
} else {
const selected = readSelectOption(control, options);
selectedValue = String(selected);
displayValue =
control.choices.find((choice) => choice.value === selected)?.label ?? control.label;
if (feature.type === "toggle") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
return (
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
searchable={false}
header={sheetHeader}
/>
</>
);
}
return (
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={iconColor}
surface="sheet"
label={control.label}
value={displayValue}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getControlTooltip(control)}
testID={`agent-feature-${control.key}`}
/>
<Combobox
options={comboboxOptions}
value={selectedValue}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
searchable={control.type === "toggle" ? false : undefined}
header={sheetHeader}
/>
</>
);
if (feature.type === "select") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={selectedOption?.label ?? feature.label}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
header={sheetHeader}
/>
</>
);
}
return null;
}
function ThinkingComboboxOption({
@@ -1508,15 +1551,16 @@ function ThinkingComboboxOption({
selected,
active,
onPress,
iconColor,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
iconColor: string;
}) {
const leadingSlot = useMemo(() => <ThinkingIcon size={16} color={iconColor} />, [iconColor]);
const leadingSlot = useMemo(
() => <DensityIcon icon={ThinkingIcon} size="md" color="foreground" />,
[],
);
return (
<ComboboxItem
label={option.label}
@@ -1528,18 +1572,6 @@ function ThinkingComboboxOption({
);
}
function useAgentOptionControls(
agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined,
onSetOption: ComposerOptionControls["onSetOption"],
): ComposerOptionControls {
const controls = agent?.controls;
const options = agent?.options;
return useMemo(
() => ({ controls: controls ?? [], options: options ?? EMPTY_OPTIONS, onSetOption }),
[controls, onSetOption, options],
);
}
export const AgentControls = memo(function AgentControls({
agentId,
serverId,
@@ -1691,8 +1723,8 @@ export const AgentControls = memo(function AgentControls({
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
);
const handleSetOption = useCallback(
(key: string, value: unknown) => {
const handleSetFeature = useCallback(
(featureId: string, value: unknown) => {
if (!client || !agentProvider) {
return;
}
@@ -1701,24 +1733,22 @@ export const AgentControls = memo(function AgentControls({
preferences: current,
provider: agentProvider,
updates: {
options: {
[key]: value,
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[AgentControls] persist option preference failed", error);
console.warn("[AgentControls] persist feature preference failed", error);
});
void client.setAgentOptions(agentId, { [key]: value }).catch((error) => {
console.warn("[AgentControls] setAgentOptions failed", error);
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentControls] setAgentFeature failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, agentProvider, client, toast, updatePreferences],
);
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
() => ({
serverId,
@@ -1737,18 +1767,21 @@ export const AgentControls = memo(function AgentControls({
select: handleSelectThinkingOption,
},
modes: commandCenterModes,
optionControls: client ? agentOptionControls : null,
features: {
list: agent?.features,
set: handleSetFeature,
},
}),
[
activeModelId,
agentOptionControls,
client,
agent?.features,
agentId,
agentModelSelectorProviders,
agentProvider,
commandCenterModes,
handleSelectCommandCenterModel,
handleSelectThinkingOption,
handleSetFeature,
modeProviderDefinitions,
modelSelection.selectedThinkingId,
modelSelection.thinkingOptions,
@@ -1797,7 +1830,8 @@ export const AgentControls = memo(function AgentControls({
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption}
optionControls={agentOptionControls}
features={agent.features}
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider}
@@ -1829,7 +1863,8 @@ export function DraftAgentControls({
selectedThinkingOptionId,
onSelectThinkingOption,
onApplyAgentProfile,
optionControls,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
@@ -1915,7 +1950,8 @@ export function DraftAgentControls({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
optionControls={optionControls}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
@@ -1940,7 +1976,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
modeBadge: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
@@ -1958,8 +1994,8 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
modeIconBadge: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -1,5 +1,8 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
import {
resolveComposerControlPresentation,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
interface ComposerControlLayoutValue {
glyphSize: number;
@@ -8,12 +11,7 @@ interface ComposerControlLayoutValue {
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
glyphSize: 16,
presentation: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
},
presentation: resolveComposerControlPresentation("full"),
};
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
@@ -1,30 +1,42 @@
import { describe, expect, it } from "vitest";
import {
COMPOSER_TOOLBAR_GEOMETRY,
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
} from "./layout";
const POINTER = composerToolbarGeometry("pointer");
const TOUCH = composerToolbarGeometry("touch");
describe("composer control layout", () => {
it("removes labels in priority order as the toolbar narrows", () => {
it("removes labels, then folds controls, then drops the model label as the toolbar narrows", () => {
expect(resolveComposerControlPresentation("full")).toEqual({
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("condensed")).toEqual({
expect(resolveComposerControlPresentation("condensed")).toMatchObject({
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
});
expect(resolveComposerControlPresentation("tight")).toEqual({
showCarets: false,
showThinkingLabel: false,
expect(resolveComposerControlPresentation("tight")).toMatchObject({
showModeLabel: false,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("compact")).toMatchObject({
foldControls: true,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("iconOnly")).toMatchObject({
foldControls: true,
showModelLabel: false,
});
});
@@ -41,6 +53,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 420,
currentDensity: "full",
geometry: POINTER,
controls,
}),
).toBe("full");
@@ -48,6 +61,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 380,
currentDensity: "full",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -55,6 +69,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 290,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -62,6 +77,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 280,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("tight");
@@ -69,6 +85,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 300,
currentDensity: "tight",
geometry: POINTER,
controls,
}),
).toBe("tight");
@@ -76,6 +93,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 312,
currentDensity: "tight",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -85,6 +103,7 @@ describe("composer control layout", () => {
const base = {
availableWidth: 430,
currentDensity: "condensed" as const,
geometry: POINTER,
};
expect(
@@ -129,6 +148,7 @@ describe("composer control layout", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
geometry: POINTER,
controls: {
hasModel: true,
hasThinking: true,
@@ -154,15 +174,97 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("gives every toolbar control one shell and one platform glyph envelope", () => {
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
it("gives every toolbar control one shell and one glyph per control sizing", () => {
expect(POINTER).toEqual({
controlSize: 28,
glyphSize: 16,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
});
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 420,
currentDensity: "full" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
},
};
expect(resolveComposerControlDensity({ ...input, geometry: POINTER })).toBe("full");
expect(resolveComposerControlDensity({ ...input, geometry: TOUCH })).toBe("condensed");
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide
// with an empty draft and 224 once an attachment brings in the send button.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
fontScale: 1,
};
it("keeps the model family label with icon-only controls on an empty draft", () => {
expect(
resolveComposerControlDensity({
availableWidth: 272,
currentDensity: "full",
geometry: TOUCH,
controls,
}),
).toBe("tight");
});
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
expect(
resolveComposerControlDensity({
availableWidth: 224,
currentDensity: "tight",
geometry: TOUCH,
controls,
}),
).toBe("compact");
});
it("drops the model label only after the controls are folded", () => {
expect(
resolveComposerControlDensity({
availableWidth: 120,
currentDensity: "compact",
geometry: TOUCH,
controls,
}),
).toBe("iconOnly");
});
});
it("starts a compact composer at the folded toolbar however wide it is", () => {
const input = {
currentDensity: "compact" as const,
geometry: TOUCH,
minimumDensity: "compact" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
},
};
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
// The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits.
expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly");
});
});
@@ -1,4 +1,23 @@
export type ComposerControlDensity = "full" | "condensed" | "tight";
import {
iconSizeFor,
TOOLBAR_CONTROL_SIZES,
type ControlSizing,
} from "@/components/ui/control-geometry";
/**
* How much of the toolbar shows, widest first. Each step drops something before the next:
* carets and labels, then the mode label, then the thinking, mode and feature controls fold into
* the model control's sheet (`compact`, the phone toolbar), then the model's own label.
*/
export type ComposerControlDensity = "full" | "condensed" | "tight" | "compact" | "iconOnly";
const DENSITY_LADDER: readonly ComposerControlDensity[] = [
"full",
"condensed",
"tight",
"compact",
"iconOnly",
];
export interface ComposerControlPresence {
hasModel: boolean;
@@ -15,27 +34,46 @@ export interface ComposerControlPresentation {
showThinkingLabel: boolean;
showModeLabel: boolean;
aggregateFeatures: boolean;
/** Thinking, mode and features live in the model control's sheet. */
foldControls: boolean;
showModelLabel: boolean;
}
export const COMPOSER_TOOLBAR_GEOMETRY = {
controlSize: 28,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
} as const;
export interface ComposerToolbarGeometry {
controlSize: number;
glyphSize: number;
controlGap: number;
iconLabelGap: number;
labelPadding: number;
caretSize: number;
}
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
return {
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
glyphSize: iconSizeFor("md")[sizing],
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
};
}
const DENSITY_HYSTERESIS = 12;
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
/** A thinking label beside the folded model label, such as "Medium". */
const THINKING_LABEL_WIDTH = 48;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
}
function sumControlWidths(widths: number[]): number {
function sumControlWidths(widths: number[], geometry: ComposerToolbarGeometry): number {
if (widths.length === 0) return 0;
return (
widths.reduce((total, width) => total + width, 0) +
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * geometry.controlGap
);
}
@@ -43,92 +81,173 @@ function estimateLabelWidth(label: string, fontScale: number): number {
return Array.from(label).length * 7 * fontScale;
}
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
function labelledChrome(geometry: ComposerToolbarGeometry): number {
return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap;
}
function resolveFeatureControlWidth(
feature: ComposerFeatureControlPresence,
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
if (feature.type === "toggle") return geometry.controlSize;
return (
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
geometry.controlSize +
geometry.iconLabelGap +
geometry.labelPadding * 2 +
estimateLabelWidth(feature.label, fontScale)
);
}
function resolveCondensedFloor(controls: ComposerControlPresence): number {
function resolveCondensedFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const widths: number[] = [];
if (controls.hasModel) widths.push(36 + 60 * fontScale);
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.hasMode) widths.push(36 + 96 * fontScale);
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
return sumControlWidths(widths);
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) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
function resolveFullFloor(controls: ComposerControlPresence): number {
function resolveFullFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const caret = geometry.caretSize;
const widths: number[] = [];
if (controls.hasModel) widths.push(50 + 70 * fontScale);
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
if (controls.hasMode) widths.push(54 + 96 * fontScale);
if (controls.hasModel) widths.push(chrome + caret + 70 * fontScale);
if (controls.hasThinking) widths.push(chrome + geometry.iconLabelGap + caret + 48 * fontScale);
if (controls.hasMode) widths.push(chrome + geometry.iconLabelGap + caret + 96 * fontScale);
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale));
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
}
return sumControlWidths(widths);
return sumControlWidths(widths, geometry);
}
/** Model at its family label, with thinking, mode and features as icon-only squares. */
function resolveTightFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const widths: number[] = [];
if (controls.hasModel) {
widths.push(labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale);
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
/** One model control carrying the model family label and the thinking label. */
function resolveCompactFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const thinkingLabel = controls.hasThinking
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
: 0;
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
}
function resolveDensityFloor(
density: ComposerControlDensity,
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
switch (density) {
case "full":
return resolveFullFloor(controls, geometry);
case "condensed":
return resolveCondensedFloor(controls, geometry);
case "tight":
return resolveTightFloor(controls, geometry);
case "compact":
return resolveCompactFloor(controls, geometry);
case "iconOnly":
return 0;
}
}
/**
* The widest density whose floor fits `availableWidth`. Widening past the current density needs
* the floor plus the hysteresis and keeping it tolerates the floor minus it, so a width near a
* floor does not flip back and forth. `minimumDensity` is the widest density allowed.
*/
export function resolveComposerControlDensity(input: {
availableWidth: number;
currentDensity: ComposerControlDensity;
controls: ComposerControlPresence;
geometry: ComposerToolbarGeometry;
minimumDensity?: ComposerControlDensity;
}): ComposerControlDensity {
const fullFloor = resolveFullFloor(input.controls);
const condensedFloor = resolveCondensedFloor(input.controls);
if (input.currentDensity === "full") {
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
const current = DENSITY_LADDER.indexOf(input.currentDensity);
const first = DENSITY_LADDER.indexOf(input.minimumDensity ?? "full");
for (let step = first; step < DENSITY_LADDER.length; step += 1) {
const density = DENSITY_LADDER[step];
const floor = resolveDensityFloor(density, input.controls, input.geometry);
let required = floor;
if (step < current) required = floor + DENSITY_HYSTERESIS;
if (step === current) required = floor - DENSITY_HYSTERESIS;
if (input.availableWidth >= required) return density;
}
if (input.currentDensity === "condensed") {
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
return "condensed";
}
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
return "tight";
return "iconOnly";
}
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
if (density === "full") {
return {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
};
}
if (density === "condensed") {
return {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
};
}
return {
const PRESENTATIONS: Record<ComposerControlDensity, ComposerControlPresentation> = {
full: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
},
condensed: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
},
tight: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
};
}
foldControls: false,
showModelLabel: true,
},
compact: {
showCarets: false,
showThinkingLabel: true,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: true,
},
iconOnly: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: false,
},
};
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
return platform === "native" ? 20 : 16;
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
return PRESENTATIONS[density];
}
@@ -27,6 +27,7 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { getAgentModeIcon, getAgentModeOptionIcon } from "@/agent-controls/icons";
import { DensityIcon } from "@/components/ui/density-icon";
interface ModeComboboxOptionProps {
option: ComboboxOption;
selected: boolean;
@@ -34,7 +35,6 @@ interface ModeComboboxOptionProps {
onPress: () => void;
provider: string;
providerDefinitions: AgentProviderDefinition[];
iconColor: string;
}
function ModeComboboxOption({
@@ -44,12 +44,12 @@ function ModeComboboxOption({
onPress,
provider,
providerDefinitions,
iconColor,
}: ModeComboboxOptionProps) {
const IconComponent = getAgentModeOptionIcon(provider, option.id, providerDefinitions);
const leadingSlot = useMemo(
() => (IconComponent ? <IconComponent size={16} color={iconColor} /> : null),
[IconComponent, iconColor],
() =>
IconComponent ? <DensityIcon icon={IconComponent} size="md" color="foreground" /> : null,
[IconComponent],
);
return (
<ComboboxItem
@@ -171,10 +171,9 @@ export function AgentModeControl({
onPress={args.onPress}
provider={provider}
providerDefinitions={providerDefinitions}
iconColor={theme.colors.foreground}
/>
),
[provider, providerDefinitions, theme.colors.foreground],
[provider, providerDefinitions],
);
const sheetHeader = useMemo<SheetHeader>(
@@ -15,6 +15,7 @@ import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
@@ -42,6 +43,8 @@ interface CompactModelSheetProps {
disabled?: boolean;
serverId?: string | null;
glyphSize: number;
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
showLabels: boolean;
canSwitchProvider: boolean;
children: ReactNode;
}
@@ -94,6 +97,7 @@ export function CompactModelSheet({
disabled = false,
serverId = null,
glyphSize,
showLabels,
canSwitchProvider,
children,
}: CompactModelSheetProps) {
@@ -229,14 +233,16 @@ export function CompactModelSheet({
open();
}, [close, isOpen, open]);
const iconOnly = !showLabels && hasSelectedProvider;
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
iconOnly && styles.triggerIconOnly,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, isOpen],
[disabled, iconOnly, isOpen],
);
const mobileRootContent = useMemo(
() => (
@@ -280,16 +286,18 @@ export function CompactModelSheet({
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
{!iconOnly ? (
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
) : null}
</View>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
) : null}
</ComboboxTrigger>
<AdaptiveModalSheet
@@ -374,7 +382,7 @@ export function CompactModelSheet({
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -384,6 +392,12 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
triggerIconOnly: {
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey,
isFeatureActive,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
@@ -15,12 +17,51 @@ describe("getAgentControlHintKey", () => {
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
it("treats a select feature as active when a non-default option is selected", () => {
const speed = {
type: "select" as const,
id: "service_tier",
label: "Speed",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
};
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
expect(
isFeatureActive({
type: "toggle",
id: "fast_mode",
label: "Fast",
value: true,
}),
).toBe(true);
});
});
describe("normalizeModelId", () => {
@@ -1,4 +1,4 @@
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -35,6 +35,16 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function isFeatureActive(feature: AgentFeature): boolean {
if (feature.type === "toggle") return feature.value;
const selectedOption = feature.options.find((option) => option.id === feature.value);
return selectedOption !== undefined && !selectedOption.isDefault;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case FAST_MODE_FEATURE_ID:
@@ -20,11 +20,12 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
optionControls?: DraftAgentControlsProps["optionControls"];
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps {
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
@@ -42,7 +43,8 @@ export function buildDraftAgentControls(input: {
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
optionControls,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
+25 -16
View File
@@ -8,7 +8,7 @@ import {
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
buildDraftAgentControls,
hasDraftContent,
@@ -35,7 +35,7 @@ type AttachmentUpdater =
interface AgentInputDraftComposerOptions {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>;
initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean;
lockedWorkingDir?: string;
}
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModeId: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget;
};
@@ -274,25 +274,26 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
);
const {
optionControls: draftOptionControls,
options: draftOptions,
applyProfile: applyProfileOptions,
} = useDraftProviderOptions({
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
applyProfileFeatureValues,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
cwd: workingDir,
modeId: formState.selectedMode,
modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId,
initialOptions: composerOptions?.initialProviderOptions,
initialFeatureValues: composerOptions?.initialFeatureValues,
});
const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile);
applyProfileOptions({ provider: profile.provider, options: profile.options });
applyProfileFeatureValues(profile.featureValues);
},
[applyProfileOptions, formState],
[applyProfileFeatureValues, formState],
);
const commandDraft = useMemo(
@@ -302,9 +303,15 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
featureValues: draftFeatureValues,
}),
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir],
[
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
],
);
const composerState = useMemo<DraftComposerState | null>(() => {
@@ -318,10 +325,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
featureValues: draftFeatureValues,
agentControls: buildDraftAgentControls({
formState,
optionControls: draftOptionControls,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraft,
@@ -332,10 +340,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
draftOptionControls,
draftOptions,
draftFeatures,
draftFeatureValues,
applyDraftAgentProfile,
formState,
setDraftFeatureValue,
workingDir,
]);
@@ -1,4 +1,3 @@
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -66,7 +65,7 @@ interface AutoSubmitConfig {
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
providerOptions: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
function resolveAutoSubmitConfig(
@@ -75,7 +74,7 @@ function resolveAutoSubmitConfig(
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
} | null,
): AutoSubmitConfig | null {
if (!pending) return null;
@@ -84,7 +83,7 @@ function resolveAutoSubmitConfig(
modeId: pending.modeId ?? null,
model: pending.model ?? null,
thinkingOptionId: pending.thinkingOptionId ?? null,
providerOptions: pending.providerOptions ?? {},
featureValues: pending.featureValues ?? {},
};
}
@@ -132,7 +131,7 @@ async function submitDraftCreateRequest(input: {
effectiveModeId: string;
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined;
featureValues: Record<string, unknown> | undefined;
};
hostDisconnectedMessage: string;
selectModelMessage: string;
@@ -171,7 +170,7 @@ async function submitDraftCreateRequest(input: {
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions,
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
@@ -205,7 +204,7 @@ function buildDraftAgentSnapshot(input: {
effectiveThinkingOptionId: string | null;
effectiveModeId: string;
selectedProvider: string | null;
agentControls: { optionControls?: ComposerOptionControls };
agentControls: { features?: Agent["features"] };
};
selectModelMessage: string;
}): Agent {
@@ -242,8 +241,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent",
cwd: workspaceDirectory,
model,
options: composerState.agentControls.optionControls?.options,
controls: composerState.agentControls.optionControls?.controls,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
@@ -335,7 +333,7 @@ export function WorkspaceDraftAgentTab({
composer: {
initialServerId: serverId,
initialValues: draftInitialValues,
initialProviderOptions: draftSetup?.providerOptions,
initialFeatureValues: draftSetup?.featureValues,
isVisible: true,
lockedWorkingDir: draftWorkingDirectory ?? undefined,
},
@@ -353,7 +351,8 @@ export function WorkspaceDraftAgentTab({
const draftModeOptions = composerState.modeOptions;
const draftSelectedMode = composerState.selectedMode;
const draftSetMode = composerState.setModeFromUser;
const draftOptionControls = composerState.agentControls.optionControls;
const draftFeatures = composerState.agentControls.features;
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear;
const replaceDraftText = draftInput.replaceText;
@@ -522,7 +521,10 @@ export function WorkspaceDraftAgentTab({
selectedId: draftSelectedMode,
select: draftSetMode,
},
optionControls: draftOptionControls ?? null,
features: {
list: draftFeatures,
set: draftOnSetFeature,
},
},
});
const isReadyForPendingAutoSubmit = Boolean(
+14 -14
View File
@@ -31,6 +31,11 @@ import {
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
@@ -193,10 +198,6 @@ const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {}
const noopCallback = () => {};
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
}
function resolveIsComposerLocked(
submitBehavior: "clear" | "preserve-and-lock",
isSubmitLoading: boolean,
@@ -1281,7 +1282,6 @@ function ComposerContentImpl({
}: ComposerContentProps) {
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1310,6 +1310,7 @@ function ComposerContentImpl({
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
@@ -2081,7 +2082,6 @@ function ComposerContentImpl({
],
);
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const beforeVoiceContent = useMemo(
() =>
hasAgent ? (
@@ -2092,7 +2092,7 @@ function ComposerContentImpl({
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
glyphSize={contextWindowMeterGlyphSize}
glyphSize={buttonIconSize}
/>
</View>
) : null,
@@ -2103,7 +2103,7 @@ function ComposerContentImpl({
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
contextWindowMeterGlyphSize,
buttonIconSize,
],
);
@@ -2598,8 +2598,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[3],
},
cancelButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
@@ -2612,15 +2612,15 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
+18 -11
View File
@@ -27,7 +27,12 @@ import {
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
@@ -1282,7 +1287,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const controlSizing = controlSizingForLayout(isCompact);
const buttonIconSize = iconSizeFor("md")[controlSizing];
const toast = useToast();
const voice = useVoiceOptional();
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
@@ -2235,6 +2241,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
controlSizing={controlSizing}
sendIconSize={buttonIconSize}
surface={textSurface}
toolbar={toolbar}
@@ -2384,21 +2391,21 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
attachButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -2407,8 +2414,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
@@ -2417,7 +2424,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
sendButtonLabeled: {
width: "auto",
minWidth: 28,
minWidth: TOOLBAR_CONTROL_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
@@ -6,14 +6,16 @@ import { Button } from "@/components/ui/button";
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isWeb } from "@/constants/platform";
import { ICON_SIZE } from "@/styles/theme";
import {
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
const CONTROL_SIZE = 28;
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
@@ -27,6 +29,7 @@ function keepEditorFocused(event: import("react-native").PointerEvent) {
*/
export function ComposerInputPresentation({
fullscreen,
controlSizing,
sendIconSize,
surface,
toolbar,
@@ -42,6 +45,8 @@ export function ComposerInputPresentation({
overlay,
}: {
fullscreen: boolean;
/** The sizing the toolbar's stylesheet resolves to; see `resolveControlSizing`. */
controlSizing: ControlSizing;
sendIconSize: number;
surface: ReactNode;
toolbar: ReactNode;
@@ -61,18 +66,19 @@ export function ComposerInputPresentation({
// action (Escape, hardware back) belongs only to the pane the user is in.
const { isActiveComposer } = useComposerKeyboardScope();
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
const glyphSize = iconSizeFor("md")[controlSizing];
const railOffset = resolveGlyphRailOffset({
controlSize: CONTROL_SIZE,
controlSize: TOOLBAR_CONTROL_SIZES[controlSizing],
sendIconSize,
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
glyphIconSize: glyphSize,
});
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
(color: string) => <Maximize2 color={color} size={glyphSize} />,
[glyphSize],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
(color: string) => <Minimize2 color={color} size={glyphSize} />,
[glyphSize],
);
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
@@ -141,8 +147,8 @@ const styles = StyleSheet.create((theme) => ({
inline: { flexShrink: 1, position: "relative" },
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: CONTROL_SIZE,
height: CONTROL_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
@@ -150,8 +156,8 @@ const styles = StyleSheet.create((theme) => ({
enterButton: {
position: "absolute",
top: 0,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
+6 -4
View File
@@ -13,6 +13,7 @@ import {
type MenuTriggerState,
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { bySizing } from "@/components/ui/control-geometry";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
import { isWeb } from "@/constants/platform";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -343,16 +344,17 @@ const styles = StyleSheet.create((theme) => {
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
// Touch and pointer sizing differ here and only here. A finger needs the row a menu row gets
// on touch (`MENU_ITEM_HEIGHT` is always its touch value at `xs`); a pointer does not, and the
// popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
minHeight: bySizing({ touch: MENU_ITEM_HEIGHT.xs, pointer: 32 }),
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
paddingVertical: bySizing({ touch: theme.spacing[2], pointer: theme.spacing[1] }),
borderRadius: theme.borderRadius.md,
} as const;
+8 -3
View File
@@ -1,14 +1,19 @@
import { useUnistyles } from "react-native-unistyles";
import { bySizing, TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75;
// Shared header inner height (excluding safe area insets and border)
// A bar of icon buttons: touch-sized it is one touch target tall, like an iOS navigation bar.
const BAR_HEIGHT = { touch: TOUCH_TARGET, pointer: 36 };
// Shared header inner height from `md` up (excluding safe area insets and border).
// Used by both agent header (ScreenHeader) and explorer sidebar header
// This ensures both headers have the same visual height
export const HEADER_INNER_HEIGHT = 36;
export const HEADER_INNER_HEIGHT = bySizing(BAR_HEIGHT).md;
export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
/** The tab rail and pane toolbars under the header. A breakpoint value for stylesheets. */
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = bySizing(BAR_HEIGHT);
export const HEADER_TOP_PADDING_MOBILE = 8;
// Measured composer width; independent of the window's compact/wide breakpoint.
@@ -80,7 +80,7 @@ describe("create agent preferences", () => {
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
options: { fast_mode: true },
featureValues: { fast_mode: true },
}),
);
@@ -95,7 +95,7 @@ describe("create agent preferences", () => {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
},
});
@@ -119,7 +119,7 @@ describe("create agent preferences", () => {
model: "gpt-5.6",
mode: undefined,
thinkingByModel: undefined,
options: undefined,
featureValues: undefined,
},
}),
).toEqual({
@@ -167,13 +167,13 @@ describe("create agent preferences", () => {
modelId: "one-minute-stream",
modeId: "approval-test",
thinkingOptionId: "",
options: {},
featureValues: {},
}),
).toEqual({
provider: "mock",
providerPreferences: {
pi: { model: "anthropic/sonnet" },
mock: { model: "one-minute-stream", mode: "approval-test", options: {} },
mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
},
});
});
@@ -208,29 +208,6 @@ describe("create agent preferences", () => {
});
});
it("reads preferences saved as featureValues as options", () => {
expect(
parseFormPreferences({
provider: "codex",
providerPreferences: { codex: { featureValues: { service_tier: "priority" } } },
}),
).toEqual({
provider: "codex",
providerPreferences: { codex: { options: { service_tier: "priority" } } },
});
});
it("keeps options that are not booleans or strings, such as a profile's sandbox settings", () => {
const options = {
sandbox_workspace_write: { network_access: true, writable_roots: ["/tmp"] },
max_turns: 3,
};
expect(
parseFormPreferences({ provider: "codex", providerPreferences: { codex: { options } } }),
).toEqual({ provider: "codex", providerPreferences: { codex: { options } } });
});
it("rejects unknown persisted fields outside the explicit legacy shape", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
});
@@ -1,14 +1,13 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
const optionsSchema = z.record(z.string(), z.json());
const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()]));
export interface ProviderPreferences {
model?: string;
mode?: string;
thinkingByModel?: Record<string, string>;
/** Last-used values of the provider's option controls, by option key. */
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
@@ -21,23 +20,12 @@ export interface FormPreferences {
launchTarget?: LaunchTarget;
}
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written
// before the rename stored the same record as `featureValues`.
function renameLegacyFeatureValues(value: unknown): unknown {
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value;
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>;
return "options" in rest ? rest : { ...rest, options: featureValues };
}
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
options: optionsSchema.optional(),
}),
);
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
featureValues: featureValuesSchema.optional(),
});
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("chat") }),
@@ -128,7 +116,7 @@ function applyProviderPreferenceUpdates(
): ProviderPreferences {
const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
const nextOptions = mergeDefinedRecord(existing.options, updates.options);
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
if (updates.model !== undefined) {
next.model = updates.model;
@@ -141,8 +129,8 @@ function applyProviderPreferenceUpdates(
if (nextThinkingByModel !== undefined) {
next.thinkingByModel = nextThinkingByModel;
}
if (nextOptions !== undefined) {
next.options = nextOptions;
if (nextFeatureValues !== undefined) {
next.featureValues = nextFeatureValues;
}
return next;
@@ -173,7 +161,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
@@ -182,7 +170,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const options = optionsSchema.safeParse(args.options);
const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({
preferences: args.preferences,
@@ -191,7 +179,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
model: modelId || undefined,
mode: args.modeId === undefined ? undefined : modeId || null,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(options.success ? { options: options.data } : {}),
...(featureValues.success ? { featureValues: featureValues.data } : {}),
},
});
}
@@ -204,7 +192,7 @@ export function applyAgentProfilePreferences(args: {
modelId: string;
modeId: string;
thinkingOptionId: string;
options: Record<string, unknown>;
featureValues: Record<string, unknown>;
}): FormPreferences {
let next = args.preferences;
if (args.previousProvider) {
@@ -227,7 +215,7 @@ export function applyAgentProfilePreferences(args: {
...(args.modelId && args.thinkingOptionId
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
: {}),
options: args.options,
featureValues: args.featureValues,
},
});
}
+6 -21
View File
@@ -17,7 +17,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -272,10 +272,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
splitButton: {
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: HEADER_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
@@ -296,10 +293,7 @@ const styles = StyleSheet.create((theme) => ({
xs: 48,
md: 42,
},
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: HEADER_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -330,24 +324,15 @@ const styles = StyleSheet.create((theme) => ({
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
width: HEADER_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
iconButton: {
width: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
width: HEADER_CONTROL_HEIGHT,
height: HEADER_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
@@ -2,7 +2,7 @@ import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { CountedCheckPresentation } from "@/git/check-presentation";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
import {
CheckPresentationIcon,
getCheckPresentationTone,
@@ -164,10 +164,10 @@ export const sectionKitStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
// Fixed, not minHeight plus padding. A row's trailing slot holds an sm Button
// (28pt), which stacked on the vertical padding and made rows with an
// , which stacked on the vertical padding and made rows with an
// "Add to chat" button taller than rows without one. The row is single-line by
// construction, so it declares a height the button fits inside instead.
height: CONTROL_HEIGHTS.sm + theme.spacing[1],
height: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[1]),
},
checkName: {
fontSize: theme.fontSize.base,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveFeatureValues } from "./feature-preferences";
describe("feature-preferences", () => {
const features = [
{
type: "toggle" as const,
id: "fast_mode",
label: "Fast",
value: false,
},
{
type: "toggle" as const,
id: "plan_mode",
label: "Plan",
value: false,
},
];
it("restores persisted values for available features", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
unknown_feature: true,
},
localFeatureValues: {},
}),
).toEqual({
fast_mode: true,
});
});
it("prefers local values over persisted values", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
plan_mode: false,
},
localFeatureValues: {
fast_mode: false,
},
}),
).toEqual({
fast_mode: false,
plan_mode: false,
});
});
});
@@ -0,0 +1,60 @@
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
export function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
export function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
export function resolveFeatureValues(args: {
features: AgentFeature[];
persistedFeatureValues: Record<string, unknown>;
localFeatureValues: Record<string, unknown>;
}): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const feature of args.features) {
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
next[feature.id] = args.localFeatureValues[feature.id];
continue;
}
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
next[feature.id] = args.persistedFeatureValues[feature.id];
}
}
return next;
}
@@ -128,14 +128,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const providerOptions = draftConfig.providerOptions;
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}
@@ -352,7 +352,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
modelId: model,
modeId,
thinkingOptionId,
options: profile.options,
featureValues: profile.featureValues,
});
}).catch((error) => {
console.warn("[useAgentFormState] persist profile preference failed", error);
@@ -1,6 +1,9 @@
import { useRef } from "react";
import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { Agent } from "@/stores/session-store";
import type {
AgentCapabilityFlags,
AgentFeature,
AgentProvider,
} from "@getpaseo/protocol/agent-types";
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
export interface AgentScreenAgent {
@@ -19,8 +22,7 @@ export interface AgentScreenAgent {
modeId?: string | null;
thinkingOptionId?: string | null;
} | null;
options?: Agent["options"];
controls?: Agent["controls"];
features?: readonly AgentFeature[];
lastError?: string | null;
projectPlacement?: {
projectKey?: string;
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import {
applyFeatureValues,
pruneFeatureValues,
resolveFeatureValues,
} from "./feature-preferences";
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
initialFeatureValues?: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
() => initialFeatureValues ?? {},
);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
const persistedFeatureValues = useMemo(
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedProvider || !normalizedCwd) {
return null;
}
return {
provider: normalizedProvider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
normalizedProvider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const availableFeaturesRaw = featuresQuery.data;
const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]);
const featureValues = useMemo(
() =>
resolveFeatureValues({
features: availableFeatures,
persistedFeatureValues,
localFeatureValues,
}),
[availableFeatures, localFeatureValues, persistedFeatureValues],
);
const features = useMemo(() => {
return applyFeatureValues(availableFeatures, featureValues);
}, [availableFeatures, featureValues]);
useEffect(() => {
const previousProvider = previousProviderRef.current;
previousProviderRef.current = normalizedProvider;
if (previousProvider === null) {
return;
}
if (previousProvider !== normalizedProvider) {
setLocalFeatureValues({});
}
}, [normalizedProvider]);
useEffect(() => {
if (availableFeaturesRaw === undefined) {
return;
}
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
if (next !== localFeatureValues) {
setLocalFeatureValues(next);
}
}, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback(
(featureId: string, value: unknown) => {
setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
if (!provider) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: {
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
});
},
[provider, updatePreferences],
);
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
setLocalFeatureValues(values);
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
applyProfileFeatureValues,
};
}
@@ -1,109 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AgentProvider, ProviderOptions } from "@getpaseo/protocol/agent-types";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "@/agent-controls/draft-options-model";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
/**
* Adapter between a draft's options model and the screen: it sends the model's complete options
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
*/
export function useDraftProviderOptions(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
initialOptions?: ProviderOptions;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const persisted = preferences.providerPreferences?.[provider ?? ""]?.options;
useEffect(() => model.observeProvider(provider), [model, provider]);
useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]);
const ownOptions = ownDraftOptions(state);
const draftConfig = useMemo(
() =>
draftControlsRequest(state, {
provider,
cwd: normalizedCwd,
modeId,
modelId,
thinkingOptionId,
}),
[modeId, modelId, normalizedCwd, provider, state, thinkingOptionId],
);
const queryKey = useMemo(
() => ["providerControls", serverId ?? null, draftConfig],
[draftConfig, serverId],
);
const requestKey = JSON.stringify(queryKey);
const { data } = useFetchQuery({
queryKey,
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(serverId && client && isConnected && draftConfig),
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderControls(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return { controls: payload.controls ?? [], options: payload.options ?? {} };
},
});
useEffect(() => {
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
}, [data, model, provider, requestKey]);
const setOption = useCallback(
(key: string, value: unknown) => {
model.setOption(key, value);
if (!provider) return;
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: { options: { [key]: value } },
}),
).catch((error) => {
console.warn("[useDraftProviderOptions] persist option preference failed", error);
});
},
[model, provider, updatePreferences],
);
const view = viewDraftOptions(state, { provider, requestKey });
const viewControls = view?.controls;
const viewOptions = view?.options;
const optionControls = useMemo<ComposerOptionControls | undefined>(
() =>
viewControls && viewOptions
? { controls: viewControls, options: viewOptions, onSetOption: setOption }
: undefined,
[setOption, viewControls, viewOptions],
);
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
}
@@ -18,6 +18,16 @@ export function hasFinePointer(): boolean {
return getFinePointerQueryList()?.matches ?? false;
}
/**
* True when the device has a touch screen, even while a mouse or keyboard is attached to it:
* the primary pointer is coarse, or the screen reports touch points. Always true on native.
*/
export function hasTouchScreen(): boolean {
if (!isWeb) return true;
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(pointer: coarse)").matches || window.navigator.maxTouchPoints > 0;
}
/**
* Reactive `hasFinePointer()` — re-renders when the user switches between a
* mouse and touch (detachable keyboards, tablet mode).
+7 -2
View File
@@ -36,7 +36,7 @@ export type ForkAgentSource = Pick<
| "model"
| "thinkingOptionId"
| "runtimeInfo"
| "options"
| "features"
| "projectPlacement"
>;
@@ -95,13 +95,18 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u
return undefined;
}
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options },
featureValues,
};
}
@@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: {
codex: {
model: "gpt-5.4",
options: {
featureValues: {
fast_mode: true,
},
},
@@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => {
},
provider: "codex",
updates: {
options: {
featureValues: {
plan_mode: true,
},
},
@@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: {
codex: {
model: "gpt-5.4",
options: {
featureValues: {
fast_mode: true,
plan_mode: true,
},
+4 -8
View File
@@ -116,8 +116,7 @@ interface ChatAgentStateShape {
model?: Agent["model"];
thinkingOptionId?: Agent["thinkingOptionId"];
runtimeInfo?: Agent["runtimeInfo"];
options?: Agent["options"];
controls?: Agent["controls"];
features?: Agent["features"];
lastError?: Agent["lastError"] | null;
}
@@ -176,8 +175,7 @@ function selectChatAgentState(
model: agent.model,
thinkingOptionId: agent.thinkingOptionId,
runtimeInfo: agent.runtimeInfo,
options: agent.options,
controls: agent.controls,
features: agent.features,
lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
@@ -204,8 +202,7 @@ function buildChatAgentFromState(
model: state.model,
thinkingOptionId: state.thinkingOptionId,
runtimeInfo: state.runtimeInfo,
options: state.options,
controls: state.controls,
features: state.features,
lastError: state.lastError ?? null,
projectPlacement,
};
@@ -638,8 +635,7 @@ function AgentPanelBody({
model: agentState.model,
thinkingOptionId: agentState.thinkingOptionId,
runtimeInfo: agentState.runtimeInfo,
options: agentState.options,
controls: agentState.controls,
features: agentState.features,
lastError: agentState.lastError ?? null,
projectPlacement,
}
+2 -2
View File
@@ -656,8 +656,8 @@ const styles = StyleSheet.create((theme) => ({
gap: { xs: theme.spacing[1], md: theme.spacing[2] },
},
headerButton: {
height: { xs: 32, md: HEADER_CONTROL_HEIGHT },
minWidth: { xs: 32, md: HEADER_CONTROL_HEIGHT },
height: HEADER_CONTROL_HEIGHT,
minWidth: HEADER_CONTROL_HEIGHT,
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
@@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: {
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): DraftCommandTarget {
const cwd = input.cwd.trim();
if (!cwd) {
@@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: {
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
return { status: "ready", config };
}
@@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => {
codex: {
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
claude: { model: "claude-sonnet-4-6" },
},
@@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
claude: { model: "claude-sonnet-4-6" },
},
@@ -270,7 +270,7 @@ interface WorkspaceDraftSubmissionConfig {
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined;
featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget;
}
@@ -373,7 +373,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
modeId: input.composerState.effectiveModeId || null,
model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
providerOptions: input.composerState.providerOptions ?? {},
featureValues: input.composerState.featureValues ?? {},
};
}
@@ -453,7 +453,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
providerOptions: composerState.providerOptions,
featureValues: composerState.featureValues,
},
initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`,
@@ -533,7 +533,7 @@ function buildComposerConfig(input: {
return {
initialServerId: serverId || null,
initialValues: buildComposerInitialValues({ initialSetup }),
initialProviderOptions: initialSetup?.providerOptions,
initialFeatureValues: initialSetup?.featureValues,
isVisible: true,
lockedWorkingDir: workingDir,
};
@@ -566,7 +566,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: initialSetup.modeId,
model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId,
providerOptions: initialSetup.providerOptions,
featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup },
};
}
@@ -576,7 +576,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: composerState.effectiveModeId || null,
model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
providerOptions: composerState.providerOptions,
featureValues: composerState.featureValues,
target: { kind: "draft", draftId },
};
}
@@ -634,7 +634,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
agentCreation: input.agentCreation,
});
@@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: {},
featureValues: {},
},
});
});
@@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: {},
featureValues: {},
},
});
+3 -3
View File
@@ -125,7 +125,7 @@ import {
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { densityIconBox, densityIconSize } from "@/styles/density";
import { density, densityIconBox, densityIconSize } from "@/styles/density";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
type EnableBuiltInDaemonOption,
@@ -1523,7 +1523,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: { xs: theme.spacing[3], md: theme.spacing[2] },
minHeight: { xs: 52, md: 28 },
minHeight: { xs: 52, md: density.tightRowHeight.md },
paddingVertical: theme.spacing[1],
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
borderRadius: { xs: 0, md: theme.borderRadius.lg },
@@ -1549,7 +1549,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: { xs: theme.spacing[3], md: theme.spacing[2] },
minHeight: { xs: 52, md: 28 },
minHeight: { xs: 52, md: density.tightRowHeight.md },
paddingVertical: theme.spacing[1],
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
borderRadius: { xs: 0, md: theme.borderRadius.lg },
@@ -1,4 +1,4 @@
import { useMemo, type ReactNode } from "react";
import { Fragment, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CompactHeader } from "@/components/headers/compact-header";
import {
@@ -6,6 +6,13 @@ import {
WorkspaceExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row";
import {
useWindowLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
WindowLayoutActions,
} from "@/window-layout";
import { IconButtonChromeProvider } from "@/components/ui/icon-button-chrome";
export { MobileWorkspaceTabSwitcher } from "./tab-switcher";
@@ -18,8 +25,11 @@ interface CompactWorkspaceHeaderProps {
onToggleExplorer: () => void;
explorerToggleLabel: string;
explorerToggleAccessibilityState: { expanded: boolean };
/** Tab switcher and workspace menu, before the explorer toggle. */
tabSwitcher: ReactNode;
/** Workspace overflow menu. */
children: ReactNode;
headerVariant: "compact" | "full";
actionsActive: boolean;
}
/** The compact header with the workspace's project row and controls. */
@@ -32,9 +42,13 @@ export function CompactWorkspaceHeader({
explorerToggleLabel,
explorerToggleAccessibilityState,
children,
tabSwitcher,
serverId,
headerVariant,
actionsActive,
}: CompactWorkspaceHeaderProps) {
const { t } = useTranslation();
const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
const projectRow = useMemo(
() => (
<WorkspaceHeaderProjectRow
@@ -45,9 +59,10 @@ export function CompactWorkspaceHeader({
),
[subtitle, isSubtitleDistinct, serverId],
);
const actions = (
<>
{children}
const symbols = {
"tab-count": tabSwitcher,
overflow: children,
explorer: (
<WorkspaceExplorerToggle
onPress={onToggleExplorer}
label={explorerToggleLabel}
@@ -56,17 +71,25 @@ export function CompactWorkspaceHeader({
accessibilityState={explorerToggleAccessibilityState}
mobile
/>
</>
);
),
};
const actions = resolveWorkspaceBarActions(headerVariant).map((symbol) => (
<Fragment key={symbol}>{symbols[symbol]}</Fragment>
));
return (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actions}
/>
<IconButtonChromeProvider value="header-island">
{headerVariant === "compact" ? (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actionsInBar ? undefined : actions}
/>
) : null}
<WindowLayoutActions active={actionsActive}>{actions}</WindowLayoutActions>
</IconButtonChromeProvider>
);
}
@@ -8,3 +8,5 @@ export {
type UseWorkspaceScriptsMenuPagesInput,
type WorkspaceScriptsMenuPages,
} from "./workspace-scripts-menu-pages";
export { useWorkspaceScriptActions } from "./use-workspace-script-actions";
@@ -3,9 +3,9 @@ import * as Clipboard from "expo-clipboard";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useToast } from "@/contexts/toast-api-context";
import {
resolveWorkspaceScriptLink,
type WorkspaceScriptLinkKind,
@@ -34,6 +34,7 @@ export interface WorkspaceScriptActions {
}
export interface UseWorkspaceScriptActionsInput {
client: Pick<DaemonClient, "startWorkspaceScript" | "killTerminal"> | null;
serverId: string;
workspaceId: string;
scripts: WorkspaceDescriptor["scripts"];
@@ -45,10 +46,11 @@ export interface UseWorkspaceScriptActionsInput {
* lists them.
*
* A restart outlives the press that asked for it — the second half waits for the daemon to
* report the script stopped — so mount this with whatever owns the menu, not inside a menu page
* that unmounts when the menu closes.
* report the script stopped — so mount this once with the workspace screen and pass its actions to
* each menu surface. Header placement and menu lifetime must not own pending restarts.
*/
export function useWorkspaceScriptActions({
client,
serverId,
workspaceId,
scripts,
@@ -56,7 +58,6 @@ export function useWorkspaceScriptActions({
}: UseWorkspaceScriptActionsInput): WorkspaceScriptActions {
const { t } = useTranslation();
const toast = useToast();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore(
(state) => state.byServerId[serverId] ?? null,
@@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { ToastApiProvider } from "@/contexts/toast-api-context";
import { useWorkspaceScriptActions } from "./use-workspace-script-actions";
import { WorkspaceScriptsButton } from "./workspace-scripts-button";
void testI18n;
@@ -50,7 +52,13 @@ const {
return {
theme: hoistedTheme,
startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })),
startWorkspaceScriptMock: vi.fn(async () => ({
terminalId: "terminal-script-1",
requestId: "start-1",
workspaceId: "workspace-1",
scriptName: "dev",
error: null,
})),
killTerminalMock: vi.fn(async () => ({
terminalId: "terminal-script-1",
success: true,
@@ -113,20 +121,6 @@ vi.mock("@/workspace-service-routes/store", async () => {
};
});
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
sessions: {
"test-server": {
client: {
startWorkspaceScript: startWorkspaceScriptMock,
killTerminal: killTerminalMock,
},
},
},
}),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ show: vi.fn(), error: vi.fn(), copied: copiedToastMock }),
}));
@@ -237,6 +231,34 @@ interface RenderScriptsOptions {
hideLabels?: boolean;
}
const testToast = { show: () => () => {}, error: () => {}, copied: copiedToastMock };
const scriptClient = {
startWorkspaceScript: startWorkspaceScriptMock,
killTerminal: killTerminalMock,
};
function ScriptsOwner({
scripts,
hideLabels,
}: {
scripts: WorkspaceScriptPayload[];
hideLabels?: boolean;
}) {
const actions = useWorkspaceScriptActions({
client: scriptClient,
serverId: "test-server",
workspaceId: "workspace-1",
scripts,
});
return (
<WorkspaceScriptsButton
actions={actions}
scripts={scripts}
liveTerminalIds={LIVE_TERMINAL_IDS}
hideLabels={hideLabels}
/>
);
}
function renderScripts(
scripts: WorkspaceScriptPayload[],
options: RenderScriptsOptions = {},
@@ -257,13 +279,9 @@ function renderScripts(
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceScriptsButton
serverId="test-server"
workspaceId="workspace-1"
scripts={nextScripts}
liveTerminalIds={LIVE_TERMINAL_IDS}
hideLabels={options.hideLabels}
/>
<ToastApiProvider api={testToast}>
<ScriptsOwner scripts={nextScripts} hideLabels={options.hideLabels} />
</ToastApiProvider>
</QueryClientProvider>
);
}
@@ -28,23 +28,18 @@ import type {
WorkspaceScriptLinkTarget,
} from "@/utils/workspace-script-links";
import type { Theme } from "@/styles/theme";
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { extraMutedIconColorMapping } from "@/components/ui/icon-color";
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
import { scriptToneColorMapping } from "./script-status";
import {
useWorkspaceScriptActions,
type WorkspaceScriptActions,
} from "./use-workspace-script-actions";
import type { WorkspaceScriptActions } from "./use-workspace-script-actions";
type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal";
interface WorkspaceScriptsButtonProps {
serverId: string;
workspaceId: string;
actions: WorkspaceScriptActions;
scripts: WorkspaceDescriptor["scripts"];
liveTerminalIds?: readonly string[];
onScriptTerminalStarted?: (terminalId: string) => void;
onViewTerminal?: (terminalId: string) => void;
onOpenUrlInBrowserTab?: (url: string) => void;
hideLabels?: boolean;
@@ -448,22 +443,14 @@ function ScriptRow({
}
export function WorkspaceScriptsButton({
serverId,
workspaceId,
actions,
scripts,
liveTerminalIds = [],
onScriptTerminalStarted,
onViewTerminal,
onOpenUrlInBrowserTab,
hideLabels,
}: WorkspaceScriptsButtonProps): ReactElement | null {
const { t } = useTranslation();
const actions = useWorkspaceScriptActions({
serverId,
workspaceId,
scripts,
onScriptTerminalStarted,
});
const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
const triggerStyle = useCallback(
@@ -531,10 +518,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
splitButton: {
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: HEADER_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
@@ -18,10 +18,7 @@ import type {
} from "@/utils/workspace-script-links";
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
import { scriptToneColorMapping, summarizeScripts, type ScriptsSummary } from "./script-status";
import {
useWorkspaceScriptActions,
type WorkspaceScriptActions,
} from "./use-workspace-script-actions";
import type { WorkspaceScriptActions } from "./use-workspace-script-actions";
type WorkspaceScript = WorkspaceDescriptor["scripts"][number];
@@ -56,11 +53,9 @@ function scriptPageId(scriptName: string): string {
}
export interface UseWorkspaceScriptsMenuPagesInput {
serverId: string;
workspaceId: string;
actions: WorkspaceScriptActions;
scripts: WorkspaceDescriptor["scripts"];
liveTerminalIds: readonly string[];
onScriptTerminalStarted: (terminalId: string) => void;
onViewTerminal: (terminalId: string) => void;
onOpenUrlInBrowserTab: (url: string) => void;
}
@@ -80,25 +75,16 @@ export interface WorkspaceScriptsMenuPages {
* Every script gets a page whether or not it runs, so stopping one from its own page flips that
* page to its stopped state instead of pulling it out from under the user.
*
* The actions live here rather than in a page, so call this from whatever owns the menu: a restart
* finishes after the sheet has closed.
* The workspace screen owns the actions so a restart finishes across menu and header changes.
*/
export function useWorkspaceScriptsMenuPages({
serverId,
workspaceId,
actions,
scripts,
liveTerminalIds,
onScriptTerminalStarted,
onViewTerminal,
onOpenUrlInBrowserTab,
}: UseWorkspaceScriptsMenuPagesInput): WorkspaceScriptsMenuPages {
const { t } = useTranslation();
const actions = useWorkspaceScriptActions({
serverId,
workspaceId,
scripts,
onScriptTerminalStarted,
});
const summary = useMemo(() => summarizeScripts(scripts), [scripts]);
const trigger = useMemo(
@@ -82,6 +82,7 @@ import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
import {
paneContentToolbarIconSize,
paneContentToolbarTrailingPadding,
ToolbarButton,
ToolbarControls,
@@ -115,6 +116,8 @@ const TAB_ICON_WIDTH = 14;
const TAB_CONTENT_GAP = 4;
const TAB_DROP_INDICATOR_WIDTH = 4;
const TAB_MODIFIED_DOT_SIZE = 8;
// The tab rail only renders in the wide layout, so the device alone picks its glyph size.
const TAB_RAIL_GLYPH_SIZE = paneContentToolbarIconSize(false);
const TAB_MIN_WIDTH = 64;
const TAB_MAX_WIDTH = 160;
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
@@ -249,7 +252,7 @@ function WorkspaceNewTabButton({
testID="workspace-new-tab-button"
style={placement === "inline" ? styles.inlineNewTabButton : undefined}
>
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
<ThemedPlus size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
</ToolbarButton>
<WorkspaceNewTabMenuContent
serverId={serverId}
@@ -298,11 +301,11 @@ function WorkspacePaneToolbarActions({
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const splitActionsVisible = showSplitActions && Boolean(onSplitRight && onSplitDown);
const splitRightLeading = useMemo(
() => <ThemedColumns2 size={14} uniProps={extraMutedColorMapping} />,
() => <ThemedColumns2 size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />,
[],
);
const splitDownLeading = useMemo(
() => <ThemedRows2 size={14} uniProps={extraMutedColorMapping} />,
() => <ThemedRows2 size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />,
[],
);
const splitRightTrailing = useMemo(
@@ -341,9 +344,9 @@ function WorkspacePaneToolbarActions({
onPress={onTogglePaneMaximized}
>
{paneMaximized ? (
<ThemedMinimize size={14} uniProps={extraMutedColorMapping} />
<ThemedMinimize size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
) : (
<ThemedMaximize size={14} uniProps={extraMutedColorMapping} />
<ThemedMaximize size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
)}
</ToolbarButton>
) : null}
@@ -354,7 +357,7 @@ function WorkspacePaneToolbarActions({
label={t("workspace.git.actions.moreActions")}
testID="workspace-split-pane-menu"
>
<ThemedEllipsis size={14} uniProps={extraMutedColorMapping} />
<ThemedEllipsis size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
</ToolbarButton>
<DropdownMenuContent
side="bottom"
@@ -409,7 +412,7 @@ function WorkspaceExitFocusModeButton({
testID="workspace-exit-focus-mode"
onPress={onPress}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
<ThemedX size={TAB_RAIL_GLYPH_SIZE} uniProps={mutedColorMapping} />
</ToolbarButton>
</View>
);
@@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: input.provider,
@@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: {
...(input.modeId ? { modeId: input.modeId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
@@ -7,6 +7,7 @@ import {
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useWindowLayout, resolveWorkspaceActionPlacement } from "@/window-layout";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
import type { LayoutChangeEvent } from "react-native";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
@@ -62,7 +63,7 @@ import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { WorkspaceActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button";
import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { WorkspaceScriptsButton, useWorkspaceScriptActions } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
@@ -502,6 +503,7 @@ function WorkspaceHeaderTitleBar({
onCopyBranchName,
onOpenSetupTab,
}: WorkspaceHeaderTitleBarProps) {
const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
return (
<View style={styles.headerTitleContainer}>
{isLoading ? (
@@ -518,18 +520,20 @@ function WorkspaceHeaderTitleBar({
/>
</View>
)}
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenuDesktop
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
</View>
{actionsInBar ? null : (
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenuDesktop
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
</View>
)}
</View>
);
}
@@ -1022,8 +1026,11 @@ function WorkspaceScreenContent({
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
const workspaceActionsInBar =
resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const workspacePanelActive = usePanelStore((state) => state.mobilePanel.target === "agent");
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode);
const hasMacTrafficLights = useHasWindowChromeObstruction("top-left");
@@ -1213,6 +1220,13 @@ function WorkspaceScreenContent({
onTerminalCreateQueued: handleTerminalCreateQueued,
onTerminalCreateFailed: handleTerminalCreateFailed,
});
const scriptActions = useWorkspaceScriptActions({
client,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
scripts: workspaceScripts,
onScriptTerminalStarted: handleScriptTerminalStarted,
});
const { archiveAgent } = useArchiveAgent();
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
@@ -3189,11 +3203,9 @@ function WorkspaceScreenContent({
<PluginHeaderButtons serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} />
{workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
actions={scriptActions}
scripts={workspaceDescriptor.scripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
hideLabels
@@ -3211,15 +3223,17 @@ function WorkspaceScreenContent({
{workspaceDirectory ? (
<>
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
<WorkspaceHeaderExplorerToggle
owner={explorerToggleOwner}
onPress={handleToggleExplorerSidebar}
label={explorerSidebarToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
style={styles.compactHeaderActionButton}
accessibilityState={explorerSidebarToggleAccessibilityState}
/>
{workspaceActionsInBar ? null : (
<WorkspaceHeaderExplorerToggle
owner={explorerToggleOwner}
onPress={handleToggleExplorerSidebar}
label={explorerSidebarToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
style={styles.compactHeaderActionButton}
accessibilityState={explorerSidebarToggleAccessibilityState}
/>
)}
</>
) : null}
</View>
@@ -3231,13 +3245,14 @@ function WorkspaceScreenContent({
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
scriptActions,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
handleToggleExplorerSidebar,
explorerSidebarToggleLabel,
explorerSidebarToggleAccessibilityState,
explorerToggleOwner,
workspaceActionsInBar,
t,
],
);
@@ -3343,104 +3358,113 @@ function WorkspaceScreenContent({
);
const compactHeaderScripts = useMemo(
() => ({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
actions: scriptActions,
scripts: workspaceScripts,
liveTerminalIds,
onScriptTerminalStarted: handleScriptTerminalStarted,
onViewTerminal: handleViewScriptTerminal,
onOpenUrlInBrowserTab: handleOpenUrlInBrowserTab,
}),
[
normalizedServerId,
normalizedWorkspaceId,
workspaceScripts,
liveTerminalIds,
handleScriptTerminalStarted,
scriptActions,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
],
);
const compactWorkspaceHeader = useMemo(
() =>
isMobile ? (
<CompactWorkspaceHeader
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
createTerminalDisabled={createTerminalDisabled}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
<WorkspaceHeaderMenuMobile
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</CompactWorkspaceHeader>
) : null,
const workspaceTabSwitcher = useMemo(
() => (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
createTerminalDisabled={createTerminalDisabled}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
),
[
activeTabKey,
createTerminalDisabled,
handleCloseOtherTabs,
handleCloseTabById,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCopyAgentId,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleCreateDraftTab,
handleCreateTerminal,
handleReloadAgent,
handleRenameTab,
handleSelectSwitcherTab,
normalizedServerId,
normalizedWorkspaceId,
tabs,
],
);
const compactWorkspaceHeader = useMemo(
() => (
<CompactWorkspaceHeader
headerVariant={isMobile ? "compact" : "full"}
actionsActive={isRouteFocused && (!isMobile || workspacePanelActive)}
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
tabSwitcher={workspaceTabSwitcher}
>
<WorkspaceHeaderMenuMobile
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</CompactWorkspaceHeader>
),
[
canOpenImportSheet,
compactHeaderScripts,
createTerminalDisabled,
currentBranchName,
explorerSidebarToggleAccessibilityState,
explorerSidebarToggleLabel,
handleCloseOtherTabs,
handleCloseTabById,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCopyAgentId,
handleCopyBranchName,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleCopyWorkspacePath,
handleCreateBrowserTab,
handleCreateDraftTab,
handleCreateTerminal,
handleCreateTerminalWithProfile,
handleOpenSetupTab,
handleReloadAgent,
handleRenameTab,
handleSelectSwitcherTab,
handleToggleExplorerSidebar,
isMobile,
isRouteFocused,
isWorkspaceHeaderLoading,
isWorkspaceHeaderSubtitleDistinct,
normalizedServerId,
@@ -3448,10 +3472,11 @@ function WorkspaceScreenContent({
openImportSheet,
showCreateBrowserTab,
showWorkspaceSetup,
tabs,
workspaceDirectory,
workspaceHeaderSubtitle,
workspaceHeaderTitle,
workspacePanelActive,
workspaceTabSwitcher,
],
);
const desktopSplitContent = useMemo(() => {
@@ -3559,6 +3584,7 @@ function WorkspaceScreenContent({
</View>
) : (
<View style={styles.centerColumn}>
{compactWorkspaceHeader}
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
{shouldRenderDesktopPaneFallback ? (
+2 -4
View File
@@ -26,8 +26,7 @@ import type { ComposerAttachment } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type {
AgentPermissionRequest,
AgentOptionControl,
ProviderOptions,
AgentFeature,
AgentProvider,
AgentMode,
AgentCapabilityFlags,
@@ -92,8 +91,7 @@ export interface Agent {
cwd: string;
workspaceId?: string;
model: string | null;
options?: ProviderOptions;
controls?: AgentOptionControl[];
features?: AgentFeature[];
thinkingOptionId?: string | null;
requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null;
@@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
allowEmptyText?: boolean;
}
@@ -95,53 +95,6 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
);
});
it("keeps every saved layout when a draft tab's setup carries a nested provider option", async () => {
const values = new Map<string, string>();
const storage: StateStorage = {
getItem: (name) => values.get(name) ?? null,
setItem: (name, value) => {
values.set(name, value);
},
removeItem: (name) => {
values.delete(name);
},
};
const store = createWorkspaceLayoutStore(undefined, storage);
await store.persist.rehydrate();
store.getState().openTab({
workspaceKey: "server:kept",
target: { kind: "agent", agentId: "agent-1" },
intent: "reveal",
});
store.getState().openTab({
workspaceKey: "server:forked",
target: {
kind: "draft",
draftId: "draft-1",
setup: {
provider: "codex",
cwd: "/repo",
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: { sandbox_workspace_write: { network_access: true }, max_turns: 3 },
},
},
intent: "reveal",
});
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
expect(Object.keys(restored.getState().layoutByWorkspace).sort()).toEqual([
"server:forked",
"server:kept",
]);
expect(JSON.stringify(restored.getState().layoutByWorkspace["server:forked"])).toContain(
"network_access",
);
});
it.each([1, 2])(
"restores the agent pane after parked focus in storage version %i",
async (version) => {
@@ -1,25 +1,14 @@
import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before
// the rename stored the same record as `featureValues`.
function renameLegacyFeatureValues(value: unknown): unknown {
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value;
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>;
return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues };
}
const WorkspaceDraftTabSetupStorageSchema = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
providerOptions: z.record(z.string(), z.json()),
}),
);
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({

Some files were not shown because too many files have changed in this diff Show More