mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddfed6afe9 | ||
|
|
4991efddf8 | ||
|
|
fcbfb4f0d3 | ||
|
|
6221b0c8b7 |
+5
-3
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user