mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f946c0a415 | ||
|
|
64ea11f61e | ||
|
|
2d976b0b19 | ||
|
|
746165c300 | ||
|
|
7834559915 | ||
|
|
3240c653ed | ||
|
|
09e8139f39 | ||
|
|
64146208bd | ||
|
|
3606a3c7a2 | ||
|
|
b1c6d8916c | ||
|
|
8215535631 | ||
|
|
58bb1c5667 | ||
|
|
ded20eb85c | ||
|
|
f665cc697c | ||
|
|
03580ce50b | ||
|
|
9c5025ad63 | ||
|
|
ee01397101 | ||
|
|
d484a841ed | ||
|
|
cd787881d9 | ||
|
|
8d260ddcb6 | ||
|
|
08aaaae15b | ||
|
|
45cee0b613 | ||
|
|
b376a34cc4 | ||
|
|
97284bea8a | ||
|
|
5a17ff1fd3 | ||
|
|
ba207ff5ef | ||
|
|
c4d5915732 | ||
|
|
46f3ac94a3 | ||
|
|
4d5b761bc8 | ||
|
|
f9fde693cf | ||
|
|
173a1197db | ||
|
|
9e528c5776 | ||
|
|
3685a08c43 | ||
|
|
d165b07682 | ||
|
|
4e96928c64 | ||
|
|
5828fd5ce4 | ||
|
|
c22b97506f | ||
|
|
4216cdc7b5 | ||
|
|
e07440ae06 | ||
|
|
4713ba8819 | ||
|
|
762f5f023e | ||
|
|
50e830ac01 | ||
|
|
f2f8a84041 | ||
|
|
7ddaf59aa7 | ||
|
|
35e136314d | ||
|
|
f4ddcf7a5b | ||
|
|
46b0466f3b | ||
|
|
429cf715ca | ||
|
|
4196fae928 | ||
|
|
bbd93f76ee | ||
|
|
9e70f5665a | ||
|
|
f4210f39aa | ||
|
|
448f258583 | ||
|
|
152a5ae731 | ||
|
|
9f39663722 | ||
|
|
568bd447e3 | ||
|
|
41a36fa593 | ||
|
|
f91ec81f7c | ||
|
|
af2fb8ab35 | ||
|
|
cdc64ecfa4 | ||
|
|
042bc36ba5 | ||
|
|
4f6e71e8d6 | ||
|
|
51505a7d3b | ||
|
|
13c318838f | ||
|
|
d9fb2ffe93 | ||
|
|
0d82eed6b5 | ||
|
|
2ec0ac3c25 | ||
|
|
6cb9685a6d | ||
|
|
8a972b8c2e | ||
|
|
cc56b392de | ||
|
|
cb331532ad | ||
|
|
da994fb400 | ||
|
|
c4baf750f2 | ||
|
|
31a408d108 | ||
|
|
3be00f5105 | ||
|
|
6e704f803c | ||
|
|
949462f39b | ||
|
|
53d91d63da | ||
|
|
4709b49dc4 | ||
|
|
f938bf963d | ||
|
|
1131241b73 | ||
|
|
ce39ab6f09 | ||
|
|
c242f4d8db | ||
|
|
66aeff70d1 | ||
|
|
adf8badade | ||
|
|
1b45d92dac | ||
|
|
42ed0a8a99 | ||
|
|
d8fca6fdc6 | ||
|
|
77f97f82bc | ||
|
|
26b1e122ae | ||
|
|
7dfb63d0c4 | ||
|
|
5c81d08959 | ||
|
|
96b2bea130 | ||
|
|
33338be16e | ||
|
|
32230e24c5 | ||
|
|
d641cb5179 | ||
|
|
58094053dc | ||
|
|
3f75a11a49 | ||
|
|
f0807c5deb | ||
|
|
e0ff0959a1 | ||
|
|
591dd3bc92 | ||
|
|
8fb31cf8c0 | ||
|
|
b99d2a010d | ||
|
|
41011ddc60 | ||
|
|
e9d440259c | ||
|
|
0d697e6330 | ||
|
|
5413b8122c | ||
|
|
8960e76d19 | ||
|
|
218bcaaa6b | ||
|
|
30cc0a8c21 | ||
|
|
a362664f47 | ||
|
|
20ce734367 | ||
|
|
ca2bdd66eb | ||
|
|
1aaac5b231 | ||
|
|
b60b74384b | ||
|
|
034f639c88 | ||
|
|
55e8cff9f4 | ||
|
|
06be203c5c | ||
|
|
007a8e36ae | ||
|
|
eb6bed8447 |
@@ -276,7 +276,7 @@ its teardown; reconnect creates new server IDs for surviving client handles. See
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting; a window with no validated inbound frame is a miss, and two misses in a row tear the socket down. When the app returns to the foreground the client runs a fresh cycle of two 5-second windows back to back, then reconnects without backoff, because a socket that died while the OS had JS suspended stays `OPEN` and accepts sends: only inbound frames prove it. Misses and probes from before the suspension do not count toward that cycle, and a window whose timer fires long after its deadline spanned another suspension, so it is discarded and the cycle starts over. Any complete frame inside a window keeps the connection, so a slow multi-frame transfer survives; a single frame that takes longer than the two windows to arrive after foreground does not, and the fix for that is smaller daemon-to-client frames, not a longer window. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -482,6 +483,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
|
||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. Only one card per overlay layer is open: pointing at another trigger replaces the open card at once, so a sweep down a list never shows two cards or leaves one behind. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
|
||||
+139
-3
@@ -474,6 +474,9 @@ path shape: a plugin can place a worktree several directories below the default
|
||||
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
|
||||
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
|
||||
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
|
||||
The same holds for a hook that runs inside an agent's lifecycle lane: an `agent.open.before` hook
|
||||
that awaits a write to the agent being opened (`setMetadata`, `setLabels`) waits on the open, which
|
||||
waits on the hook. The hooks reference tells plugin authors to start such a write without awaiting it.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
@@ -529,13 +532,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
|
||||
It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
|
||||
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||
Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
|
||||
(`providerOptions`) in `session.config`. A running-agent change arrives as
|
||||
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
|
||||
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
|
||||
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
|
||||
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
|
||||
its value into an option and is sent both shapes in `session.configure`; delete it with
|
||||
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
@@ -661,6 +672,131 @@ Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
|
||||
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
|
||||
|
||||
## Contribute composer items
|
||||
|
||||
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
|
||||
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
|
||||
|
||||
A plugin reads one `ComposerModel` (`packages/app/src/composer/model.ts`): state, subscribe and
|
||||
commands. `toPluginComposer` (`packages/app/src/plugins/composer-items/`) turns its state into the
|
||||
`PluginComposer` an item reads, and the host never learns which kind of composer it got. Two
|
||||
implementations provide it:
|
||||
|
||||
- A running agent's `agentComposer(serverId, agentId)` (`composer/agent-composer/`). It exists
|
||||
without a mounted tab: the prompt and attachments are the agent's draft in the draft store, the
|
||||
settings are the session store's agent, and `model` and `thinking` resolve through the provider
|
||||
catalog with `resolveAgentModelSelection`, the picker's own function, so a read equals what the
|
||||
picker shows. The model loads the catalog itself and resolves with no models until it lands, as the picker does,
|
||||
so a read never differs from what the picker shows. While anything watches the model it holds a
|
||||
`QueryObserver` on the agent's catalog: that is what lets reconnect repair, which refetches only
|
||||
active queries, refresh a catalog nobody has a picker open for, and retry one that failed.
|
||||
`PluginComposersSource.subscribe(selector, listener)` is what holds it for `useComposers`, so
|
||||
the subscription must go through the model's own `subscribe`. Watching again after the catalog
|
||||
was invalidated or failed refetches it, and a read with no watcher starts one deduplicated
|
||||
refresh and shows the cached catalog until it lands. The model keeps one pair of store
|
||||
subscriptions however many components watch it. It is shared for as long as its agent is in the
|
||||
session and not archived, so a command a plugin saved keeps reaching the composer a later tab
|
||||
mounts; it is dropped when the agent is gone or archived and nothing watches it or has one
|
||||
mounted, checked on session changes and when the last holder lets go. The mounted
|
||||
`AgentControls` plugs its input in with `attachInput` so `insertText` lands at the cursor.
|
||||
Without a live input the model writes the stored prompt with `replaceDraftText`, which also asks
|
||||
a mounted input to show it. A workspace the user left stays mounted, display none. Web
|
||||
drops the input ref there; native keeps the input, with a stale cursor and selection.
|
||||
`ComposerInput.isLive()` is how the model tells: it needs the ref and a retained panel that is
|
||||
showing (`useRetainedPanelActive`). Closing a root agent's tab archives
|
||||
it, so a running agent with no mounted composer at all only exists as a subagent whose tab was
|
||||
closed. The daemon does not check `setModel` and `setThinking` against a catalog; the provider
|
||||
decides and its refusal rejects the call.
|
||||
- A draft or New workspace screen's `composerModelFromHalves`, an adapter over the two contexts
|
||||
in `composer/session.tsx`: `Composer` provides `ComposerInput` (prompt, attachments),
|
||||
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
|
||||
|
||||
`ComposerAgent` stays for the built-in controls. Add a capability to the `ComposerModel`, both
|
||||
implementations and `toPluginComposer`; never branch on `context` in the host.
|
||||
|
||||
`useComposers` and `composers()` reach an agent's model by reference (`plugins/composer-items/source.ts`):
|
||||
`PluginInstallationProvider` provides the plugin's source to every surface, and
|
||||
`createPluginCapabilities` puts `composers` on every command context. `{ focused: true }` is the
|
||||
agent Command Center registration hands to agent items, published through `plugins/focused-agent.ts`
|
||||
(null outside a workspace route). The source reads that module, not the navigation or layout
|
||||
stores, so the plugin runtime does not import them. The remembered workspace selection is
|
||||
navigation restore state and is not the focused agent. The query-cache subscriptions react to
|
||||
`updated` events only: a `useQuery` in another component's render announces `added`, and a
|
||||
subscriber that updates then triggers React's "update a component while rendering a different one"
|
||||
warning.
|
||||
|
||||
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
|
||||
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
|
||||
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
|
||||
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
|
||||
condenses to make room for them. A custom component renders wherever its item is placed, and an
|
||||
item that renders nothing still counts for density; there is no registration for either.
|
||||
|
||||
A command runs against the owner's current state, not the render that built the callback. The
|
||||
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
|
||||
and `draftComposerAgent` rejects a provider the host does not offer. It passes a model id to the form
|
||||
as the picker does, with no catalog check; for a running agent the provider decides. `ComposerControl` owns the pending state and the error toast for callbacks that
|
||||
return a promise.
|
||||
|
||||
The overflow sheet's content portals to the app root, away from the composer. Pass
|
||||
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
|
||||
the sheet draws no plugin rows.
|
||||
|
||||
## Contribute New workspace items
|
||||
|
||||
`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract
|
||||
is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items).
|
||||
|
||||
The screen's choices that a plugin may change live in one plain store,
|
||||
`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base
|
||||
picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and
|
||||
the plugin commands write to it, so a command sees what an earlier command left. Project and host
|
||||
stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and
|
||||
they are read-only to plugins; a command that switched them would unmount the item that called it.
|
||||
|
||||
The store is built in the render that creates the screen, with what the screen derives from its
|
||||
hooks (the lock, the project, whether the project can make worktrees, whether the host supports
|
||||
metadata), so a command is valid from a child's first effect, which runs before the screen's own
|
||||
effects. `syncInputs` refreshes those inputs in a layout effect on every commit, and commands read
|
||||
them when they run, never from the render that built them. The pickers and the commands pick
|
||||
through the same `chooseBase` and `chooseIsolation`, so a plugin command has the effects of a
|
||||
pick; the screen injects those effects when it builds the store (syncing the picker's PR attachment,
|
||||
remembering the isolation), so a command from a mount effect does the same as a click. Closing the
|
||||
picker stays in the picker's own handler. Add a capability by extending the store;
|
||||
do not read screen state from a plugin component.
|
||||
|
||||
The item host (`PluginNewWorkspaceItems`) keys the items by `(serverId, project id)`, so a change of
|
||||
project or host remounts them. That is what keeps an item's own state and a `Combobox`'s cached
|
||||
results from outliving their project: the `Combobox` cannot tell that an author's inline `search`
|
||||
now means another project, because its identity changes every render. The store clears `branch`
|
||||
and `metadata` on the same change in `syncInputs`, and a remounted item's mount effect sets them
|
||||
again.
|
||||
|
||||
An item's commands come from `setup.commandsFor({ serverId, projectId })`, bound to the target the
|
||||
item was mounted for. Once `syncInputs` reports another target they reject with a "moved" error, so
|
||||
a lookup that finishes after a project switch cannot write into the new project's choices. The
|
||||
reset only clears what was already written; this check covers what is written later. Rejecting, not
|
||||
ignoring, matches the lock: a command's promise resolves only when the change took effect.
|
||||
|
||||
Known limit: an item that issues a command from `useLayoutEffect` on mount after a project or host
|
||||
switch runs before the screen's `syncInputs` layout effect, so its commands (bound to the new
|
||||
target) still see the previous target and reject as moved. This applies to a project switch as well as a host switch, since items
|
||||
remount per `(serverId, project id)`. Passive effects are not affected.
|
||||
|
||||
`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a
|
||||
new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base
|
||||
at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The
|
||||
server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref
|
||||
creation uses; `branchNameFromRef` is for labels only.
|
||||
|
||||
Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on
|
||||
desktop, a full-width field when compact) and the same pending state, error toast and list as the
|
||||
composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin
|
||||
`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and
|
||||
`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through
|
||||
`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs.
|
||||
`plugins/item-host.tsx` is the host shared by composer and New workspace items.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
|
||||
+62
-3
@@ -30,6 +30,65 @@ 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.
|
||||
@@ -115,7 +174,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 `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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -478,7 +537,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
readonly controls?: AgentOptionControl[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
@@ -500,7 +559,7 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
|
||||
@@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. An underfilled viewport
|
||||
loads older history until it fills or history is exhausted. Further history is user-driven.
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||
|
||||
@@ -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]?.featureValues ?? null;
|
||||
return preferences.providerPreferences?.[providerId]?.options ?? 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",
|
||||
featureValues: { fast_mode: true },
|
||||
options: { 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,
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -146,36 +146,6 @@ test("opens a timeline image in a zoomable lightbox", async ({
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
});
|
||||
|
||||
test("opens a remote Markdown image whose URL needs normalization", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
}) => {
|
||||
const image = await createSmallAssistantPng(workspace, {
|
||||
alt: "Remote image with a mixed-case host",
|
||||
fileName: "remote.png",
|
||||
width: 240,
|
||||
height: 160,
|
||||
});
|
||||
const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath));
|
||||
const url = delivery.url.replace("127.0.0.1", "LOCALHOST");
|
||||
try {
|
||||
delivery.release();
|
||||
const agent = await createSettledMockAgent(workspace, "Remote image lightbox");
|
||||
await emitSettledAssistantImage(workspace.client, agent, image, url);
|
||||
await openAssistantImageTimeline(page, agent);
|
||||
const rendered = page.getByRole("button", { name: image.alt }).first();
|
||||
await expect(rendered).toHaveJSProperty("naturalWidth", image.width);
|
||||
await rendered.click();
|
||||
await expect(page.getByTestId("attachment-lightbox-canvas")).toBeVisible();
|
||||
await page.getByTestId("zoomable-viewport-toolbar").hover();
|
||||
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Close image", exact: true }).click();
|
||||
await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0);
|
||||
} finally {
|
||||
await delivery.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reloading a timeline reserves image height without a load-time scroll jump", async ({
|
||||
imageWorkspace: workspace,
|
||||
page,
|
||||
|
||||
@@ -198,130 +198,3 @@ test.describe("Turn end", () => {
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectTimingInsideFooter(page: Page): Promise<void> {
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
|
||||
const labelBounds = element.parentElement!.getBoundingClientRect();
|
||||
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const lines: boolean[] = [];
|
||||
while (walker.nextNode()) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
for (const rect of range.getClientRects()) {
|
||||
lines.push(
|
||||
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
|
||||
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
|
||||
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
|
||||
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
expect(paintedLines.length).toBeGreaterThan(0);
|
||||
expect(paintedLines.every(Boolean)).toBe(true);
|
||||
}
|
||||
|
||||
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
|
||||
const footer = page.getByTestId("turn-footer").last();
|
||||
await footer.evaluate((element, availableWidth) => {
|
||||
element.style.width = `${availableWidth}px`;
|
||||
}, width);
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
await expect(label).toBeVisible();
|
||||
await expectTimingInsideFooter(page);
|
||||
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
|
||||
const before = await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
);
|
||||
const footerBefore = await footer.boundingBox();
|
||||
const labelBefore = await label.boundingBox();
|
||||
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
|
||||
expect(await label.boundingBox()).toMatchObject({
|
||||
x: labelBefore!.x,
|
||||
width: labelBefore!.width,
|
||||
height: labelBefore!.height,
|
||||
});
|
||||
expect(
|
||||
await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
),
|
||||
).toEqual(before);
|
||||
expect(await footer.boundingBox()).toMatchObject({
|
||||
x: footerBefore!.x,
|
||||
y: footerBefore!.y,
|
||||
width: footerBefore!.width,
|
||||
});
|
||||
await expectTimingInsideFooter(page);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
|
||||
}
|
||||
|
||||
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectFooterWithinWidth(page, 140);
|
||||
await expectFooterWithinWidth(page, 150);
|
||||
await expectFooterWithinWidth(page, 300);
|
||||
await expectFooterWithinWidth(page, 880);
|
||||
});
|
||||
|
||||
async function visibleFooterLeftEdges(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => candidate.checkVisibility());
|
||||
return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.filter((footer) => footer.checkVisibility())
|
||||
.map((footer) => Math.round(footer.getBoundingClientRect().left));
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFootersInOneColumn(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const edges = await visibleFooterLeftEdges(page);
|
||||
return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges;
|
||||
})
|
||||
.toBe("one column");
|
||||
}
|
||||
|
||||
test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
// Wider than the chat's max width, so the column is centered with space on both sides.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates");
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expectFootersInOneColumn(page);
|
||||
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expectFootersInOneColumn(page);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
closeActiveAgentWithShortcut,
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
@@ -21,12 +20,8 @@ import {
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
unarchiveAgentFromConversation,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer";
|
||||
import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
@@ -56,34 +51,6 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("restoring a closed agent retains its unsent composer draft after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "archived-composer-",
|
||||
title: "Restore unsent draft",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessage(page, "A completed conversation before closing");
|
||||
await awaitAssistantMessage(page);
|
||||
await expectAgentIdle(page);
|
||||
await fillComposerDraft(page, "Keep this unsent message after closing");
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
await closeActiveAgentWithShortcut(page, agent.agentId);
|
||||
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
|
||||
await page.reload();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, "Restore unsent draft");
|
||||
await clickSessionRow(page, "Restore unsent draft");
|
||||
await expectArchivedAgentFocused(page, agent.agentId);
|
||||
await unarchiveAgentFromConversation(page);
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
|
||||
@@ -27,22 +27,15 @@ async function holdBranchSuggestions(page: Page) {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
}
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage(async (message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "branch_suggestions_response"
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
|
||||
await gate;
|
||||
}
|
||||
browser.send(message);
|
||||
});
|
||||
|
||||
@@ -8,11 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -266,126 +262,6 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
|
||||
await openWorkspaceChangesTree(page, workspace);
|
||||
await expect(
|
||||
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
|
||||
).toBeVisible();
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesTreePanel(page);
|
||||
}
|
||||
|
||||
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await createLargeStreamedWorkspace();
|
||||
await addOversizedSibling(workspace);
|
||||
const stream = await holdDiffCompletion(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await expectAnnouncedTreeAndLoader(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
|
||||
stream.finish();
|
||||
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
const connections = stream.connections;
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
|
||||
await addOneLineToFirstFile(workspace);
|
||||
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
expect(stream.connections).toBe(connections);
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
Array.from({ length: 252 }, (_, file) =>
|
||||
writeFile(
|
||||
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
|
||||
Array.from(
|
||||
{ length: 90 },
|
||||
(_unused, line) =>
|
||||
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
|
||||
).join("\n") + "\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
const file = path.join(workspace.repoPath, "src/file-0000.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
|
||||
}
|
||||
async function holdDiffCompletion(page: Page) {
|
||||
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
|
||||
let holding = true;
|
||||
let connections = 0;
|
||||
let completed = 0;
|
||||
const completions: Array<() => void> = [];
|
||||
const changedBodies: number[] = [];
|
||||
const bodyCounts = new Map<number, number>();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
connections++;
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (
|
||||
typeof message !== "string" &&
|
||||
message[0] >= DiffOpcode.Begin &&
|
||||
message[0] <= DiffOpcode.End
|
||||
) {
|
||||
const frame = decodeDiffFrame(message);
|
||||
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
|
||||
else if (frame.opcode === DiffOpcode.Bodies)
|
||||
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
|
||||
else {
|
||||
completed++;
|
||||
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
|
||||
}
|
||||
}
|
||||
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
|
||||
completions.push(() => browserSocket.send(message));
|
||||
return;
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
changedBodies,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
},
|
||||
};
|
||||
}
|
||||
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
|
||||
}
|
||||
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
|
||||
await expect(changesTree(page).first()).toBeVisible();
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
@@ -623,35 +499,6 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
|
||||
await useLegacyDiffHost(page);
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
|
||||
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
});
|
||||
async function useLegacyDiffHost(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.message?.payload?.status === "server_info") {
|
||||
delete envelope.message.payload.features.diffSubscription;
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1464,13 +1311,11 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(300_000);
|
||||
test.setTimeout(180_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
// Measure document bounds after delivery, independently of the cold Git read.
|
||||
await expectWorkingDiffLoaded(page, 240_000);
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
@@ -2049,16 +1894,12 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
expectWorkspaceHeaderVisible,
|
||||
resizeComposerViewport,
|
||||
submitMessage,
|
||||
submitMessageWithButton,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -147,14 +146,6 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await submitMessageWithButton(page, "Pointer submission");
|
||||
await expectComposerDraft(page, "");
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, "Ready after Send");
|
||||
await expectComposerDraft(page, "Ready after Send");
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expectComposerFocused(page);
|
||||
|
||||
await submitMessage(page, "First message");
|
||||
await expectComposerFocused(page);
|
||||
|
||||
@@ -182,7 +173,7 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible();
|
||||
await connection.drop();
|
||||
await connection.waitForBlockedConnection();
|
||||
await pasteRichTextIntoComposer({
|
||||
@@ -322,9 +313,6 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
// Autofocus makes its first attempt on the next animation frame.
|
||||
await waitForAnimationFrames(page, 4);
|
||||
await expectComposerNotFocused(page);
|
||||
await submitMessageWithButton(page, "Touch submission");
|
||||
await expectComposerDraft(page, "");
|
||||
await expectComposerNotFocused(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareNarrowSplit(page: Page, workspaceId: string) {
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
@@ -74,38 +74,33 @@ async function prepareNarrowSplit(page: Page, workspaceId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function expectPanesKeepTabRails(page: Page, panes: Locator[]) {
|
||||
for (const pane of panes) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function launchTerminalFromPaneRail(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click();
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestorePane(pane: Locator) {
|
||||
await pane.getByTestId("workspace-maximize-pane").click();
|
||||
await pane.getByTestId("workspace-restore-pane").click();
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
}
|
||||
|
||||
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await launchTerminalFromPaneRail(page, originalPane);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestorePane(originalPane);
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -123,15 +118,17 @@ async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
|
||||
@@ -79,12 +79,6 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
@@ -106,7 +100,6 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectCleanChangesLoaded(page);
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { holdNextPreviewLoad } from "../support/helpers/image-preview";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -38,15 +37,6 @@ function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function expectTallImageFrame(frame: Locator): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await frame.boundingBox();
|
||||
return box ? box.height - box.width : 0;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function hasHorizontalOverflow(element: HTMLElement): boolean {
|
||||
return element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
@@ -739,10 +729,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "shape.svg"),
|
||||
await readFile(path.resolve(__dirname, "../mobile/fixtures/native-preview.svg")),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
@@ -775,7 +761,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = visibleFilePane.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const imageCanvas = visibleFilePane.getByTestId("image-file-preview-canvas");
|
||||
const imageCanvas = page.getByTestId("image-file-preview-canvas");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await imageCanvas.hover();
|
||||
const transformedContent = imageCanvas.locator(":scope > div").first();
|
||||
@@ -788,34 +774,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
|
||||
await openWorkspaceFile(page, "shape.svg");
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await expect(image).toBeVisible();
|
||||
await expect(image).toHaveJSProperty("complete", true);
|
||||
await expect(image).toHaveJSProperty("naturalWidth", 240);
|
||||
|
||||
const oldSource = await image.evaluate((element: HTMLImageElement) => ({
|
||||
url: element.currentSrc,
|
||||
width: element.naturalWidth,
|
||||
height: element.naturalHeight,
|
||||
}));
|
||||
const pendingLoad = await holdNextPreviewLoad(visibleFilePane, oldSource.url);
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "shape.svg"),
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="240"><rect width="160" height="240" fill="green"/></svg>',
|
||||
);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(oldSource.url);
|
||||
await pendingLoad.wait();
|
||||
// Native can deliver the old source's queued event to this new listener.
|
||||
await pendingLoad.deliver(oldSource);
|
||||
await expect(imageCanvas).toHaveCount(0);
|
||||
} finally {
|
||||
await pendingLoad.release();
|
||||
}
|
||||
await expect(imageCanvas).toBeVisible();
|
||||
await expectTallImageFrame(transformedContent);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
|
||||
|
||||
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerControlsExample(page, async (composer) => {
|
||||
await composer.narrowTheWindow();
|
||||
await composer.openAgentFeatures();
|
||||
await composer.expectFeatureRow("Needs review");
|
||||
await composer.expectFeatureRow("Insert checklist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginWritingToAnotherWorkspace,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
|
||||
test("a panel in the Explorer inserts at the cursor of the focused agent's composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.typeInComposer("start end", 5);
|
||||
await author.insertChecklistFromPanel();
|
||||
await author.expectComposerText(`start${CHECKLIST} end`);
|
||||
});
|
||||
});
|
||||
|
||||
test("a Command Center item changes an agent's metadata through its composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.expectPanelShowsReview(false);
|
||||
await author.markForReviewFromCommandCenter();
|
||||
await author.expectPanelShowsReview(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("the panel follows which agent is focused", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withComposerToolsExample(page, async (author) => {
|
||||
await author.openComposerToolsInExplorer();
|
||||
await author.expectPanelFocusedAgent(true);
|
||||
await author.focusANewAgentTab();
|
||||
await author.expectPanelFocusedAgent(false);
|
||||
await author.focusTheAgentTab();
|
||||
await author.expectPanelFocusedAgent(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin writes to an agent's composer while its workspace is not on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginWritingToAnotherWorkspace(page, async (author) => {
|
||||
await author.openTargetAgent();
|
||||
await author.goToAnotherWorkspace();
|
||||
await author.insertFromCommandCenter();
|
||||
await author.returnToTargetWorkspace();
|
||||
await author.expectComposerText("A note from a plugin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items";
|
||||
|
||||
for (const layout of [
|
||||
{ name: "desktop", compact: false },
|
||||
{ name: "compact", compact: true },
|
||||
]) {
|
||||
test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-102 Crash on resume");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("pas-102-crash-on-resume");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a branch chosen while Local applies once the user switches to a new worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(page, { compact: false }, async (screen) => {
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
await screen.chooseWorktree();
|
||||
const created = await screen.createWorkspace({ prompt: "" });
|
||||
expect(created.branch).toBe("pas-101-slow-sidebar");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-101" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin's branch applies when the project has no default base to branch off", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, detachedHead: true },
|
||||
async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("pas-101-slow-sidebar");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("a branch and metadata chosen for one project are not sent for another", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, otherProject: true },
|
||||
async (screen) => {
|
||||
await screen.chooseWorktree();
|
||||
await screen.chooseIssue("PAS-101 Slow sidebar");
|
||||
await screen.switchToOtherProject();
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).not.toBe("pas-101-slow-sidebar");
|
||||
expect(created.metadata ?? {}).toEqual({});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an item's Issue list does not keep the previous project's results after a project switch", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, otherProject: true, searchesByProject: true },
|
||||
async (screen) => {
|
||||
await screen.openIssueList();
|
||||
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, true);
|
||||
await screen.closeIssueList();
|
||||
await screen.switchToOtherProject();
|
||||
await screen.openIssueList();
|
||||
await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, setsOnMount: true },
|
||||
async (screen) => {
|
||||
await screen.expectIsolation("worktree");
|
||||
await screen.reopenWithoutPlugin();
|
||||
await screen.expectIsolation("worktree");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("an item can set the branch and metadata from its mount effect", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await withNewWorkspaceIssuesExample(
|
||||
page,
|
||||
{ compact: false, setsOnMount: true },
|
||||
async (screen) => {
|
||||
await screen.expectIsolation("worktree");
|
||||
const created = await screen.createWorkspace();
|
||||
expect(created.branch).toBe("set-on-mount");
|
||||
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -60,15 +60,6 @@ async function verifyChronologicalFiltering(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyHistorySurvivesReload(page: Page): Promise<void> {
|
||||
await test.step("reloads History without visiting a workspace to reconnect", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await page.reload();
|
||||
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
|
||||
await expect(page.getByTestId("sessions-host-errors")).toBeHidden();
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyRecencyBeforeMatchStrength(page: Page): Promise<void> {
|
||||
await test.step("keeps newer partial matches before older stronger matches", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
@@ -160,8 +151,6 @@ test.describe("History search", () => {
|
||||
|
||||
await verifyChronologicalFiltering(page);
|
||||
|
||||
await verifyHistorySurvivesReload(page);
|
||||
|
||||
await verifyRecencyBeforeMatchStrength(page);
|
||||
|
||||
await verifyExactAndTypoHighlights(page);
|
||||
|
||||
@@ -95,63 +95,66 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("keeps sidebar and detail scrolling independent through section changes", async ({
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await test.step("scrolls the sidebar and detail pane independently", async () => {
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
await test.step("keeps the sidebar position after rapid consecutive selections", async () => {
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
await test.step("keeps the sidebar position when switching between app and host sections", async () => {
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectOnlyWorkspaceHoverCardFor,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -82,22 +80,6 @@ async function openWorkspaceHoverCard(page: import("@playwright/test").Page, wor
|
||||
return hoverCard;
|
||||
}
|
||||
|
||||
async function sortWorkspacesBySidebarOrder<T extends { workspaceId: string }>(
|
||||
page: Page,
|
||||
workspaces: T[],
|
||||
): Promise<T[]> {
|
||||
const positioned = await Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const bounds = await page
|
||||
.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))
|
||||
.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspace.workspaceId}`);
|
||||
return { workspace, y: bounds.y };
|
||||
}),
|
||||
);
|
||||
return positioned.sort((left, right) => left.y - right.y).map(({ workspace }) => workspace);
|
||||
}
|
||||
|
||||
interface PaseoOwnedWorktree {
|
||||
projectName: string;
|
||||
workspaceId: string;
|
||||
@@ -448,38 +430,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("scanning up the workspace rows leaves only the resting row's hover card open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspaces = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `sidebar-hover-scan-${index}-`,
|
||||
title: `Hover scan ${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
for (const workspace of workspaces) {
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
}
|
||||
const rowsTopToBottom = await sortWorkspacesBySidebarOrder(page, workspaces);
|
||||
const top = rowsTopToBottom[0]!;
|
||||
|
||||
for (let scan = 0; scan < 3; scan += 1) {
|
||||
await sweepPointerAcrossWorkspaceRows(
|
||||
page,
|
||||
rowsTopToBottom.map((workspace) => workspace.workspaceId).toReversed(),
|
||||
);
|
||||
await expectOnlyWorkspaceHoverCardFor(page, top.workspaceName);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
|
||||
@@ -63,26 +63,6 @@ async function rowsBetweenLatestSummaryAndReply(
|
||||
}, replyTestId);
|
||||
}
|
||||
|
||||
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
|
||||
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
|
||||
const summaryRow = () =>
|
||||
page.evaluate(() => {
|
||||
const latest = (testId: string) =>
|
||||
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
|
||||
const summary = latest("turn-summary");
|
||||
const row = summary.closest("[data-message-id]")!;
|
||||
return {
|
||||
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
|
||||
gapBelowPrompt: Math.round(
|
||||
summary.getBoundingClientRect().top -
|
||||
latest("user-message").getBoundingClientRect().bottom,
|
||||
),
|
||||
};
|
||||
});
|
||||
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
|
||||
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
@@ -228,7 +208,6 @@ export default function contribute(client) {
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await expectSummaryLineSitsApartFromPrompt(page);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
@@ -491,87 +470,34 @@ async function seedWholeTurnHistory() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function expectInitialFillIsConcealed(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
|
||||
}
|
||||
|
||||
async function expectFilledChatAtBottom(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
const chat = page.getByTestId("agent-chat-scroll");
|
||||
await expect(chat).toHaveCSS("opacity", "1");
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((element) => ({
|
||||
hasOverflow: element.scrollHeight > element.clientHeight,
|
||||
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
|
||||
})),
|
||||
)
|
||||
.toEqual({ hasOverflow: true, atBottom: true });
|
||||
}
|
||||
|
||||
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await page.setViewportSize({ width: 1280, height: 8000 });
|
||||
await history.expectRequestedPages(3);
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
history.releasePage(3);
|
||||
await history.expectSettledWithRequestedPages(3);
|
||||
history.expectNoRepeatedEntries();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await expectComposerVisible(page);
|
||||
const latest = page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(3);
|
||||
history.releasePage(3);
|
||||
await page.getByTestId("scroll-to-bottom-button").click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await scrollTimelineRowIntoView(page, latest);
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function panes(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return visible(page, "workspace-split-resize-handle");
|
||||
}
|
||||
|
||||
async function openSplitWorkspace(
|
||||
page: Page,
|
||||
repoPrefix: string,
|
||||
split: "Split pane right" | "Split pane down",
|
||||
): Promise<SeededWorkspace> {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await runWorkspaceActionFromCommandCenter(page, split);
|
||||
await expect(splitDividers(page)).toHaveCount(1);
|
||||
await expect(panes(page)).toHaveCount(2);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function expectOneSharedWorkspaceHeader(page: Page): Promise<void> {
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1);
|
||||
await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The tab counter belongs to the single-pane layout: split panes always show their rails. */
|
||||
async function expectPanesKeepTabRails(page: Page): Promise<void> {
|
||||
for (const pane of await panes(page).all()) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Workspace header with split panes", () => {
|
||||
test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stacked panes share one header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right");
|
||||
try {
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right");
|
||||
try {
|
||||
await dragDivider(splitDividers(page), -300);
|
||||
const [left, right] = await panes(page).all();
|
||||
const leftBox = await left.boundingBox();
|
||||
const rightBox = await right.boundingBox();
|
||||
expect(leftBox!.width).toBeLessThan(500);
|
||||
expect(rightBox!.width).toBeGreaterThan(500);
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing back to one pane returns the merged compact header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right");
|
||||
try {
|
||||
const [, emptyPane] = await panes(page).all();
|
||||
const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row");
|
||||
await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover();
|
||||
await emptyPaneRail.locator('[data-testid*="-close-"]').first().click();
|
||||
await expect(splitDividers(page)).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
# Native SVG preview regression
|
||||
|
||||
Copy `../fixtures/native-preview.svg`, `../fixtures/native-preview-viewbox.svg`, and
|
||||
`../../../assets/images/icon.png` into a workspace on a test daemon. In the Android
|
||||
or iOS app, connect to that daemon, open the workspace's Files tab, and tap
|
||||
`native-preview.svg`. The SVG should show a yellow circle on a green rectangle.
|
||||
|
||||
With that preview open, run:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad \
|
||||
--metro-port <port> --artifacts-dir .artifacts/svg-preview
|
||||
```
|
||||
|
||||
Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions
|
||||
inferred from `viewBox`, then with the PNG open to check raster images. Close and
|
||||
reopen the SVG and rerun to cover cached file previews. Use English app language
|
||||
for the zoom button selectors. The scripts are discovered by `npm run test:e2e:mobile`; the iOS counterpart is
|
||||
`native-svg-preview.ios.ad`. They reopen the installed app with its current
|
||||
preview and leave the daemon connection unchanged.
|
||||
|
||||
Before the fix the SVG remains blank and the canvas wait fails. The PNG passes.
|
||||
The replay checks successful native dimension decoding and zoom/reset controls;
|
||||
visually verify the circle and rectangle as well.
|
||||
@@ -1,11 +0,0 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"image-file-preview-canvas\"" 10000
|
||||
wait "role=button label=\"Zoom in\"" 10000
|
||||
press "role=button label=\"Zoom in\""
|
||||
wait "role=button label=\"Reset view\" enabled=true" 10000
|
||||
press "role=button label=\"Reset view\""
|
||||
wait "role=button label=\"Reset view\" enabled=false" 10000
|
||||
close
|
||||
@@ -1,11 +0,0 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"image-file-preview-canvas\"" 10000
|
||||
wait "role=button label=\"Zoom in\"" 10000
|
||||
press "role=button label=\"Zoom in\""
|
||||
wait "role=button label=\"Reset view\" enabled=true" 10000
|
||||
press "role=button label=\"Reset view\""
|
||||
wait "role=button label=\"Reset view\" enabled=false" 10000
|
||||
close
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>
|
||||
|
Before Width: | Height: | Size: 164 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>
|
||||
|
Before Width: | Height: | Size: 188 B |
@@ -219,11 +219,6 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
|
||||
await page.keyboard.press("Alt+Shift+w");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||
visible: true,
|
||||
@@ -242,10 +237,6 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export async function unarchiveAgentFromConversation(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
function isReady(value: { ready(): boolean }): boolean {
|
||||
return value.ready();
|
||||
}
|
||||
|
||||
/** Replay a queued native load payload after a real file refresh, through the image's load event. */
|
||||
export async function holdNextPreviewLoad(pane: Locator, previousUrl: string) {
|
||||
const pending = await pane.evaluateHandle((container, oldUrl: string) => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
let holding = true;
|
||||
const hold = (event: Event) => {
|
||||
if (holding) event.stopImmediatePropagation();
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
if (image) return;
|
||||
const candidates = container.querySelectorAll<HTMLImageElement>(
|
||||
'[data-testid="image-file-preview-image"] img',
|
||||
);
|
||||
const next = Array.from(candidates).find((candidate) => candidate.src !== oldUrl);
|
||||
if (!next) return;
|
||||
image = next;
|
||||
image.addEventListener("load", hold, true);
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(container, { subtree: true, childList: true, attributes: true });
|
||||
return {
|
||||
ready: () => image !== null && image.complete && image.naturalWidth > 0,
|
||||
deliver(source: { url: string; width: number; height: number }) {
|
||||
if (!image) throw new Error("No pending preview load");
|
||||
// Expo's web adapter reads these fields to produce the same public onLoad
|
||||
// payload that native queues before React dispatches to the current listener.
|
||||
Object.defineProperties(image, {
|
||||
currentSrc: { configurable: true, value: source.url },
|
||||
naturalWidth: { configurable: true, value: source.width },
|
||||
naturalHeight: { configurable: true, value: source.height },
|
||||
});
|
||||
try {
|
||||
holding = false;
|
||||
image.dispatchEvent(new Event("load"));
|
||||
holding = true;
|
||||
} finally {
|
||||
Reflect.deleteProperty(image, "currentSrc");
|
||||
Reflect.deleteProperty(image, "naturalWidth");
|
||||
Reflect.deleteProperty(image, "naturalHeight");
|
||||
}
|
||||
},
|
||||
release() {
|
||||
holding = false;
|
||||
observer.disconnect();
|
||||
image?.removeEventListener("load", hold, true);
|
||||
image?.dispatchEvent(new Event("load"));
|
||||
},
|
||||
};
|
||||
}, previousUrl);
|
||||
return {
|
||||
async wait() {
|
||||
await expect.poll(() => pending.evaluate(isReady)).toBe(true);
|
||||
},
|
||||
async deliver(source: { url: string; width: number; height: number }) {
|
||||
await pending.evaluate((value, payload) => value.deliver(payload), source);
|
||||
},
|
||||
async release() {
|
||||
await pending.evaluate((value) => value.release());
|
||||
await pending.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
const PLUGIN_ID = "composer-controls-example";
|
||||
const NARROW = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` in an agent's composer with the composer-controls example installed. */
|
||||
export async function withComposerControlsExample(
|
||||
page: Page,
|
||||
run: (composer: {
|
||||
narrowTheWindow(): Promise<void>;
|
||||
openAgentFeatures(): Promise<void>;
|
||||
expectFeatureRow(name: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "composer-items-",
|
||||
title: "Composer items",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Composer items agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
narrowTheWindow: () =>
|
||||
test.step("narrow the window until the composer folds its controls away", () =>
|
||||
page.setViewportSize(NARROW)),
|
||||
openAgentFeatures: () =>
|
||||
test.step("open the agent features sheet", async () => {
|
||||
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
|
||||
}),
|
||||
expectFeatureRow: (name) =>
|
||||
test.step(`the sheet has a row for "${name}"`, () =>
|
||||
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const CHECKLIST = "- [ ] Tests\n- [ ] Docs\n";
|
||||
|
||||
type Workspace = Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
|
||||
function prompt(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." });
|
||||
}
|
||||
|
||||
async function createMockAgent(workspace: Workspace, title: string) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string) {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function withComposerToolsExample(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openComposerToolsInExplorer(): Promise<void>;
|
||||
typeInComposer(text: string, cursorAfter: number): Promise<void>;
|
||||
insertChecklistFromPanel(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
markForReviewFromCommandCenter(): Promise<void>;
|
||||
focusANewAgentTab(): Promise<void>;
|
||||
focusTheAgentTab(): Promise<void>;
|
||||
expectPanelFocusedAgent(focused: boolean): Promise<void>;
|
||||
expectPanelShowsReview(needsReview: boolean): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-tools-", title: "Composer tools" });
|
||||
const agent = await createMockAgent(workspace, "Composer tools agent");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("composer-controls");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await run({
|
||||
openComposerToolsInExplorer: () =>
|
||||
test.step("open the Composer tools panel in the Explorer", async () => {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu")
|
||||
.filter({ visible: true })
|
||||
.getByRole("menuitem", { name: "Composer tools", exact: true })
|
||||
.click();
|
||||
await expect(page.getByText("Focused agent: mock", { exact: true })).toBeVisible();
|
||||
}),
|
||||
typeInComposer: (text, cursorAfter) =>
|
||||
test.step(`type "${text}" and put the cursor after ${cursorAfter} characters`, async () => {
|
||||
const input = prompt(page);
|
||||
await input.fill(text);
|
||||
await input.press("Home");
|
||||
for (let index = 0; index < cursorAfter; index += 1) await input.press("ArrowRight");
|
||||
}),
|
||||
insertChecklistFromPanel: () =>
|
||||
test.step("press the panel's button", () =>
|
||||
page
|
||||
.getByRole("button", { name: "Insert checklist into the focused agent", exact: true })
|
||||
.click()),
|
||||
expectComposerText: (text) =>
|
||||
test.step("the composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
markForReviewFromCommandCenter: () =>
|
||||
test.step("run the Command Center item", () =>
|
||||
runCommand(page, "Composer controls: mark for review")),
|
||||
focusANewAgentTab: () =>
|
||||
test.step("focus a new agent tab, which is a draft", () => createAgentTabFromMenu(page)),
|
||||
focusTheAgentTab: () =>
|
||||
test.step("focus the running agent's tab again", () =>
|
||||
page
|
||||
.getByTestId(`workspace-tab-agent_${agent.id}`)
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } })),
|
||||
expectPanelFocusedAgent: (focused) =>
|
||||
test.step(`the panel ${focused ? "names" : "has no"} focused agent`, async () => {
|
||||
await expect(page.getByText("Focused agent: mock", { exact: true })).toHaveCount(
|
||||
focused ? 1 : 0,
|
||||
);
|
||||
await expect(page.getByText("No agent is focused", { exact: true })).toHaveCount(
|
||||
focused ? 0 : 1,
|
||||
);
|
||||
}),
|
||||
expectPanelShowsReview: (needsReview) =>
|
||||
test.step(`the panel's snapshot says needs review: ${needsReview}`, () =>
|
||||
expect(
|
||||
page.getByText(`Needs review: ${needsReview ? "yes" : "no"}`, { exact: true }),
|
||||
).toBeVisible()),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-controls-example");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin that names an agent by id from a Command Center item while the user is in another
|
||||
* workspace: the target's workspace is not on screen, so its composer is not mounted.
|
||||
*/
|
||||
export async function withPluginWritingToAnotherWorkspace(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openTargetAgent(): Promise<void>;
|
||||
goToAnotherWorkspace(): Promise<void>;
|
||||
insertFromCommandCenter(): Promise<void>;
|
||||
returnToTargetWorkspace(): Promise<void>;
|
||||
expectComposerText(text: string): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "composer-ref-", title: "Composer ref" });
|
||||
const elsewhere = await seedWorkspace({ repoPrefix: "composer-away-", title: "Composer away" });
|
||||
const target = await createMockAgent(workspace, "Target agent");
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-ref-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "composer-ref", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
client.addCommandCenterItem({
|
||||
id: "note",
|
||||
title: "Composer ref: add a note",
|
||||
icon: "Flag",
|
||||
context: "global",
|
||||
onSelect({ composers }) {
|
||||
for (const composer of composers({ agentId: ${JSON.stringify(target.id)} })) composer.insertText("A note from a plugin");
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const targetTab = () => page.getByTestId(`workspace-tab-agent_${target.id}`);
|
||||
const workspaceRow = (id: string) =>
|
||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${id}`);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openTargetAgent: () =>
|
||||
test.step("open the agent the plugin will write to", async () => {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: target.id });
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
goToAnotherWorkspace: () =>
|
||||
test.step("go to another workspace, so the agent's composer is not on screen", async () => {
|
||||
await workspaceRow(elsewhere.workspaceId).click();
|
||||
// The app keeps the workspace it left mounted, out of sight.
|
||||
await expect(targetTab().first()).toBeHidden();
|
||||
}),
|
||||
insertFromCommandCenter: () =>
|
||||
test.step("run the plugin's Command Center item", () =>
|
||||
runCommand(page, "Composer ref: add a note")),
|
||||
returnToTargetWorkspace: () =>
|
||||
test.step("return to the agent's workspace without reloading the app", async () => {
|
||||
await workspaceRow(workspace.workspaceId).click();
|
||||
await expect(targetTab().first()).toBeVisible({ timeout: 30_000 });
|
||||
}),
|
||||
expectComposerText: (text) =>
|
||||
test.step("its composer holds the text", () => expect(prompt(page)).toHaveValue(text)),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("composer-ref");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await elsewhere.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export { CHECKLIST };
|
||||
@@ -0,0 +1,228 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { waitForSidebarHydration } from "./workspace-ui";
|
||||
|
||||
/** Presses Create, typing the prompt first unless the test wants an empty one. */
|
||||
async function submitCreate(page: Page, prompt: string | undefined) {
|
||||
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
|
||||
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
|
||||
}
|
||||
|
||||
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
|
||||
async function replaceWithMountEffectItem(directory: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useEffect } from "react";
|
||||
import { useNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Action } from "@getpaseo/plugin/client/ui";
|
||||
function OnMount() {
|
||||
const newWorkspace = useNewWorkspace();
|
||||
useEffect(() => {
|
||||
void newWorkspace.setIsolation("worktree");
|
||||
void newWorkspace.setBranch("set-on-mount");
|
||||
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
|
||||
}, []);
|
||||
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An item whose search depends on the project: it answers with the project's name, and never
|
||||
* answers for the project named "other", so a list that still shows the first project's answer
|
||||
* there is stale.
|
||||
*/
|
||||
async function replaceWithProjectSearchItem(directory: string) {
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`import { useNewWorkspace } from "@getpaseo/plugin/client";
|
||||
import { Combobox } from "@getpaseo/plugin/client/ui";
|
||||
function ProjectIssues() {
|
||||
const { project } = useNewWorkspace();
|
||||
const name = project?.name ?? "";
|
||||
return (
|
||||
<Combobox
|
||||
title="Issue"
|
||||
icon="CircleDot"
|
||||
value={null}
|
||||
search={() =>
|
||||
name.includes("other")
|
||||
? new Promise(() => {})
|
||||
: Promise.resolve([{ id: name, label: "Issue of " + name }])
|
||||
}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addNewWorkspaceItem({ id: "project-issues", Component: ProjectIssues });
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
|
||||
async function readCreatedWorkspace(client: Client, workspaceId: string) {
|
||||
const deadline = Date.now() + 30_000;
|
||||
for (;;) {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
const workspace = entries.find((entry) => entry.id === workspaceId);
|
||||
const branch = workspace?.gitRuntime?.currentBranch ?? null;
|
||||
if (workspace && branch) return { branch, metadata: workspace.metadata };
|
||||
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
const PLUGIN_ID = "new-workspace-issues-example";
|
||||
const COMPACT = { width: 500, height: 800 };
|
||||
|
||||
/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */
|
||||
export async function withNewWorkspaceIssuesExample(
|
||||
page: Page,
|
||||
options: {
|
||||
compact: boolean;
|
||||
detachedHead?: boolean;
|
||||
setsOnMount?: boolean;
|
||||
/** Installs an item whose Issue list depends on the project instead of the example's. */
|
||||
searchesByProject?: boolean;
|
||||
/** A second project the screen can switch to. */
|
||||
otherProject?: boolean;
|
||||
},
|
||||
run: (screen: {
|
||||
chooseWorktree(): Promise<void>;
|
||||
chooseIssue(label: string): Promise<void>;
|
||||
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
|
||||
switchToOtherProject(): Promise<void>;
|
||||
openIssueList(): Promise<void>;
|
||||
closeIssueList(): Promise<void>;
|
||||
/** The Issue list shows `text`, or does not show it when `shown` is false. */
|
||||
expectIssueListText(text: string, shown: boolean): Promise<void>;
|
||||
/** The name the first project's items read as `project.name`. */
|
||||
firstProjectName: string;
|
||||
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
|
||||
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
|
||||
reopenWithoutPlugin(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("new-workspace-issues");
|
||||
if (options.searchesByProject) await replaceWithProjectSearchItem(example.directory);
|
||||
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
|
||||
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
|
||||
const otherRepo = options.otherProject
|
||||
? await createTempGitRepo("new-workspace-other-", { branches: ["main"] })
|
||||
: null;
|
||||
let createdDirectory: string | null = null;
|
||||
try {
|
||||
// A detached HEAD leaves the screen with no default base to branch off.
|
||||
if (options.detachedHead) execFileSync("git", ["checkout", "--detach"], { cwd: repo.path });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
const other = otherRepo ? await openProjectViaDaemon(client, otherRepo.path) : null;
|
||||
let target = project;
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
// The sidebar rows used to reach the screen exist only at desktop width.
|
||||
if (options.compact) await page.setViewportSize(COMPACT);
|
||||
await run({
|
||||
chooseWorktree: () =>
|
||||
test.step("choose a new worktree", async () => {
|
||||
await page.getByTestId("workspace-create-isolation-trigger").click();
|
||||
await page.getByTestId("workspace-create-isolation-worktree").click();
|
||||
}),
|
||||
chooseIssue: (label) =>
|
||||
test.step(`choose the issue "${label}"`, async () => {
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
await page.getByText(label, { exact: true }).click();
|
||||
}),
|
||||
firstProjectName: project.projectDisplayName,
|
||||
openIssueList: () =>
|
||||
test.step("open the Issue list", async () => {
|
||||
await page.getByRole("button", { name: "Issue", exact: true }).click();
|
||||
}),
|
||||
closeIssueList: () => test.step("close the Issue list", () => page.keyboard.press("Escape")),
|
||||
expectIssueListText: (text, shown) =>
|
||||
test.step(`the Issue list ${shown ? "shows" : "does not show"} "${text}"`, async () => {
|
||||
const list = page.getByTestId("combobox-desktop-container").last();
|
||||
await expect(list.getByText(text, { exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}),
|
||||
switchToOtherProject: () =>
|
||||
test.step("switch to the other project", async () => {
|
||||
if (!other) throw new Error("Open the example with otherProject to switch projects");
|
||||
await page.getByRole("button", { name: "Workspace project" }).click();
|
||||
await page
|
||||
.getByTestId("combobox-desktop-container")
|
||||
.last()
|
||||
.getByText(other.projectDisplayName, { exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
|
||||
other.projectDisplayName,
|
||||
);
|
||||
target = other;
|
||||
}),
|
||||
expectIsolation: (isolation) =>
|
||||
test.step(`the screen is on ${isolation}`, () =>
|
||||
expectWorkspaceIsolationSelected(page, isolation)),
|
||||
reopenWithoutPlugin: () =>
|
||||
test.step("reopen the screen without the plugin", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
// Leaving the screen drops its in-memory choice, so only the saved preference is left.
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expect(page.getByRole("button", { name: "Mount", exact: true })).toHaveCount(0);
|
||||
}),
|
||||
createWorkspace: (createOptions) =>
|
||||
test.step("create the workspace and read what it carries", async () => {
|
||||
await submitCreate(page, createOptions?.prompt);
|
||||
const created = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: target.workspaceId,
|
||||
projectDisplayName: target.projectDisplayName,
|
||||
// The sidebar is closed at compact width.
|
||||
assertSidebarRow: !options.compact,
|
||||
});
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
return readCreatedWorkspace(client, created.workspaceId);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (createdDirectory) {
|
||||
await archiveWorkspaceFromDaemon(client, createdDirectory).catch(() => undefined);
|
||||
}
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await repo.cleanup();
|
||||
await otherRepo?.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -96,35 +96,6 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<voi
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
/** Scans the rows in the given order at about a user's pace, then rests on the last one. */
|
||||
export async function sweepPointerAcrossWorkspaceRows(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
): Promise<void> {
|
||||
const centers: Array<{ x: number; y: number }> = [];
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const bounds = await row.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspaceId}`);
|
||||
centers.push({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 });
|
||||
}
|
||||
const [first, ...rest] = centers;
|
||||
await page.mouse.move(first!.x, first!.y);
|
||||
for (const center of rest) {
|
||||
await page.mouse.move(center.x, center.y, { steps: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceHoverCardFor(
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards.getByTestId("hover-card-workspace-name")).toHaveText(workspaceName);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -575,10 +575,8 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The folded turn's summary line is the row under its prompt.
|
||||
const hostId = await promptRow.getAttribute("data-history-row-id");
|
||||
const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`);
|
||||
await expect(summaryRow.getByTestId("turn-summary")).toBeVisible();
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeAgentCommandsCwd(cwd: string): string {
|
||||
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
|
||||
"thinking",
|
||||
draftConfig.thinkingOptionId ?? null,
|
||||
"features",
|
||||
draftConfig.featureValues ?? null,
|
||||
draftConfig.providerOptions ?? null,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
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.
|
||||
*
|
||||
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
|
||||
* the provider's default applies again.
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
* The provider the draft's form has now. An edit belongs to it even before the screen has
|
||||
* rendered the change, so `setOption` observes it first.
|
||||
*/
|
||||
currentProvider?: () => string | null;
|
||||
}): 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();
|
||||
}
|
||||
|
||||
function observeProvider(provider: string | null): void {
|
||||
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() };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
observeProvider,
|
||||
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) => {
|
||||
if (input.currentProvider) observeProvider(input.currentProvider());
|
||||
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 = withoutRemovals({ ...state.seed, ...remembered, ...edits });
|
||||
return Object.keys(own).length > 0 ? own : undefined;
|
||||
}
|
||||
|
||||
function withoutRemovals(options: ProviderOptions): ProviderOptions {
|
||||
return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null));
|
||||
}
|
||||
|
||||
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
|
||||
: withoutRemovals({ ...resolved.options, ...state.edits }),
|
||||
};
|
||||
}
|
||||
|
||||
const NO_OPTIONS: ProviderOptions = {};
|
||||
|
||||
/**
|
||||
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
|
||||
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
|
||||
* the daemon has answered.
|
||||
*/
|
||||
export function shownDraftOptions(
|
||||
state: DraftOptionsState,
|
||||
current: { provider: string | null; requestKey: string },
|
||||
): ProviderOptions {
|
||||
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAgentFeatureIcon } from "./icons";
|
||||
|
||||
describe("getAgentFeatureIcon", () => {
|
||||
it("draws the Lucide icon a plugin provider names", () => {
|
||||
const icon = getAgentFeatureIcon("Mic");
|
||||
expect(icon).not.toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("Mic")).toBe(icon);
|
||||
});
|
||||
|
||||
it("falls back to the generic icon for a missing or unknown name", () => {
|
||||
expect(getAgentFeatureIcon()).toBe(Settings2);
|
||||
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ShieldQuestionMark,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export interface AgentControlIconProps {
|
||||
@@ -61,6 +62,9 @@ export function getAgentModeOptionIcon(
|
||||
return icon ? MODE_ICONS[icon] : undefined;
|
||||
}
|
||||
|
||||
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
|
||||
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
|
||||
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
|
||||
if (!icon) return Settings2;
|
||||
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
|
||||
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
featureValues: {},
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { web_search: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
|
||||
modelId: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { web_search: true },
|
||||
options: { 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({ featureValues: {} }))),
|
||||
).not.toHaveProperty("featureValues");
|
||||
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
|
||||
).not.toHaveProperty("options");
|
||||
});
|
||||
|
||||
it("never carries a provider, which a running agent cannot change", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
options: 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),
|
||||
featureValues: profile.featureValues ?? {},
|
||||
options: profile.options ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
|
||||
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
||||
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
||||
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
||||
...(Object.keys(profile.featureValues).length > 0
|
||||
? { featureValues: profile.featureValues }
|
||||
: {}),
|
||||
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeatureRequestKey,
|
||||
buildControlsRequestKey,
|
||||
openAgentProfileForm,
|
||||
type AgentProfileFormModel,
|
||||
} from "./profile-form-model";
|
||||
@@ -70,6 +70,21 @@ 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" });
|
||||
@@ -173,7 +188,7 @@ describe("openAgentProfileForm", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: { webSearch: true },
|
||||
options: { webSearch: true },
|
||||
notes: "Visual work only.",
|
||||
},
|
||||
});
|
||||
@@ -229,10 +244,11 @@ describe("openAgentProfileForm", () => {
|
||||
selectClaude(model);
|
||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||
model.setThinking("think-hard", { label: "Think hard" });
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.setFeatureValue("webSearch", true);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||
options: { webSearch: false },
|
||||
});
|
||||
model.setOption("webSearch", true);
|
||||
|
||||
model.setProvider("codex", { label: "Codex" });
|
||||
const state = model.getState();
|
||||
@@ -242,8 +258,9 @@ describe("openAgentProfileForm", () => {
|
||||
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
|
||||
expect(state.thinkingOptionId).toBe("");
|
||||
expect(state.thinkingDisplay).toBeNull();
|
||||
expect(state.featureValues).toEqual({});
|
||||
expect(state.features).toEqual([]);
|
||||
expect(state.options).toEqual({});
|
||||
expect(state.optionEdits).toEqual({});
|
||||
expect(state.controls).toEqual([]);
|
||||
expect(state.name).toBe("Test writer");
|
||||
});
|
||||
|
||||
@@ -334,23 +351,28 @@ describe("openAgentProfileForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("features", () => {
|
||||
it("asks for features per provider/model/mode/thinking combination", () => {
|
||||
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", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
expect(model.getState().controlsRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think",
|
||||
});
|
||||
const firstKey = model.getState().featureRequestKey;
|
||||
const firstKey = model.getState().controlsRequestKey;
|
||||
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().featureRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().featureRequest).toEqual({
|
||||
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
|
||||
expect(model.getState().controlsRequest).toEqual({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
modeId: "plan",
|
||||
@@ -361,77 +383,233 @@ describe("openAgentProfileForm", () => {
|
||||
it("moves from pending to complete only for the current request", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
const key = model.getState().featureRequestKey ?? "";
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
const key = model.getState().controlsRequestKey ?? "";
|
||||
expect(model.getState().controlsResolution).toBe("pending");
|
||||
|
||||
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("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(key, [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
it("reflects a set feature value back into the rendered feature", () => {
|
||||
it("reflects a set option back into the rendered controls", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
model.setName("Searcher");
|
||||
selectClaude(model);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
|
||||
model.setOption("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 });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
options: { webSearch: true, sandbox_mode: "read-only" },
|
||||
},
|
||||
});
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||
|
||||
model.setFeatureValue("webSearch", true);
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
|
||||
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
||||
expect(model.getState().controls).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps stored feature values while the feature list is still pending", () => {
|
||||
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: "UI work",
|
||||
name: "Fast",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true },
|
||||
model: "claude-opus-5",
|
||||
options: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
|
||||
|
||||
expect(model.getState().featureResolution).toBe("pending");
|
||||
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
|
||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
|
||||
|
||||
expect(model.getState().submitValue?.options).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prunes stored values the provider no longer reports", () => {
|
||||
const model = openAgentProfileForm({
|
||||
mode: "edit",
|
||||
profile: {
|
||||
id: "p1",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
featureValues: { webSearch: true, retiredFlag: true },
|
||||
},
|
||||
});
|
||||
model.applyProviderCatalog(ENTRIES);
|
||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
||||
]);
|
||||
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);
|
||||
|
||||
expect(model.getState().featureValues).toEqual({ webSearch: 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 });
|
||||
});
|
||||
|
||||
it("resolves an unavailable feature listing without showing the field", () => {
|
||||
const model = openWithCatalog({ mode: "create" });
|
||||
selectClaude(model);
|
||||
|
||||
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
|
||||
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
|
||||
|
||||
expect(model.getState().featureResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showFeaturesField).toBe(false);
|
||||
expect(model.getState().controlsResolution).toBe("complete");
|
||||
expect(model.getState().disclosure.showControlsField).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -464,15 +642,15 @@ describe("openAgentProfileForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeatureRequestKey", () => {
|
||||
describe("buildControlsRequestKey", () => {
|
||||
it("is null without a provider", () => {
|
||||
expect(buildFeatureRequestKey(null)).toBeNull();
|
||||
expect(buildControlsRequestKey(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("separates the fields so a value cannot bleed across positions", () => {
|
||||
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildFeatureRequestKey({ provider: "claude", model: "a" }),
|
||||
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||
buildControlsRequestKey({ provider: "claude", model: "a" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
ProviderOptions,
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -57,11 +58,15 @@ export interface AgentProfileFormSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export interface AgentProfileFeatureRequest {
|
||||
export interface AgentProfileControlsRequest {
|
||||
provider: string;
|
||||
providerOptions?: ProviderOptions;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -73,7 +78,7 @@ export interface AgentProfileFormDisclosure {
|
||||
showModelField: boolean;
|
||||
showModeField: boolean;
|
||||
showThinkingField: boolean;
|
||||
showFeaturesField: boolean;
|
||||
showControlsField: boolean;
|
||||
}
|
||||
|
||||
export interface AgentProfileFormState {
|
||||
@@ -86,13 +91,22 @@ export interface AgentProfileFormState {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
/** The options the profile was opened with, including ones no control draws. */
|
||||
options: ProviderOptions;
|
||||
/** Changes made in this form, over `options`. */
|
||||
optionEdits: ProviderOptions;
|
||||
|
||||
providerOptions: AgentProfileFormOption[];
|
||||
modelOptions: AgentProfileFormOption[];
|
||||
modeOptions: AgentProfileFormOption[];
|
||||
thinkingOptions: AgentProfileFormOption[];
|
||||
features: AgentFeature[];
|
||||
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>;
|
||||
|
||||
providerDisplay: AgentProfileFormDisplay | null;
|
||||
modelDisplay: AgentProfileFormDisplay | null;
|
||||
@@ -100,9 +114,9 @@ export interface AgentProfileFormState {
|
||||
thinkingDisplay: AgentProfileFormDisplay | null;
|
||||
|
||||
catalogResolution: AgentProfileResolutionStatus;
|
||||
featureResolution: AgentProfileResolutionStatus;
|
||||
featureRequest: AgentProfileFeatureRequest | null;
|
||||
featureRequestKey: string | null;
|
||||
controlsResolution: AgentProfileResolutionStatus;
|
||||
controlsRequest: AgentProfileControlsRequest | null;
|
||||
controlsRequestKey: string | null;
|
||||
|
||||
disclosure: AgentProfileFormDisclosure;
|
||||
isSubmitting: boolean;
|
||||
@@ -118,9 +132,12 @@ 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. */
|
||||
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
|
||||
applyControls: (
|
||||
requestKey: string,
|
||||
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
|
||||
) => void;
|
||||
/** Resolve a request that produced no usable features (provider error). */
|
||||
applyFeaturesUnavailable: (requestKey: string) => void;
|
||||
applyControlsUnavailable: (requestKey: string) => void;
|
||||
setName: (value: string) => void;
|
||||
setAppearance: (value: { icon: string; color: string }) => void;
|
||||
setNotes: (value: string) => void;
|
||||
@@ -128,7 +145,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;
|
||||
setFeatureValue: (featureId: string, value: unknown) => void;
|
||||
setOption: (key: string, value: unknown) => void;
|
||||
setSubmitting: (value: boolean) => void;
|
||||
setSubmitError: (value: string | null) => void;
|
||||
}
|
||||
@@ -312,19 +329,39 @@ function refineDisplay(
|
||||
return current ?? { label: selectedId };
|
||||
}
|
||||
|
||||
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
|
||||
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
|
||||
/**
|
||||
* 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 {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
@@ -336,12 +373,25 @@ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | nul
|
||||
].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 } : {}),
|
||||
@@ -350,16 +400,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
|
||||
...(state.modelId ? { model: state.modelId } : {}),
|
||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFeatureStatus(
|
||||
featureRequestKey: string | null,
|
||||
function resolveControlsStatus(
|
||||
controlsRequestKey: string | null,
|
||||
featuresAreCurrent: boolean,
|
||||
): AgentProfileResolutionStatus {
|
||||
if (featureRequestKey === null) {
|
||||
if (controlsRequestKey === null) {
|
||||
return "idle";
|
||||
}
|
||||
return featuresAreCurrent ? "complete" : "pending";
|
||||
@@ -392,25 +442,29 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
||||
modelId,
|
||||
modeId: profile.modeId ?? "",
|
||||
thinkingOptionId: profile.thinkingOptionId ?? "",
|
||||
featureValues: { ...profile.featureValues },
|
||||
options: { ...profile.options },
|
||||
optionEdits: {},
|
||||
providerOptions: [],
|
||||
modelOptions: [],
|
||||
modeOptions: [],
|
||||
thinkingOptions: [],
|
||||
features: [],
|
||||
controls: [],
|
||||
controlOptions: {},
|
||||
drawnControlKeys: new Set(),
|
||||
answeredControlKeys: new Set(),
|
||||
providerDisplay: seedDisplay(provider),
|
||||
modelDisplay: seedDisplay(modelId),
|
||||
modeDisplay: seedDisplay(profile.modeId),
|
||||
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
|
||||
catalogResolution: "idle",
|
||||
featureResolution: "idle",
|
||||
featureRequest: null,
|
||||
featureRequestKey: null,
|
||||
controlsResolution: "idle",
|
||||
controlsRequest: null,
|
||||
controlsRequestKey: null,
|
||||
disclosure: {
|
||||
showModelField: false,
|
||||
showModeField: false,
|
||||
showThinkingField: false,
|
||||
showFeaturesField: false,
|
||||
showControlsField: false,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
@@ -422,28 +476,45 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
||||
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
|
||||
let entries: readonly ProviderSnapshotEntry[] = [];
|
||||
let catalogResolution: AgentProfileResolutionStatus = "idle";
|
||||
let resolvedFeatureKey: string | null = null;
|
||||
let resolvedFeatures: AgentFeature[] = [];
|
||||
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 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 = seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
});
|
||||
const next: AgentProfileFormState = {
|
||||
...seedSelections(incoming, {
|
||||
entry: findEntry(entries, incoming.provider),
|
||||
models,
|
||||
modes,
|
||||
}),
|
||||
drawnControlKeys: drawnKeys,
|
||||
answeredControlKeys: answeredKeys,
|
||||
};
|
||||
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
|
||||
const featureRequest = buildFeatureRequest(next);
|
||||
const featureRequestKey = buildFeatureRequestKey(featureRequest);
|
||||
const controlsRequest = buildControlsRequest(next);
|
||||
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
|
||||
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
|
||||
const featuresAreCurrent =
|
||||
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
|
||||
const features = featuresAreCurrent
|
||||
? applyFeatureValues(resolvedFeatures, next.featureValues)
|
||||
: [];
|
||||
const featureResolution = resolveFeatureStatus(featureRequestKey, 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);
|
||||
|
||||
const withOptions: AgentProfileFormState = {
|
||||
...next,
|
||||
@@ -451,7 +522,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
modelOptions: buildModelOptions(models),
|
||||
modeOptions: buildModeOptions(modes),
|
||||
thinkingOptions: buildThinkingOptions(thinking),
|
||||
features,
|
||||
controls,
|
||||
controlOptions,
|
||||
providerDisplay: refineDisplay(
|
||||
next.providerDisplay,
|
||||
next.provider,
|
||||
@@ -479,9 +551,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
})(),
|
||||
),
|
||||
catalogResolution,
|
||||
featureResolution,
|
||||
featureRequest,
|
||||
featureRequestKey,
|
||||
controlsResolution,
|
||||
controlsRequest,
|
||||
controlsRequestKey,
|
||||
};
|
||||
|
||||
// A field appears once the catalog can populate it, and stays visible while
|
||||
@@ -492,7 +564,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
|
||||
showThinkingField:
|
||||
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
|
||||
showFeaturesField: hasProvider && features.length > 0,
|
||||
showControlsField: hasProvider && controls.length > 0,
|
||||
};
|
||||
const canSubmit =
|
||||
withOptions.name.trim().length > 0 &&
|
||||
@@ -531,23 +603,26 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
catalogResolution = "complete";
|
||||
publish((current) => current);
|
||||
},
|
||||
applyFeatures: (requestKey, features) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
applyControls: (requestKey, resolved) => {
|
||||
if (requestKey !== state.controlsRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [...features];
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
|
||||
}));
|
||||
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);
|
||||
},
|
||||
applyFeaturesUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.featureRequestKey) {
|
||||
applyControlsUnavailable: (requestKey) => {
|
||||
if (requestKey !== state.controlsRequestKey) {
|
||||
return;
|
||||
}
|
||||
resolvedFeatureKey = requestKey;
|
||||
resolvedFeatures = [];
|
||||
resolvedControlsKey = requestKey;
|
||||
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||
resolvedControls = [];
|
||||
resolvedOptions = {};
|
||||
publish((current) => current);
|
||||
},
|
||||
setName: (value) => publish((current) => ({ ...current, name: value })),
|
||||
@@ -562,6 +637,8 @@ 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,
|
||||
@@ -572,7 +649,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
modeDisplay: null,
|
||||
thinkingOptionId: "",
|
||||
thinkingDisplay: null,
|
||||
featureValues: {},
|
||||
options: {},
|
||||
optionEdits: {},
|
||||
};
|
||||
}),
|
||||
setModel: (modelId, display) =>
|
||||
@@ -598,10 +676,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
||||
publish((current) => ({ ...current, modeId, modeDisplay: display })),
|
||||
setThinking: (thinkingOptionId, display) =>
|
||||
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
|
||||
setFeatureValue: (featureId, value) =>
|
||||
setOption: (key, value) =>
|
||||
publish((current) => ({
|
||||
...current,
|
||||
featureValues: { ...current.featureValues, [featureId]: value },
|
||||
optionEdits: { ...current.optionEdits, [key]: 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.featureValues ?? {}).length;
|
||||
const featureCount = Object.keys(input.profile.options ?? {}).length;
|
||||
if (featureCount > 0) {
|
||||
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
|
||||
}
|
||||
|
||||
@@ -117,9 +117,7 @@ export function useAgentProfilePicker(
|
||||
modelId: resolved.modelId,
|
||||
modeId: resolved.modeId,
|
||||
thinkingOptionId: resolved.thinkingOptionId,
|
||||
...(Object.keys(resolved.featureValues).length > 0
|
||||
? { featureValues: resolved.featureValues }
|
||||
: {}),
|
||||
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
|
||||
}, [entries, input.model]);
|
||||
}
|
||||
|
||||
export function useAgentProfileFormFeatures(input: {
|
||||
export function useAgentProfileFormControls(input: {
|
||||
serverId: string;
|
||||
model: AgentProfileFormModel;
|
||||
state: AgentProfileFormState;
|
||||
@@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(input.serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||
const request = input.state.featureRequest;
|
||||
const requestKey = input.state.featureRequestKey;
|
||||
const request = input.state.controlsRequest;
|
||||
const requestKey = input.state.controlsRequestKey;
|
||||
|
||||
// `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 featuresQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
|
||||
const controlsQuery = useFetchQuery({
|
||||
queryKey: ["agentProfileControls", input.serverId, requestKey],
|
||||
dataShape: "value",
|
||||
staleTimeMs: 5 * 60 * 1000,
|
||||
enabled: Boolean(client && isConnected && request),
|
||||
@@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: {
|
||||
if (!client || !request) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.features ?? [];
|
||||
return { controls: payload.controls ?? [], options: payload.options ?? {} };
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isError } = featuresQuery;
|
||||
const { data, isError } = controlsQuery;
|
||||
const { model } = input;
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !data) {
|
||||
return;
|
||||
}
|
||||
model.applyFeatures(requestKey, data);
|
||||
model.applyControls(requestKey, data);
|
||||
}, [data, model, requestKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestKey || !isError) {
|
||||
return;
|
||||
}
|
||||
model.applyFeaturesUnavailable(requestKey);
|
||||
model.applyControlsUnavailable(requestKey);
|
||||
}, [isError, model, requestKey]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ 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 { AgentFeature } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,7 +25,7 @@ import type {
|
||||
} from "../internal/profile-form-model";
|
||||
import {
|
||||
useAgentProfileFormCatalog,
|
||||
useAgentProfileFormFeatures,
|
||||
useAgentProfileFormControls,
|
||||
} from "../internal/use-profile-form-inputs";
|
||||
import {
|
||||
useAgentProfileFormModel,
|
||||
@@ -140,7 +141,7 @@ function OpenAgentProfileEditModal({
|
||||
const model = useAgentProfileFormModel(snapshot);
|
||||
const state = useAgentProfileFormState(model);
|
||||
useAgentProfileFormCatalog({ serverId, model });
|
||||
useAgentProfileFormFeatures({ serverId, model, state });
|
||||
useAgentProfileFormControls({ serverId, model, state });
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
@@ -327,16 +328,17 @@ function OpenAgentProfileEditModal({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showFeaturesField ? (
|
||||
{state.disclosure.showControlsField ? (
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.featuresLabel")}
|
||||
testID="agent-profile-features-field"
|
||||
>
|
||||
<View style={styles.featureCard} testID="agent-profile-features-card">
|
||||
{state.features.map((feature, index) => (
|
||||
<AgentProfileFeatureRow
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
{state.controls.map((control, index) => (
|
||||
<AgentProfileControlRow
|
||||
key={control.key}
|
||||
control={control}
|
||||
options={state.controlOptions}
|
||||
isFirst={index === 0}
|
||||
disabled={state.isSubmitting}
|
||||
size={controlSize}
|
||||
@@ -399,14 +401,16 @@ function OpenAgentProfileEditModal({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfileFeatureRow({
|
||||
feature,
|
||||
function AgentProfileControlRow({
|
||||
control,
|
||||
options,
|
||||
isFirst,
|
||||
disabled,
|
||||
size,
|
||||
model,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
control: AgentOptionControl;
|
||||
options: ProviderOptions;
|
||||
isFirst: boolean;
|
||||
disabled: boolean;
|
||||
size: ControlSize;
|
||||
@@ -417,74 +421,75 @@ function AgentProfileFeatureRow({
|
||||
[isFirst],
|
||||
);
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
(value: boolean) => model.setOption(control.key, value),
|
||||
[control.key, model],
|
||||
);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => model.setFeatureValue(feature.id, value),
|
||||
[feature.id, model],
|
||||
(value: string) => model.setOption(control.key, value),
|
||||
[control.key, model],
|
||||
);
|
||||
const selectOptions = useMemo<SelectFieldOption<string>[]>(
|
||||
() =>
|
||||
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.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],
|
||||
[control],
|
||||
);
|
||||
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
|
||||
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
|
||||
if (feature.type !== "select" || !feature.value) {
|
||||
if (control.type !== "select" || !selectedValue) {
|
||||
return null;
|
||||
}
|
||||
const option = feature.options.find((candidate) => candidate.id === feature.value);
|
||||
return { label: option?.label ?? feature.value };
|
||||
}, [feature]);
|
||||
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
|
||||
return { label: choice?.label ?? selectedValue };
|
||||
}, [control, selectedValue]);
|
||||
|
||||
if (feature.type === "select") {
|
||||
if (control.type === "select") {
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<SelectField
|
||||
label={feature.label}
|
||||
value={feature.value}
|
||||
label={control.label}
|
||||
value={selectedValue}
|
||||
selectedDisplay={selectedDisplay}
|
||||
options={selectOptions}
|
||||
onChange={handleSelect}
|
||||
placeholder={feature.label}
|
||||
emptyText={feature.label}
|
||||
placeholder={control.label}
|
||||
emptyText={control.label}
|
||||
disabled={disabled}
|
||||
{...(feature.description ? { hint: feature.description } : {})}
|
||||
title={feature.label}
|
||||
{...(control.description ? { hint: control.description } : {})}
|
||||
title={control.label}
|
||||
size={size}
|
||||
testID={`agent-profile-feature-field-${feature.id}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
|
||||
testID={`agent-profile-feature-field-${control.key}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<View style={styles.featureMeta}>
|
||||
<Text style={styles.featureLabel} numberOfLines={1}>
|
||||
{feature.label}
|
||||
{control.label}
|
||||
</Text>
|
||||
{feature.description ? (
|
||||
{control.description ? (
|
||||
<Text style={styles.featureDescription} numberOfLines={2}>
|
||||
{feature.description}
|
||||
{control.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={feature.value === true}
|
||||
value={readToggleOption(control, options)}
|
||||
onValueChange={handleToggle}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={feature.label}
|
||||
testID={`agent-profile-feature-switch-${feature.id}`}
|
||||
accessibilityLabel={control.label}
|
||||
testID={`agent-profile-feature-switch-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,310 +1,197 @@
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createHistoryStartPagination,
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const initial: HistoryStartPaginationInput = {
|
||||
function createArmedHistoryStartPaginationState() {
|
||||
return rearmHistoryStartPagination(createHistoryStartPaginationState());
|
||||
}
|
||||
|
||||
const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
isReady: true,
|
||||
progressKey: "20",
|
||||
progressKey: "epoch-1:20",
|
||||
};
|
||||
|
||||
function harness(initiallyVisible = false) {
|
||||
let geometry = { ...initial };
|
||||
const requests: Array<(value: boolean) => void> = [];
|
||||
let settles = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible,
|
||||
read: () => geometry,
|
||||
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {
|
||||
settles++;
|
||||
},
|
||||
});
|
||||
return {
|
||||
pagination,
|
||||
requests,
|
||||
get settles() {
|
||||
return settles;
|
||||
},
|
||||
set(patch: Partial<HistoryStartPaginationInput>) {
|
||||
geometry = { ...geometry, ...patch };
|
||||
pagination.update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("waits for a delayed request and its committed page before settling", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(1);
|
||||
expect(h.pagination.state.visible).toBe(false);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("waits for the committed page after a request completes", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(0);
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("continues short pages and reveals on exhaustion", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.set({ hasOlderHistory: false });
|
||||
h.requests[1](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("releases a failed or stalled request and allows retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("keeps visible history visible while filling", () => {
|
||||
const h = harness(true);
|
||||
h.pagination.update();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("does not load a full viewport until user intent and respects inactive views", () => {
|
||||
const h = harness();
|
||||
h.set({ isUnderfilled: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: false });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: true });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("preserves the reading position after an anchored page moves away from the start", async () => {
|
||||
const h = harness(true);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.rearm();
|
||||
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ distanceFromHistoryStart: 0 });
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a timed-out response after the reader retries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
h.pagination.rearm();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("loading");
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not repeat a successful page that made no progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.update();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits until the cursor and active history are available", () => {
|
||||
const h = harness();
|
||||
h.set({ isReady: false, progressKey: null });
|
||||
h.set({ isReady: true, isLoadingOlderHistory: true });
|
||||
h.set({ isLoadingOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ progressKey: "20" });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("reveals immediately on exhausted history without offering retry", () => {
|
||||
const h = harness();
|
||||
h.set({ hasOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps a settled page pending while its view is inactive", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.set({ isReady: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.status).toBe("settling");
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ isReady: true, isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
|
||||
const h = harness();
|
||||
h.pagination.reveal();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"handles native padding when short history has older=%s",
|
||||
(hasOlderHistory) => {
|
||||
const geometry = nativeHistoryStartGeometry({
|
||||
contentHeight: 825,
|
||||
viewportHeight: 700,
|
||||
offsetY: 125,
|
||||
topOverlayInset: 123,
|
||||
it("treats locally revealed history as progress before remote pagination", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.update();
|
||||
expect(requests).toBe(hasOlderHistory ? 1 : 0);
|
||||
expect(pagination.state.visible).toBe(!hasOlderHistory);
|
||||
pagination.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the forward native list's top edge to request older history", () => {
|
||||
let offsetY = 1300;
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: true,
|
||||
read: () => ({
|
||||
...initial,
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: 2000,
|
||||
viewportHeight: 700,
|
||||
offsetY,
|
||||
topOverlayInset: 123,
|
||||
}),
|
||||
}),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
expect(first.shouldLoad).toBe(true);
|
||||
expect(localReveal.state).toEqual({
|
||||
status: "settling",
|
||||
loadedProgressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
});
|
||||
pagination.rearm();
|
||||
expect(requests).toBe(0);
|
||||
offsetY = 155;
|
||||
pagination.update();
|
||||
expect(requests).toBe(1);
|
||||
pagination.dispose();
|
||||
});
|
||||
it("waits for user scroll intent before loading", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||
|
||||
it.each([null, { ...initial, isReady: false }])(
|
||||
"resumes a completed page after its view stays inactive beyond the deadline (%j)",
|
||||
async (inactive) => {
|
||||
vi.useFakeTimers();
|
||||
let geometry: HistoryStartPaginationInput | null = { ...initial };
|
||||
let complete!: (applied: boolean) => void;
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => geometry,
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
|
||||
});
|
||||
|
||||
it("waits for anchored page geometry before authorizing another page", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
try {
|
||||
pagination.update();
|
||||
geometry = inactive;
|
||||
pagination.update();
|
||||
complete(true);
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
geometry = { ...initial, progressKey: "10" };
|
||||
pagination.update();
|
||||
expect(pagination.state.status).toBe("settling");
|
||||
geometry = inactive;
|
||||
pagination.update();
|
||||
pagination.settle();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(pagination.state.status).toBe("settling");
|
||||
geometry = { ...initial, progressKey: "10" };
|
||||
pagination.update();
|
||||
pagination.settle();
|
||||
expect(requests).toBe(2);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(pagination.state.status).toBe("failed");
|
||||
} finally {
|
||||
pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
|
||||
});
|
||||
|
||||
it("loads one page each time anchored geometry leaves and returns to history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const settledAway = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(settledAway.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([
|
||||
first.shouldLoad,
|
||||
pageApplied.shouldLoad,
|
||||
settledAway.shouldLoad,
|
||||
returned.shouldLoad,
|
||||
]).toEqual([true, false, false, true]);
|
||||
});
|
||||
|
||||
it("continues the same loading operation when anchored geometry remains at history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const continued = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect(continued.shouldLoad).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
|
||||
});
|
||||
|
||||
it("latches a request that finishes without cursor progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
|
||||
const away = evaluateHistoryStartPagination(duplicate.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
});
|
||||
|
||||
expect(finished.state).toEqual({ status: "latched" });
|
||||
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(away.state).toEqual({ status: "ready" });
|
||||
});
|
||||
|
||||
it("allows another user attempt after a request finishes without progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(failed.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(failed.state).toEqual({ status: "latched" });
|
||||
expect(retried.shouldLoad).toBe(true);
|
||||
});
|
||||
|
||||
it("latches an attempt that becomes invalid before entering flight", () => {
|
||||
const requested = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
|
||||
status: "latched",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake repeated edge observations for a finished request", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
|
||||
expect(repeated.state).toBe(first.state);
|
||||
expect(repeated.shouldLoad).toBe(false);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
const state = createArmedHistoryStartPaginationState();
|
||||
|
||||
expect([
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
|
||||
.shouldLoad,
|
||||
]).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,149 +1,138 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export type HistoryStartPaginationState =
|
||||
| { status: "dormant" }
|
||||
| { status: "ready" }
|
||||
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
|
||||
| { status: "settling"; loadedProgressKey: string }
|
||||
| { status: "latched" };
|
||||
|
||||
export interface HistoryStartPaginationInput {
|
||||
distanceFromHistoryStart: number;
|
||||
isUnderfilled: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryStartPaginationState {
|
||||
status: "dormant" | "ready" | "loading" | "settling" | "failed";
|
||||
visible: boolean;
|
||||
export interface HistoryStartPaginationTransition {
|
||||
state: HistoryStartPaginationState;
|
||||
shouldLoad: boolean;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { status: "dormant" };
|
||||
}
|
||||
|
||||
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
|
||||
return state.status === "loading" || state.status === "settling";
|
||||
}
|
||||
|
||||
/** Owns one pagination operation, including the initial viewport reveal. */
|
||||
export function createHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
changed: (state: HistoryStartPaginationState) => void;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
|
||||
let requestedKey: string | null = null;
|
||||
let completed = false;
|
||||
let fill = false;
|
||||
let generation = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
export function rearmHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
|
||||
}
|
||||
|
||||
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
|
||||
if (status === state.status && visible === state.visible) return;
|
||||
state = { status, visible };
|
||||
input.changed(state);
|
||||
};
|
||||
const clear = () => {
|
||||
generation++;
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
};
|
||||
const finish = (status: HistoryStartPaginationState["status"]) => {
|
||||
clear();
|
||||
publish(status, true);
|
||||
};
|
||||
const fail = () => finish("failed");
|
||||
export function abandonHistoryStartPaginationRequest(
|
||||
state: HistoryStartPaginationState,
|
||||
requestedProgressKey: string,
|
||||
): HistoryStartPaginationState {
|
||||
if (
|
||||
state.status !== "loading" ||
|
||||
state.requestObserved ||
|
||||
state.requestedProgressKey !== requestedProgressKey
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { status: "latched" };
|
||||
}
|
||||
|
||||
const watchProgress = (geometry: HistoryStartPaginationInput | null) => {
|
||||
if (completed && !geometry?.isReady) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
} else if (timeout === undefined) {
|
||||
const request = generation;
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined;
|
||||
// A completed request waits for its retained view to become measurable again.
|
||||
// Unfinished requests still time out even while the view is inactive.
|
||||
if (request === generation && (!completed || input.read()?.isReady)) fail();
|
||||
}, 15_000);
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status === "dormant") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
|
||||
return {
|
||||
state: { status: "settling", loadedProgressKey: input.progressKey },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
if (input.isLoadingOlderHistory && !state.requestObserved) {
|
||||
return { state: { ...state, requestObserved: true }, shouldLoad: false };
|
||||
}
|
||||
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.requestObserved &&
|
||||
!input.isLoadingOlderHistory &&
|
||||
input.progressKey === state.requestedProgressKey
|
||||
) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const geometry = input.read();
|
||||
if (isHistoryStartLoadingOperation(state)) watchProgress(geometry);
|
||||
if (!geometry) return;
|
||||
if (state.status === "loading") {
|
||||
if (!completed) return;
|
||||
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
|
||||
publish("settling");
|
||||
input.scheduleSettle();
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
|
||||
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (state.status === "dormant" && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
return;
|
||||
}
|
||||
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
|
||||
if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
return;
|
||||
}
|
||||
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
|
||||
fill = geometry.isUnderfilled;
|
||||
requestedKey = geometry.progressKey;
|
||||
completed = false;
|
||||
publish("loading");
|
||||
const request = ++generation;
|
||||
watchProgress(geometry);
|
||||
void input.load().then(
|
||||
(applied) => {
|
||||
if (request !== generation) return undefined;
|
||||
if (!applied) {
|
||||
fail();
|
||||
return undefined;
|
||||
}
|
||||
completed = true;
|
||||
update();
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
if (request === generation) fail();
|
||||
},
|
||||
);
|
||||
};
|
||||
if (state.status === "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (!isAtHistoryStart) {
|
||||
return state.status === "ready"
|
||||
? { state, shouldLoad: false }
|
||||
: { state: { status: "ready" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.status === "latched" ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
update,
|
||||
settle() {
|
||||
if (state.status !== "settling") return;
|
||||
const geometry = input.read();
|
||||
if (!geometry?.isReady) return;
|
||||
clear();
|
||||
if (fill && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
publish("ready", true);
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
publish("dormant", true);
|
||||
} else {
|
||||
publish("ready");
|
||||
update();
|
||||
}
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function settleHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status !== "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (
|
||||
!isAtHistoryStart ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return {
|
||||
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
},
|
||||
rearm() {
|
||||
if (state.status === "dormant" || state.status === "failed") publish("ready", true);
|
||||
update();
|
||||
},
|
||||
reveal() {
|
||||
publish(state.status, true);
|
||||
},
|
||||
reset(visible: boolean) {
|
||||
clear();
|
||||
publish("dormant", visible);
|
||||
},
|
||||
dispose: clear,
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export function nativeHistoryStartGeometry(input: {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
offsetY: number;
|
||||
topOverlayInset: number;
|
||||
}) {
|
||||
const contentHeight = input.contentHeight - 32 - input.topOverlayInset;
|
||||
return {
|
||||
distanceFromHistoryStart: Math.max(0, input.offsetY - input.topOverlayInset - 32),
|
||||
isUnderfilled: contentHeight < input.viewportHeight,
|
||||
};
|
||||
}
|
||||
@@ -608,11 +608,8 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", ()
|
||||
display: "collapseTurns",
|
||||
isTurnActive: false,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
|
||||
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
|
||||
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
|
||||
});
|
||||
|
||||
@@ -639,14 +636,12 @@ it("keeps streaming replies native, then folds work without losing manual expans
|
||||
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
const expandedTurnHostIds = new Set(["prompt"]);
|
||||
const opened = present({ ...completed, expandedTurnHostIds });
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
@@ -654,7 +649,6 @@ it("keeps streaming replies native, then folds work without losing manual expans
|
||||
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
});
|
||||
@@ -681,7 +675,7 @@ it.each(["full", "collapseToolCalls"] as const)(
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(result.turns.summaryRowsByHostId.size).toBe(0);
|
||||
expect(result.turns.turnsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -703,13 +697,7 @@ it("opening a turn reveals individual calls rather than another tool group", ()
|
||||
isTurnActive: false,
|
||||
expandedTurnHostIds: new Set(["prompt"]),
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"tool-one",
|
||||
"tool-two",
|
||||
"reply",
|
||||
]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
@@ -751,7 +739,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn's host.
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
@@ -800,7 +788,7 @@ it("does not reopen the previous turn while a new run waits for its first row",
|
||||
isTurnActive: true,
|
||||
activeTurnId: "new",
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
@@ -822,7 +810,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
@@ -879,13 +867,7 @@ it.each([false, true])(
|
||||
const closed = present(input);
|
||||
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
|
||||
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"thought",
|
||||
"tool-one",
|
||||
"reply",
|
||||
]);
|
||||
expect(opened.tail).toEqual(tail);
|
||||
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
|
||||
expect(opened.groupsByHostId.size).toBe(0);
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
previousSummaryRowsByHostId: new Map(),
|
||||
previousTurnsByHostId: new Map(),
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
@@ -135,8 +135,7 @@ export function createStreamPresentation() {
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
previousSummaryRowsByHostId:
|
||||
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
|
||||
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
const message: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
@@ -8,27 +7,6 @@ const message: StreamItem = {
|
||||
text: "First\n\nSecond",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const prompt: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "p",
|
||||
text: "Fix it",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "t",
|
||||
text: "Reading",
|
||||
timestamp: new Date(0),
|
||||
status: "ready",
|
||||
};
|
||||
const summary: StreamItem = {
|
||||
kind: "turn_summary",
|
||||
id: "turn-summary:p",
|
||||
hostId: "p",
|
||||
expanded: false,
|
||||
timestamp: new Date(0),
|
||||
tools: summarizeToolCalls([]),
|
||||
};
|
||||
describe("whole-message spacing", () => {
|
||||
it("compacts only the gap to its turn footer", () => {
|
||||
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
|
||||
@@ -39,18 +17,4 @@ describe("whole-message spacing", () => {
|
||||
it("keeps separate messages separated", () => {
|
||||
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
|
||||
});
|
||||
it("sets a prompt the same distance above whatever answers it", () => {
|
||||
const belowPrompt = getGapBetweenStreamItems(prompt, thought);
|
||||
expect(belowPrompt).toBeGreaterThan(0);
|
||||
expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt);
|
||||
expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt);
|
||||
});
|
||||
it("spaces a turn's summary row like the work rows it stands for", () => {
|
||||
expect(getGapBetweenStreamItems(summary, message)).toBe(
|
||||
getGapBetweenStreamItems(thought, message),
|
||||
);
|
||||
expect(getGapBetweenStreamItems(thought, summary)).toBe(
|
||||
getGapBetweenStreamItems(thought, thought),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,8 @@ export function getAssistantMessageSpacing(params: {
|
||||
}
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||
/** A row of the agent's work, including the summary row that stands in for a folded turn's. */
|
||||
const isWorkItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" ||
|
||||
item?.kind === "thought" ||
|
||||
item?.kind === "todo_list" ||
|
||||
item?.kind === "turn_summary";
|
||||
const isToolSequenceItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
|
||||
export function getGapBetweenStreamItems(
|
||||
item: StreamItem | null,
|
||||
@@ -29,16 +25,19 @@ export function getGapBetweenStreamItems(
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isUserMessageItem(item)) {
|
||||
return SPACING[4];
|
||||
}
|
||||
if (isWorkItem(item) && isWorkItem(belowItem)) {
|
||||
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return SPACING[4];
|
||||
}
|
||||
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return SPACING[1];
|
||||
}
|
||||
return SPACING[4];
|
||||
|
||||
@@ -25,9 +25,6 @@ import {
|
||||
} from "@legendapp/list/react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -41,11 +38,15 @@ import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
@@ -107,16 +108,6 @@ function distanceFromEnd(input: {
|
||||
}): number {
|
||||
return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY);
|
||||
}
|
||||
const viewportStyle: ViewStyle = { flex: 1 };
|
||||
const initialFillStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -134,8 +125,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
@@ -146,8 +135,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
baseListContentContainerStyle,
|
||||
strategy,
|
||||
} = props;
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
|
||||
@@ -170,6 +157,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isReleasedAtEndRef = useRef(false);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
@@ -200,36 +191,48 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
return {
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: metrics.contentHeight,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
offsetY: metrics.offsetY,
|
||||
topOverlayInset,
|
||||
}),
|
||||
distanceFromHistoryStart: metrics.offsetY,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady:
|
||||
isActive &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
historyStartReadyRef.current &&
|
||||
hasMeasuredViewport,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (transition.shouldLoad) {
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
if (requestedProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const scheduleHistoryStartSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -237,9 +240,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: requestAnimationFrame,
|
||||
cancelFrame: cancelAnimationFrame,
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
onSettle: () => {
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -381,11 +390,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]);
|
||||
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -498,7 +506,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
finishInitialFill();
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
@@ -507,13 +514,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// no row LegendList may anchor a history page on. The gesture's end decides following.
|
||||
disclosureHold.cancel();
|
||||
bottomAnchorController.beginUserScroll();
|
||||
historyStartPagination.rearm();
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed !== historyStartPaginationStateRef.current) {
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
// A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there.
|
||||
// A release past the end bounces back to it on iOS, and lines that land during the bounce scroll
|
||||
// in as they do while following. Momentum stopping short of the end, or output that arrived below
|
||||
@@ -590,7 +598,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
publishNearBottom();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
});
|
||||
@@ -603,23 +611,19 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartSettle,
|
||||
isHistoryStartSettling,
|
||||
]);
|
||||
|
||||
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
|
||||
@@ -654,65 +658,51 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<View style={historyStartSlotStyle} testID="older-history-slot">
|
||||
{isLoadingOperation && (
|
||||
{isLoadingOperation ? (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
|
||||
}, [historyStartPaginationState]);
|
||||
|
||||
return (
|
||||
<View style={viewportStyle}>
|
||||
<LegendList<StreamItem>
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
alignItemsAtEnd
|
||||
initialScrollAtEnd
|
||||
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
|
||||
getEstimatedItemSize={getEstimatedItemSize}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
/>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<LegendList<StreamItem>
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
alignItemsAtEnd
|
||||
initialScrollAtEnd
|
||||
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
|
||||
getEstimatedItemSize={getEstimatedItemSize}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: async () => true,
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
@@ -31,10 +30,15 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
@@ -73,17 +77,7 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
|
||||
const initialFillStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
// Rows and the live auxiliary (turn footer, pending permissions) center their content with
|
||||
// `alignSelf`, which only applies inside a flex container.
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -304,8 +298,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart,
|
||||
@@ -326,9 +318,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
[historyMounted, historyVirtualized, inputSegments],
|
||||
);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const rowsHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
@@ -362,6 +351,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const readingAnchor = useMemo(createReadingAnchor, []);
|
||||
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
@@ -488,36 +481,60 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
return {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
isUnderfilled:
|
||||
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
|
||||
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
|
||||
scrollContainer.clientHeight,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
// An explicit jump owns the landing position. Revealing another local
|
||||
// page here changes the virtual index before the target has mounted.
|
||||
isReady:
|
||||
historyStartReadyRef.current &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
bottomAnchorSettled &&
|
||||
!isJumpSettling(),
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
},
|
||||
);
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed === historyStartPaginationStateRef.current) {
|
||||
return;
|
||||
}
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -525,12 +542,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return !input || input.isLoadingOlderHistory;
|
||||
},
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -767,7 +794,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
finishInitialFill();
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
@@ -775,10 +801,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
rearmHistoryStartFromUserIntent();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
@@ -824,11 +846,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [disposeHistoryStart, evaluateHistoryStart]);
|
||||
}, [evaluateHistoryStart]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -917,18 +938,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
updateScrollMetrics();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -967,7 +986,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (isHistoryStartSettling()) {
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
updateScrollMetrics();
|
||||
@@ -1001,7 +1020,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
forceStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
@@ -1223,9 +1241,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Detached reading has one owner: reconcileReadingPosition.
|
||||
overflowAnchor: followOutput ? "auto" : "none",
|
||||
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
|
||||
opacity: historyStartPaginationState.visible ? 1 : 0,
|
||||
};
|
||||
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
|
||||
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
|
||||
const viewportStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: "relative",
|
||||
@@ -1302,21 +1319,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="older-history-slot">
|
||||
{isLoadingOperation && (
|
||||
{isLoadingOperation ? (
|
||||
<div data-testid="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
|
||||
{t("paneFind.retry")}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
|
||||
}, [historyStartPaginationState]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
@@ -1336,7 +1346,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
<div ref={rowsHostRef} style={streamRowsHostStyle}>
|
||||
<div style={streamRowsHostStyle}>
|
||||
<div style={virtualRowsContainerStyle} />
|
||||
{[
|
||||
...virtualRows.map((virtualRow) => {
|
||||
@@ -1364,17 +1374,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
<div ref={auxiliaryRef} style={streamRowStyle}>
|
||||
{liveAuxiliary}
|
||||
</div>
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<div data-testid="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
) : null}
|
||||
{scrollEnabled && !isMobileBreakpoint ? (
|
||||
<DomOverlayScrollbar
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
|
||||
@@ -85,13 +85,11 @@ export interface StreamRenderInput {
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
// The history row under the top of the viewport, for surfaces that mark where the reader
|
||||
// is in the transcript. Only the web viewport measures it today.
|
||||
onReadingPositionChange?: (rowId: string | null) => void;
|
||||
onNearHistoryStart: () => Promise<boolean>;
|
||||
onNearHistoryStart: () => boolean | Promise<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
|
||||
@@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
previousSummaryRowsByHostId: new Map(),
|
||||
previousTurnsByHostId: new Map(),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -73,29 +73,17 @@ describe("collapseTurns", () => {
|
||||
pluginReply("final", "right"),
|
||||
];
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"math/final/left",
|
||||
"math/final/right",
|
||||
]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
|
||||
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
|
||||
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
|
||||
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
|
||||
expect(ids(expanded.tail)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"acknowledgment",
|
||||
"call",
|
||||
"math/final/left",
|
||||
"math/final/right",
|
||||
]);
|
||||
expect(expanded.tail).toEqual(items);
|
||||
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
|
||||
});
|
||||
|
||||
it("folds intermediate plugin replies even without tool calls", () => {
|
||||
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
|
||||
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
|
||||
});
|
||||
|
||||
@@ -105,7 +93,7 @@ describe("collapseTurns", () => {
|
||||
const card = { ...pluginReply("card"), sourceKind };
|
||||
const items = [user("prompt"), shell("call"), card];
|
||||
expect(collapse(items).tail).toEqual(items);
|
||||
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
|
||||
expect(collapse(items).turnsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -114,41 +102,38 @@ describe("collapseTurns", () => {
|
||||
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
|
||||
});
|
||||
|
||||
// A mounted row renders again only when its item changes, so a turn whose work and open state
|
||||
// are unchanged keeps its summary row object across folds of the same history.
|
||||
it("keeps a summary row until its turn's work or open state changes", () => {
|
||||
const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
|
||||
// A history row renders again only when its item changes, so a prompt that is already retained
|
||||
// when its turn closes needs its row revised to show the summary line.
|
||||
it("reports the hosts whose summary line appeared, changed or went away", () => {
|
||||
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
|
||||
isLatestTurnOpen: true,
|
||||
});
|
||||
expect([...open.updatedHostIds]).toEqual(["u1"]);
|
||||
const closed = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
|
||||
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
|
||||
{
|
||||
previousTurnsByHostId: open.turnsByHostId,
|
||||
},
|
||||
);
|
||||
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
|
||||
expect([...closed.updatedHostIds]).toEqual(["u2"]);
|
||||
const unchanged = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
|
||||
{
|
||||
previousTurnsByHostId: closed.turnsByHostId,
|
||||
},
|
||||
);
|
||||
expect(unchanged.updatedHostIds.size).toBe(0);
|
||||
const moreWork = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
|
||||
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
|
||||
{ previousTurnsByHostId: unchanged.turnsByHostId },
|
||||
);
|
||||
expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1"));
|
||||
expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2"));
|
||||
const opened = collapse(moreWork.tail, {
|
||||
previousSummaryRowsByHostId: moreWork.summaryRowsByHostId,
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
|
||||
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
|
||||
previousTurnsByHostId: moreWork.turnsByHostId,
|
||||
});
|
||||
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
|
||||
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
|
||||
});
|
||||
it("stands at its host's timeline position, taking a new row when that position lands", () => {
|
||||
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
|
||||
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
|
||||
const cursor = { epoch: "epoch-1", seq: 7 };
|
||||
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
|
||||
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
|
||||
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
|
||||
expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor);
|
||||
const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId });
|
||||
expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1"));
|
||||
});
|
||||
it("maps hidden rows to their turn's host only while the turn is closed", () => {
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
@@ -183,15 +168,15 @@ describe("collapseTurns", () => {
|
||||
reply("done"),
|
||||
].map((item) => Object.assign(item, { turnId: "same-run" }));
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
|
||||
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
|
||||
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
|
||||
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
paseoActions: { "Created agent": 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
@@ -201,16 +186,14 @@ describe("collapseTurns", () => {
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
|
||||
expect(result.tail[1]).toEqual({
|
||||
kind: "turn_summary",
|
||||
id: "turn-summary:u1",
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
timestamp: at,
|
||||
expanded: false,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
summary: {
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
@@ -219,9 +202,9 @@ describe("collapseTurns", () => {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
@@ -229,8 +212,8 @@ describe("collapseTurns", () => {
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
@@ -243,8 +226,8 @@ describe("collapseTurns", () => {
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
@@ -253,7 +236,7 @@ describe("collapseTurns", () => {
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.summaryRowsByHostId.size).toBe(0);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
@@ -262,7 +245,7 @@ describe("collapseTurns", () => {
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.summaryRowsByHostId.size).toBe(0);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a completed response folded when a promptless canonical turn starts", () => {
|
||||
@@ -271,15 +254,15 @@ describe("collapseTurns", () => {
|
||||
);
|
||||
const next = { ...shell("heartbeat"), turnId: "second" };
|
||||
const result = collapse([...first, next], { isLatestTurnOpen: true });
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
|
||||
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
|
||||
expect(result.turnsByHostId.has("u1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps mid-turn messages visible when work is folded", () => {
|
||||
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
|
||||
for (const item of tail) item.turnId = "same-turn";
|
||||
const result = collapse(tail);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
|
||||
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -289,7 +272,7 @@ describe("collapseTurns", () => {
|
||||
expandedHostIds: new Set(["prompt"]),
|
||||
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
|
||||
});
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
@@ -299,6 +282,8 @@ describe("collapseTurns", () => {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,25 +3,35 @@ import {
|
||||
getStreamItemMessageId,
|
||||
type StreamItem,
|
||||
type ThoughtItem,
|
||||
type TimelinePosition,
|
||||
type ToolCallItem,
|
||||
type TurnSummaryItem,
|
||||
} from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The input or first loaded row that carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The host of the turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
/**
|
||||
* The summary rows by host. A mounted row renders again only when its item changes, so a fold
|
||||
* keeps the previous row object for a turn whose work and open state are unchanged.
|
||||
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
|
||||
* history row renders again only when its item changes, so these rows are revised.
|
||||
*/
|
||||
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
|
||||
updatedHostIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -31,39 +41,39 @@ export interface CollapseTurnsInput {
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||
/** The previous fold of the same history, whose unchanged summary rows are reused. */
|
||||
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
|
||||
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
|
||||
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_ROWS: ReadonlyMap<string, TurnSummaryItem> = new Map();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): OverviewSummary {
|
||||
): TurnSummary {
|
||||
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return summarizeToolCalls(calls);
|
||||
return { tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(tools: OverviewSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
|
||||
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean {
|
||||
const { paseoActions: actionsA, ...countsA } = a;
|
||||
const { paseoActions: actionsB, ...countsB } = b;
|
||||
return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB);
|
||||
}
|
||||
|
||||
function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) {
|
||||
return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq);
|
||||
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
|
||||
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
|
||||
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
|
||||
return (
|
||||
a.expanded === b.expanded &&
|
||||
hasSameCounts(countsA, countsB) &&
|
||||
hasSameCounts(actionsA, actionsB)
|
||||
);
|
||||
}
|
||||
|
||||
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
|
||||
@@ -71,32 +81,19 @@ function hasSameCounts(a: Record<string, number>, b: Record<string, number>): bo
|
||||
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
|
||||
}
|
||||
|
||||
function summaryRow(
|
||||
host: StreamItem,
|
||||
expanded: boolean,
|
||||
tools: OverviewSummary,
|
||||
previous: TurnSummaryItem | undefined,
|
||||
): TurnSummaryItem {
|
||||
if (
|
||||
previous &&
|
||||
previous.expanded === expanded &&
|
||||
hasSameWork(previous.tools, tools) &&
|
||||
hasSamePosition(previous.timelineCursor, host.timelineCursor)
|
||||
) {
|
||||
return previous;
|
||||
function findUpdatedHosts(
|
||||
previous: ReadonlyMap<string, CollapsedTurn>,
|
||||
next: ReadonlyMap<string, CollapsedTurn>,
|
||||
): ReadonlySet<string> {
|
||||
const updated = new Set<string>();
|
||||
for (const [hostId, turn] of next) {
|
||||
const before = previous.get(hostId);
|
||||
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
|
||||
}
|
||||
return {
|
||||
kind: "turn_summary",
|
||||
id: `turn-summary:${host.id}`,
|
||||
// The row stands at its host's place in the timeline, so the reading position over it
|
||||
// still resolves to the turn's prompt.
|
||||
...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}),
|
||||
...(host.turnId !== undefined ? { turnId: host.turnId } : {}),
|
||||
timestamp: host.timestamp,
|
||||
hostId: host.id,
|
||||
expanded,
|
||||
tools,
|
||||
};
|
||||
for (const hostId of previous.keys()) {
|
||||
if (!next.has(hostId)) updated.add(hostId);
|
||||
}
|
||||
return updated.size === 0 ? EMPTY_IDS : updated;
|
||||
}
|
||||
|
||||
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||
@@ -127,15 +124,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn behind a summary row under its user message: everything the agent
|
||||
* did before its last message collapses into that row, and the last message stays in the chat.
|
||||
* A turn folds only once it is complete, its user message is loaded, and it sent messages or
|
||||
* called tools before its reply. Reasoning is revealed only when the turn opens.
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Reasoning is revealed only when the turn opens.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
@@ -155,19 +152,13 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const tools = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
const summary = summaryRow(
|
||||
host,
|
||||
expanded,
|
||||
tools,
|
||||
input.previousSummaryRowsByHostId.get(host.id),
|
||||
);
|
||||
summaryRowsByHostId.set(host.id, summary);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
const revealed = expanded
|
||||
? folded.flatMap<StreamItem>((item) =>
|
||||
item.kind === "tool_call" || item.kind === "thought"
|
||||
@@ -182,7 +173,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
output.push(
|
||||
summary,
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
@@ -200,20 +190,26 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (summaryRowsByHostId.size === 0) {
|
||||
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
summaryRowsByHostId: EMPTY_ROWS,
|
||||
updatedHostIds,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output,
|
||||
tail:
|
||||
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||
? tail
|
||||
: output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
summaryRowsByHostId,
|
||||
updatedHostIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native";
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { TurnSummaryItem } from "@/types/stream";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
item,
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
item: TurnSummaryItem;
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
|
||||
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
|
||||
[hold, onExpandedChange, item.expanded, item.hostId],
|
||||
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
|
||||
[hold, onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={item.expanded}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { createNativeStreamStrategy } from "./strategy-native";
|
||||
import { CompletedTurnFooterRow } from "./turn-footer";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// A Sunday, so a turn that ended on Friday shows a weekday timestamp.
|
||||
const NOW = new Date(2026, 10, 8, 5, 0);
|
||||
const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11);
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
host = document.createElement("div");
|
||||
host.style.width = "880px";
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
await i18n.changeLanguage("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const reply: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "reply",
|
||||
text: "Done.",
|
||||
timestamp: FRIDAY_NIGHT,
|
||||
};
|
||||
|
||||
function renderCompletedFooter(timing: TurnTiming): void {
|
||||
act(() =>
|
||||
root.render(
|
||||
<CompletedTurnFooterRow
|
||||
strategy={createNativeStreamStrategy()}
|
||||
items={[reply]}
|
||||
timing={timing}
|
||||
startIndex={0}
|
||||
supportsTimelineCursor={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** How many painted lines each footer label takes. */
|
||||
function paintedLinesByLabel(element: HTMLElement): Record<string, number> {
|
||||
const lines: Record<string, number> = {};
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent ?? "";
|
||||
if (!text.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label
|
||||
// may be sized by the other: a timestamp with more characters can still paint narrower.
|
||||
it.each([
|
||||
{
|
||||
language: "en",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 },
|
||||
duration: "Worked for 26s",
|
||||
},
|
||||
// CJK glyphs make the shorter string paint wider in every font.
|
||||
{
|
||||
language: "ja",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 },
|
||||
duration: "作業時間 59m 58s",
|
||||
},
|
||||
])("keeps both timing labels on one line when the chat has room ($language)", async (input) => {
|
||||
await i18n.changeLanguage(input.language);
|
||||
|
||||
renderCompletedFooter(input.timing);
|
||||
|
||||
expect(paintedLinesByLabel(host)).toEqual({
|
||||
[input.duration]: 1,
|
||||
"Friday 11:11 PM": 1,
|
||||
});
|
||||
});
|
||||
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "stretch",
|
||||
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
alignSelf: "flex-start",
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
|
||||
},
|
||||
turnFooterContent: {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
createHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationState,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
export function useHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
const read = useStableEvent(input.read);
|
||||
const load = useStableEvent(input.load);
|
||||
const scheduleSettle = useStableEvent(input.scheduleSettle);
|
||||
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
|
||||
status: "dormant",
|
||||
visible: input.initiallyVisible,
|
||||
}));
|
||||
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
|
||||
if (!controller.current) {
|
||||
controller.current = createHistoryStartPagination({
|
||||
initiallyVisible: input.initiallyVisible,
|
||||
read,
|
||||
load,
|
||||
scheduleSettle,
|
||||
changed: setState,
|
||||
});
|
||||
}
|
||||
return { state, controller: controller.current };
|
||||
}
|
||||
@@ -248,8 +248,6 @@ export interface AgentStreamViewProps {
|
||||
turnPresentation: TurnPresentation;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||
bottomOverlayTailClearance?: number;
|
||||
/** Bottom offset required for controls floating above that overlay. */
|
||||
@@ -347,8 +345,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
turnPresentation,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
bottomOverlayTailClearance = 0,
|
||||
bottomOverlayControlClearance,
|
||||
toast,
|
||||
@@ -693,23 +689,26 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through a stable event so a collapse rerun does not change the renderer identity;
|
||||
// nested rows are revised through `historyRowRevision`.
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const { item } = layoutItem;
|
||||
if (nestedItemIds.has(item.id)) {
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -729,8 +728,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -915,10 +931,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "turn_summary":
|
||||
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -949,6 +972,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
@@ -1127,17 +1151,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: {
|
||||
has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id),
|
||||
has: (id: string) =>
|
||||
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
|
||||
},
|
||||
displayStateById: {
|
||||
// An opened lone call also marks its future group open; that row stays unrevised
|
||||
// until a group actually forms there.
|
||||
has: (id: string) =>
|
||||
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
@@ -1145,9 +1171,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[
|
||||
expandedToolCallGroups,
|
||||
getToolCallGroup,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
updatedTurnHostIds,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1181,8 +1209,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange: nearBottom.set,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
@@ -1259,7 +1285,8 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
|
||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||
}
|
||||
if (left.features !== right.features) reasons.push("agent.features");
|
||||
if (left.options !== right.options) reasons.push("agent.options");
|
||||
if (left.controls !== right.controls) reasons.push("agent.controls");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
@@ -1325,12 +1352,6 @@ function agentStreamViewPropsEqual(
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
|
||||
reasons.push("isInitialHistoryLoading");
|
||||
}
|
||||
if (left.revealInitialHistory !== right.revealInitialHistory) {
|
||||
reasons.push("revealInitialHistory");
|
||||
}
|
||||
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
|
||||
reasons.push("bottomOverlayTailClearance");
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export function estimateStreamItemHeight({
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
case "turn_summary":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
|
||||
@@ -40,16 +40,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
cwd: "/repo",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
features: [
|
||||
{ type: "toggle", id: "web-search", label: "Web search", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "effort",
|
||||
label: "Effort",
|
||||
value: "high",
|
||||
options: [{ id: "high", label: "High" }],
|
||||
},
|
||||
],
|
||||
options: { "web-search": true, effort: "high" },
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
@@ -114,7 +105,7 @@ describe("buildDraftAgentSetup", () => {
|
||||
modeId: "mode-current",
|
||||
model: "agent-model",
|
||||
thinkingOptionId: "think-hard",
|
||||
featureValues: {
|
||||
providerOptions: {
|
||||
"web-search": true,
|
||||
effort: "high",
|
||||
},
|
||||
|
||||
@@ -65,17 +65,12 @@ 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,
|
||||
featureValues,
|
||||
providerOptions: { ...agent.options },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentOptionControl,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import {
|
||||
@@ -31,7 +35,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
|
||||
thinking: TestIcon,
|
||||
planMode: TestIcon,
|
||||
mode: () => TestIcon,
|
||||
feature: () => TestIcon,
|
||||
control: () => TestIcon,
|
||||
};
|
||||
|
||||
function provider(input: {
|
||||
@@ -65,8 +69,8 @@ function provider(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
|
||||
return { type: "toggle", id, label: id, value, icon };
|
||||
function toggleControl(key: string, icon?: string): AgentOptionControl {
|
||||
return { type: "toggle", key, label: key, icon };
|
||||
}
|
||||
|
||||
function makeSource(
|
||||
@@ -75,7 +79,7 @@ function makeSource(
|
||||
models?: Partial<AgentControlContributionSource["models"]>;
|
||||
thinking?: Partial<AgentControlContributionSource["thinking"]>;
|
||||
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
|
||||
features?: Partial<AgentControlContributionSource["features"]>;
|
||||
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
|
||||
} = {},
|
||||
): AgentControlContributionSource {
|
||||
return {
|
||||
@@ -104,10 +108,11 @@ function makeSource(
|
||||
select: () => undefined,
|
||||
...overrides.modes,
|
||||
},
|
||||
features: {
|
||||
list: [],
|
||||
set: () => undefined,
|
||||
...overrides.features,
|
||||
optionControls: {
|
||||
controls: [],
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
...overrides.optionControls,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -133,7 +138,7 @@ describe("Command Center agent-control contributions", () => {
|
||||
const contributions = buildAgentControlContributions({
|
||||
...makeSource({
|
||||
models: { providers: [provider({ id: "claude", label: "Claude" })] },
|
||||
features: { list: [toggleFeature("plan_mode", true)] },
|
||||
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
|
||||
}),
|
||||
modes: undefined,
|
||||
});
|
||||
@@ -323,9 +328,10 @@ describe("Command Center agent-control contributions", () => {
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
},
|
||||
features: {
|
||||
list: [toggleFeature("plan_mode", true, "list-todo")],
|
||||
set: (id, value) => {
|
||||
optionControls: {
|
||||
controls: [toggleControl("plan_mode", "list-todo")],
|
||||
options: { plan_mode: true },
|
||||
onSetOption: (id, value) => {
|
||||
calls.push([id, value]);
|
||||
},
|
||||
},
|
||||
@@ -341,9 +347,10 @@ describe("Command Center agent-control contributions", () => {
|
||||
const calls: [string, unknown][] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
features: {
|
||||
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
|
||||
set: (id, value) => {
|
||||
optionControls: {
|
||||
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
|
||||
options: { fast_mode: false, auto_accept: true },
|
||||
onSetOption: (id, value) => {
|
||||
calls.push([id, value]);
|
||||
},
|
||||
},
|
||||
@@ -376,7 +383,7 @@ describe("Command Center agent-control contributions", () => {
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
},
|
||||
features: { list: [toggleFeature("fast_mode")] },
|
||||
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
|
||||
}),
|
||||
);
|
||||
const ranks = new Map<string, number>();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 {
|
||||
@@ -61,7 +62,7 @@ export interface AgentControlContributionIcons {
|
||||
thinking: CommandCenterIcon;
|
||||
planMode: CommandCenterIcon;
|
||||
mode(modeId: string): CommandCenterIcon;
|
||||
feature(feature: AgentFeature): CommandCenterIcon;
|
||||
control(control: AgentOptionControl): CommandCenterIcon;
|
||||
}
|
||||
|
||||
interface CommandCenterChoice {
|
||||
@@ -105,10 +106,7 @@ export interface AgentControlContributionSource {
|
||||
defaultModeId: string | null;
|
||||
select(modeId: string): void | Promise<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[];
|
||||
set(featureId: string, value: unknown): void | Promise<void>;
|
||||
};
|
||||
optionControls: ComposerOptionControls;
|
||||
}
|
||||
|
||||
function buildContributions(
|
||||
@@ -242,18 +240,19 @@ function toggleChoices(input: {
|
||||
}
|
||||
|
||||
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const planFeature = source.features.list.find(
|
||||
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
|
||||
const { controls, options, onSetOption } = source.optionControls;
|
||||
const planControl = controls.find(
|
||||
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
|
||||
);
|
||||
let choices: CommandCenterChoice[] = [];
|
||||
if (planFeature?.type === "toggle") {
|
||||
if (planControl?.type === "toggle") {
|
||||
choices = toggleChoices({
|
||||
source,
|
||||
key: "plan",
|
||||
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),
|
||||
icon: source.icons.control(planControl),
|
||||
isOn: readToggleOption(planControl, options),
|
||||
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
|
||||
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
|
||||
});
|
||||
} else if (source.modes) {
|
||||
const modes = source.modes;
|
||||
@@ -280,18 +279,19 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
|
||||
}
|
||||
|
||||
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const fastFeature = source.features.list.find(
|
||||
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
|
||||
const { controls, options, onSetOption } = source.optionControls;
|
||||
const fastControl = controls.find(
|
||||
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
|
||||
);
|
||||
const choices =
|
||||
fastFeature?.type === "toggle"
|
||||
fastControl?.type === "toggle"
|
||||
? toggleChoices({
|
||||
source,
|
||||
key: "fast",
|
||||
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),
|
||||
icon: source.icons.control(fastControl),
|
||||
isOn: readToggleOption(fastControl, options),
|
||||
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
|
||||
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentProvider,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
getAgentFeatureIcon,
|
||||
@@ -14,6 +9,7 @@ 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,
|
||||
@@ -44,15 +40,15 @@ export interface AgentControlCommandCenterSource {
|
||||
selectedId: string | null | undefined;
|
||||
select(modeId: string): void | Promise<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[] | undefined;
|
||||
set?: (featureId: string, value: unknown) => void | Promise<void>;
|
||||
};
|
||||
/** Absent when the surface cannot change options (the agent has no client). */
|
||||
optionControls: ComposerOptionControls | null;
|
||||
}
|
||||
|
||||
function ignoreAgentFeatureChange(): void {
|
||||
return;
|
||||
}
|
||||
const NO_OPTION_CONTROLS: ComposerOptionControls = {
|
||||
controls: [],
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
};
|
||||
|
||||
function resolveDefaultModeId(
|
||||
provider: string | null | undefined,
|
||||
@@ -87,7 +83,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
}): void {
|
||||
const { t } = useTranslation();
|
||||
const { controls } = input;
|
||||
const { features, models, modes, thinking } = controls;
|
||||
const { optionControls, models, modes, thinking } = controls;
|
||||
const getProviderIcon = useProviderIcons(controls.serverId);
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
@@ -104,7 +100,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
getCommandCenterIcon(
|
||||
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
|
||||
),
|
||||
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
|
||||
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
|
||||
},
|
||||
models: {
|
||||
providers: models.providers,
|
||||
@@ -123,10 +119,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
modes?.select,
|
||||
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
|
||||
),
|
||||
features: {
|
||||
list: features.set ? (features.list ?? []) : [],
|
||||
set: features.set ?? ignoreAgentFeatureChange,
|
||||
},
|
||||
optionControls: optionControls ?? NO_OPTION_CONTROLS,
|
||||
}),
|
||||
[
|
||||
getProviderIcon,
|
||||
@@ -134,8 +127,7 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
controls.provider,
|
||||
controls.providerDefinitions,
|
||||
controls.serverId,
|
||||
features.list,
|
||||
features.set,
|
||||
optionControls,
|
||||
models.providers,
|
||||
models.select,
|
||||
models.selectedModelId,
|
||||
|
||||
@@ -557,15 +557,6 @@ interface AssistantTurnFooterProps {
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
@@ -577,25 +568,21 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
marginTop: 0,
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
labelButton: {
|
||||
flex: 1,
|
||||
minHeight: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minWidth: 0,
|
||||
labelWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
label: {
|
||||
width: "100%",
|
||||
flexShrink: 0,
|
||||
labelSizer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
overlappingLabel: {
|
||||
marginLeft: "-100%",
|
||||
},
|
||||
hiddenLabel: {
|
||||
opacity: 0,
|
||||
},
|
||||
labelOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
}));
|
||||
|
||||
const TIMESTAMP_REVEAL_MS = 3000;
|
||||
@@ -662,51 +649,31 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
{getContent || canFork ? (
|
||||
<View style={assistantTurnFooterStylesheet.actions}>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
</View>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
style={assistantTurnFooterStylesheet.labelButton}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||||
>
|
||||
{/* Both labels participate in layout at the available width. Overlapping their
|
||||
columns reserves the taller wrapped height without measuring text or characters. */}
|
||||
<Text
|
||||
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
|
||||
aria-hidden={showTimestamp}
|
||||
style={[
|
||||
assistantTurnFooterStylesheet.label,
|
||||
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
|
||||
]}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Text>
|
||||
{canSwap ? (
|
||||
<Text
|
||||
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
|
||||
aria-hidden={!showTimestamp}
|
||||
style={[
|
||||
assistantTurnFooterStylesheet.label,
|
||||
assistantTurnFooterStylesheet.overlappingLabel,
|
||||
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
|
||||
]}
|
||||
>
|
||||
{timestampLabel}
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
container width is stable across hover transitions. */}
|
||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||
{showTimestamp ? timestampLabel : primaryLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderTabs,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
} from "@/screens/workspace/adaptive-workspace-chrome";
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
@@ -66,13 +66,12 @@ import { isNative } from "@/constants/platform";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
/** The full workspace header: title, project row and workspace actions. */
|
||||
renderMainHeader?: () => ReactNode;
|
||||
/**
|
||||
* The compact workspace header. With one presented pane it carries that pane's tab switcher in
|
||||
* place of its tab rail; above a split it carries workspace controls only.
|
||||
*/
|
||||
renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode;
|
||||
renderCompactHeader: (
|
||||
tabs: WorkspaceTabDescriptor[],
|
||||
activeTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => ReactNode;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -129,11 +128,6 @@ interface SplitNodeViewProps extends Omit<
|
||||
windowChromeCorners: WindowChromeCorners;
|
||||
maximizedPaneId: string | null;
|
||||
workspaceHasMultiplePanes: boolean;
|
||||
/**
|
||||
* One presented pane draws the workspace header itself and may merge its tabs into the compact
|
||||
* header. Split panes share one header above them and always show their tab rails.
|
||||
*/
|
||||
ownsWorkspaceHeader: boolean;
|
||||
onTogglePaneMaximized: (paneId: string) => void;
|
||||
}
|
||||
|
||||
@@ -250,27 +244,11 @@ export function SplitContainer({
|
||||
: inheritedWindowChromeCorners;
|
||||
const mainColumnStyle = styles.mainColumn;
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root);
|
||||
const sharedCompactHeader = useMemo(
|
||||
() =>
|
||||
panesShareWorkspaceHeader ? (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(null)}
|
||||
</WindowChromeSafeArea>
|
||||
) : null,
|
||||
[panesShareWorkspaceHeader, renderCompactHeader],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id="SplitContainer">
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{panesShareWorkspaceHeader ? (
|
||||
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
</AdaptiveWorkspaceChrome>
|
||||
) : null}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
@@ -306,12 +284,7 @@ export function SplitContainer({
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={
|
||||
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
|
||||
? "none"
|
||||
: windowChromeCorners
|
||||
}
|
||||
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
@@ -450,7 +423,6 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
ownsWorkspaceHeader,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitNodeViewProps) {
|
||||
@@ -539,7 +511,6 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
ownsWorkspaceHeader={ownsWorkspaceHeader}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
@@ -607,7 +578,6 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
ownsWorkspaceHeader={ownsWorkspaceHeader}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
@@ -668,7 +638,6 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
ownsWorkspaceHeader,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitPaneViewProps) {
|
||||
@@ -801,24 +770,13 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
createNewTab,
|
||||
});
|
||||
const compactHeader = useMemo(
|
||||
() =>
|
||||
ownsWorkspaceHeader ? (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader({
|
||||
tabs: paneTabs,
|
||||
activeTabKey: activeTabDescriptor?.key ?? "",
|
||||
actions: compactHeaderActions,
|
||||
})}
|
||||
</WindowChromeSafeArea>
|
||||
) : null,
|
||||
[
|
||||
ownsWorkspaceHeader,
|
||||
renderCompactHeader,
|
||||
paneTabs,
|
||||
activeTabDescriptor?.key,
|
||||
compactHeaderActions,
|
||||
],
|
||||
() => (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
|
||||
</WindowChromeSafeArea>
|
||||
),
|
||||
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
@@ -829,7 +787,7 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
testID={`workspace-pane-${pane.id}`}
|
||||
>
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
|
||||
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
|
||||
{renderMainHeader?.()}
|
||||
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ComboboxProps {
|
||||
}) => ReactElement;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
searchable?: boolean;
|
||||
/** The caller answers the search query itself, so the options are shown as given. */
|
||||
remoteSearch?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
@@ -1280,6 +1282,10 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean {
|
||||
return input.searchable && !input.remoteSearch;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
@@ -1287,6 +1293,7 @@ export function Combobox({
|
||||
renderOption,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
remoteSearch,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
@@ -1446,7 +1453,7 @@ export function Combobox({
|
||||
buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery,
|
||||
searchable,
|
||||
searchable: filtersLocally({ searchable, remoteSearch }),
|
||||
allowCustomValue,
|
||||
customValuePrefix,
|
||||
customValueDescription,
|
||||
@@ -1458,6 +1465,7 @@ export function Combobox({
|
||||
customValueKind,
|
||||
customValuePrefix,
|
||||
options,
|
||||
remoteSearch,
|
||||
searchQuery,
|
||||
searchable,
|
||||
],
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -18,17 +17,23 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import { computePosition, type Alignment, type Placement, type Rect } from "./anchor";
|
||||
import {
|
||||
computePosition,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
type Placement,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "./anchor";
|
||||
|
||||
const CLOSE_GRACE_MS = 100;
|
||||
|
||||
// One card per layer: moving onto a sibling trigger replaces the open card instead of stacking
|
||||
// a second one beside it for the grace period. Nested cards open on a higher layer.
|
||||
const openCardCloseByLayer = new Map<number, () => void>();
|
||||
|
||||
interface HoverCardContextValue {
|
||||
open: boolean;
|
||||
layer: number;
|
||||
@@ -111,26 +116,12 @@ function WebHoverCard({
|
||||
}, CLOSE_GRACE_MS);
|
||||
}, [keyboardFocusInside]);
|
||||
|
||||
const closeNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const openNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
if (disabled) return;
|
||||
const openSibling = openCardCloseByLayer.get(layer);
|
||||
if (openSibling && openSibling !== closeNow) openSibling();
|
||||
openCardCloseByLayer.set(layer, closeNow);
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
if (openCardCloseByLayer.get(layer) === closeNow) openCardCloseByLayer.delete(layer);
|
||||
};
|
||||
}, [closeNow, layer, open]);
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, disabled]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardInputEvent): boolean => {
|
||||
@@ -308,29 +299,17 @@ interface HoverCardContentProps {
|
||||
|
||||
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
|
||||
const ctx = useContext(HoverCardContext);
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
if (!ctx?.open) return null;
|
||||
return (
|
||||
<Portal hostName={portalHostName}>
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
</Portal>
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function readDomRect(view: View | null): Rect | null {
|
||||
const element = view as unknown as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
// Rendered inside the portal so the card is placed in the commit that mounts it: the first
|
||||
// painted frame already sits beside the trigger. Web-only, like the rest of the open card.
|
||||
function HoverCardSurface({
|
||||
layer,
|
||||
triggerRef,
|
||||
@@ -348,29 +327,48 @@ function HoverCardSurface({
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const overlayRef = useRef<View>(null);
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const triggerRect = readDomRect(triggerRef.current);
|
||||
const overlayRect = readDomRect(overlayRef.current);
|
||||
const content = contentRef.current as unknown as HTMLElement | null;
|
||||
if (!triggerRect || !overlayRect || !content) return;
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
measureElement(triggerRef.current),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([triggerRect, hostRect]) => {
|
||||
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [portalHostName, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor || !contentSize) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
triggerRect,
|
||||
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
|
||||
triggerRect: anchor.triggerRect,
|
||||
contentSize,
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
placement,
|
||||
flipHorizontal: true,
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
|
||||
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
|
||||
}, [alignment, contentRef, offset, placement, triggerRef]);
|
||||
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
|
||||
}, [alignment, anchor, contentSize, offset, placement]);
|
||||
|
||||
useLayoutEffect(place, [place]);
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
@@ -383,26 +381,24 @@ function HoverCardSurface({
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={overlayRef}
|
||||
pointerEvents="box-none"
|
||||
style={[styles.portalOverlay, { zIndex: layer }]}
|
||||
>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={place}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
<Portal hostName={portalHostName}>
|
||||
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Image, type ImageLoadEventData } from "expo-image";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ViewportFitOptions, ViewportSize } from "./geometry";
|
||||
import { ZoomableViewport } from "./index";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
const ViewportImage = withUnistyles(Image);
|
||||
|
||||
interface ZoomableImageProps {
|
||||
uri: string;
|
||||
accessibilityLabel?: string;
|
||||
@@ -37,27 +34,36 @@ export function ZoomableImage({
|
||||
testID = "zoomable-image",
|
||||
wheelActivation = "always",
|
||||
}: ZoomableImageProps) {
|
||||
const source = useMemo(() => ({ uri: normalizeImageUri(uri) }), [uri]);
|
||||
const [loadedImage, setLoadedImage] = useState<{ uri: string; size: ViewportSize } | null>(null);
|
||||
const resolvedSize = contentSize ?? (loadedImage?.uri === source.uri ? loadedImage.size : null);
|
||||
const handleLoad = useCallback(
|
||||
({ source: { url, width, height } }: ImageLoadEventData) => {
|
||||
if (url === source.uri && width > 0 && height > 0) {
|
||||
setLoadedImage({ uri: source.uri, size: { width, height } });
|
||||
}
|
||||
},
|
||||
[source.uri],
|
||||
);
|
||||
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
|
||||
const resolvedSize = contentSize ?? loadedSize;
|
||||
const source = useMemo(() => ({ uri }), [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentSize) return;
|
||||
let active = true;
|
||||
setLoadedSize(null);
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
|
||||
},
|
||||
() => {
|
||||
if (active) onError?.();
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [contentSize, onError, uri]);
|
||||
|
||||
if (!resolvedSize) {
|
||||
return (
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<ViewportImage
|
||||
<Image
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
onError={onError}
|
||||
onLoad={handleLoad}
|
||||
contentFit="contain"
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
@@ -79,9 +85,9 @@ export function ZoomableImage({
|
||||
testID={testID}
|
||||
wheelActivation={wheelActivation}
|
||||
>
|
||||
<ViewportImage
|
||||
<Image
|
||||
onError={onError}
|
||||
contentFit="contain"
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
@@ -90,15 +96,6 @@ export function ZoomableImage({
|
||||
);
|
||||
}
|
||||
|
||||
// Use the same URL spelling as the decoder while preserving opaque native URIs.
|
||||
function normalizeImageUri(uri: string): string {
|
||||
try {
|
||||
return new URL(uri).href;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
image: { width: "100%", height: "100%" },
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { providersSnapshotQueryRoot } from "@/data/providers-snapshot";
|
||||
import { agentComposer, createAgentComposer } from "./index";
|
||||
|
||||
const serverId = "host-1";
|
||||
const agentId = "agent-1";
|
||||
const draftKey = buildDraftStoreKey({ serverId, agentId });
|
||||
|
||||
const entries: ProviderSnapshotEntry[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "opus",
|
||||
label: "Opus",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4",
|
||||
label: "Sonnet 4",
|
||||
aliases: ["sonnet"],
|
||||
thinkingOptions: [{ id: "low", label: "Low" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function agent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
serverId,
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
status: "idle",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: new Date("2026-11-04T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-11-04T10:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-11-04T10:00:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
cwd: "/repo",
|
||||
workspaceId: "workspace-1",
|
||||
model: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
async getProvidersSnapshot() {
|
||||
if (hostCatalog.failing) throw new Error("The host is busy.");
|
||||
return {
|
||||
entries: hostCatalog.entries,
|
||||
generatedAt: "2026-11-04T10:00:00.000Z",
|
||||
requestId: "snapshot",
|
||||
};
|
||||
},
|
||||
async setAgentModel(id: string, model: string | null) {
|
||||
calls.push(`model:${id}:${model}`);
|
||||
if (model === "nope") throw new Error("The provider refused model 'nope'");
|
||||
},
|
||||
async setAgentMetadata(id: string, patch: unknown) {
|
||||
calls.push(`metadata:${id}:${JSON.stringify(patch)}`);
|
||||
},
|
||||
async setAgentOptions(id: string, patch: unknown) {
|
||||
calls.push(`options:${id}:${JSON.stringify(patch)}`);
|
||||
},
|
||||
async setAgentMode(id: string, mode: string) {
|
||||
calls.push(`mode:${id}:${mode}`);
|
||||
return null;
|
||||
},
|
||||
async setAgentThinkingOption(id: string, thinking: string) {
|
||||
calls.push(`thinking:${id}:${thinking}`);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return { client: client as unknown as DaemonClient, calls };
|
||||
}
|
||||
|
||||
/** What the host answers the next catalog request with. */
|
||||
const hostCatalog: { entries: ProviderSnapshotEntry[]; failing: boolean } = {
|
||||
entries,
|
||||
failing: false,
|
||||
};
|
||||
|
||||
function openSession(running: Agent) {
|
||||
const { client, calls } = fakeClient();
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { providersSnapshot: true },
|
||||
} as never);
|
||||
store.setAgents(serverId, new Map([[running.id, running]]));
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function until(read: () => unknown, expected: unknown) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (Object.is(read(), expected)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(read()).toBe(expected);
|
||||
}
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
function runtimeModel(model: string): Agent["runtimeInfo"] {
|
||||
return { provider: "claude", sessionId: null, model };
|
||||
}
|
||||
|
||||
function composer() {
|
||||
return createAgentComposer(serverId, agentId, { queryClient });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
hostCatalog.entries = entries;
|
||||
hostCatalog.failing = false;
|
||||
useDraftStore.setState({ drafts: {}, textReplacementRequestByDraftKey: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
});
|
||||
|
||||
describe("agent composer", () => {
|
||||
it("reads without notifying anyone, so a read during a render updates no other component", () => {
|
||||
const running = agent();
|
||||
const other = agent({ id: "agent-2", cwd: "/other-repo" });
|
||||
openSession(running);
|
||||
useSessionStore.getState().setAgents(
|
||||
serverId,
|
||||
new Map([
|
||||
[running.id, running],
|
||||
[other.id, other],
|
||||
]),
|
||||
);
|
||||
const watched = createAgentComposer(serverId, running.id, { queryClient });
|
||||
const reader = createAgentComposer(serverId, other.id, { queryClient });
|
||||
let notifications = 0;
|
||||
const stop = watched.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
const before = notifications;
|
||||
// The first read of another catalog creates its query in the shared cache.
|
||||
reader.getState();
|
||||
expect(notifications).toBe(before);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("keeps the prompt in the stored draft while no composer is mounted", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
model.insertText("Review ");
|
||||
model.insertText("this diff.\n");
|
||||
expect(model.getState().prompt).toBe("Review this diff.\n");
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)?.text).toBe("Review this diff.\n");
|
||||
model.setPrompt("Start over");
|
||||
expect(model.getState().prompt).toBe("Start over");
|
||||
});
|
||||
|
||||
it("sends prompt commands through the mounted composer while it is mounted", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const mounted: string[] = [];
|
||||
const detach = model.attachInput({
|
||||
isLive: () => true,
|
||||
setText: (text) => mounted.push(`set:${text}`),
|
||||
insertText: (text) => mounted.push(`insert:${text}`),
|
||||
});
|
||||
model.insertText("at the cursor");
|
||||
model.setPrompt("replaced");
|
||||
expect(mounted).toEqual(["insert:at the cursor", "set:replaced"]);
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined();
|
||||
detach();
|
||||
model.insertText("stored");
|
||||
expect(model.getState().prompt).toBe("stored");
|
||||
});
|
||||
|
||||
it("stores the prompt and asks the draft's input to show it while the composer is mounted off screen", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const mounted: string[] = [];
|
||||
model.attachInput({
|
||||
isLive: () => false,
|
||||
setText: (text) => mounted.push(`set:${text}`),
|
||||
insertText: (text) => mounted.push(`insert:${text}`),
|
||||
});
|
||||
model.insertText("while away");
|
||||
expect(mounted).toEqual([]);
|
||||
expect(model.getState().prompt).toBe("while away");
|
||||
expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(1);
|
||||
model.setPrompt("replaced");
|
||||
expect(model.getState().prompt).toBe("replaced");
|
||||
expect(useDraftStore.getState().textReplacementRequestByDraftKey[draftKey]).toBe(2);
|
||||
});
|
||||
|
||||
it("notifies subscribers of a stored prompt and returns the same state until something changes", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
let notified = 0;
|
||||
const stop = model.subscribe(() => {
|
||||
notified += 1;
|
||||
});
|
||||
const before = model.getState();
|
||||
expect(model.getState()).toBe(before);
|
||||
model.insertText("x");
|
||||
expect(notified).toBeGreaterThan(0);
|
||||
expect(model.getState()).not.toBe(before);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("adds and removes attachments in the stored draft", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const attachment = {
|
||||
kind: "plugin_resource" as const,
|
||||
pluginId: "review",
|
||||
sourceId: "issues",
|
||||
sourceTitle: "Issues",
|
||||
sourceIcon: "CircleDot",
|
||||
item: {
|
||||
id: "1",
|
||||
identifier: "ENG-1",
|
||||
title: "Title",
|
||||
subtitle: "Open",
|
||||
url: "https://example.com/1",
|
||||
text: "Issue ENG-1: Title",
|
||||
resourceType: "issue",
|
||||
},
|
||||
};
|
||||
model.changeAttachments((current) => [...current, attachment]);
|
||||
expect(model.getState().attachments).toHaveLength(1);
|
||||
model.changeAttachments(() => []);
|
||||
expect(model.getState().attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads mode, options and metadata from the agent, and sends commands to the daemon", async () => {
|
||||
const calls = openSession(
|
||||
agent({ currentModeId: "plan", options: { effort: "high" }, metadata: { flag: true } }),
|
||||
);
|
||||
const model = composer();
|
||||
expect(model.getState()).toMatchObject({
|
||||
context: "agent",
|
||||
provider: "claude",
|
||||
mode: "plan",
|
||||
options: { effort: "high" },
|
||||
metadata: { flag: true },
|
||||
});
|
||||
await model.setMetadata({ flag: null });
|
||||
await model.setOptions({ effort: "low" });
|
||||
await model.setMode("default");
|
||||
expect(calls).toEqual([
|
||||
'metadata:agent-1:{"flag":null}',
|
||||
'options:agent-1:{"effort":"low"}',
|
||||
"mode:agent-1:default",
|
||||
]);
|
||||
await expect(model.setProvider("codex")).rejects.toThrow(
|
||||
"A running agent cannot change provider.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a model the provider refuses with the provider's message", async () => {
|
||||
openSession(agent());
|
||||
await expect(composer().setModel("nope")).rejects.toThrow("The provider refused model 'nope'");
|
||||
});
|
||||
|
||||
it("takes the catalog the host changed while the picker was not open, once the host reconnects", async () => {
|
||||
openSession(agent({ runtimeInfo: runtimeModel("opus") }));
|
||||
const model = composer();
|
||||
const stop = model.subscribe(() => {});
|
||||
await until(() => model.getState().thinking, "high");
|
||||
|
||||
// The host changes the catalog while the client is away. Reconnecting invalidates the provider
|
||||
// catalogs, which refetches the ones something is watching.
|
||||
hostCatalog.entries = [
|
||||
{
|
||||
...entries[0],
|
||||
models: [{ ...entries[0].models![0], defaultThinkingOptionId: "low" }],
|
||||
},
|
||||
] as ProviderSnapshotEntry[];
|
||||
await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
|
||||
|
||||
await until(() => model.getState().thinking, "low");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("asks for the catalog again after a failed load once the host reconnects", async () => {
|
||||
hostCatalog.failing = true;
|
||||
openSession(agent({ runtimeInfo: runtimeModel("opus") }));
|
||||
const model = composer();
|
||||
const stop = model.subscribe(() => {});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(model.getState().thinking).toBeNull();
|
||||
|
||||
hostCatalog.failing = false;
|
||||
await queryClient.invalidateQueries({ queryKey: providersSnapshotQueryRoot(serverId) });
|
||||
|
||||
await until(() => model.getState().thinking, "high");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("watches the stores once however many components watch the composer, and only for its own draft", () => {
|
||||
openSession(agent());
|
||||
const model = composer();
|
||||
const calls = [0, 0, 0];
|
||||
const stops: Array<() => void> = [];
|
||||
for (const index of calls.keys()) {
|
||||
stops.push(model.subscribe(() => void (calls[index] += 1)));
|
||||
}
|
||||
const before = [...calls];
|
||||
model.insertText("typed");
|
||||
expect(calls.map((count, index) => count - before[index])).toEqual([1, 1, 1]);
|
||||
|
||||
const unrelated = [...calls];
|
||||
useDraftStore.getState().replaceDraftText({
|
||||
draftKey: buildDraftStoreKey({ serverId, agentId: "agent-2" }),
|
||||
text: "elsewhere",
|
||||
});
|
||||
expect(calls).toEqual(unrelated);
|
||||
for (const stop of stops) stop();
|
||||
});
|
||||
|
||||
function pickerShows(running: Agent, catalog: ProviderSnapshotEntry[] | null) {
|
||||
const picker = resolveAgentModelSelection({
|
||||
models: filterSelectableModels(catalog?.[0]?.models ?? null),
|
||||
runtimeModelId: running.runtimeInfo?.model ?? null,
|
||||
configuredModelId: running.model,
|
||||
runtimeThinkingOptionId: running.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: running.thinkingOptionId,
|
||||
});
|
||||
return { model: picker.activeModelId, thinking: picker.selectedThinkingId };
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an alias of a listed model",
|
||||
{ runtimeInfo: { model: "sonnet" } },
|
||||
"sonnet",
|
||||
"claude-sonnet-4",
|
||||
"low",
|
||||
],
|
||||
["no reported model", { model: null }, null, "opus", "high"],
|
||||
["the configured model", { model: "opus", thinkingOptionId: "low" }, "opus", "opus", "low"],
|
||||
] as const)(
|
||||
"shows what the picker shows for %s, before the catalog and after it loads",
|
||||
async (_name, overrides, modelBefore, modelAfter, thinkingAfter) => {
|
||||
const running = agent(overrides as unknown as Partial<Agent>);
|
||||
openSession(running);
|
||||
const model = composer();
|
||||
// No catalog yet: the picker resolves with no models.
|
||||
expect(model.getState()).toMatchObject(pickerShows(running, null));
|
||||
expect(model.getState().model).toBe(modelBefore);
|
||||
const stop = model.subscribe(() => {});
|
||||
await until(() => model.getState().model, modelAfter);
|
||||
expect(model.getState()).toMatchObject(pickerShows(running, entries));
|
||||
expect(model.getState().thinking).toBe(thinkingAfter);
|
||||
stop();
|
||||
},
|
||||
);
|
||||
|
||||
it("is shared while its agent is in the session, across closing and reopening its tab", () => {
|
||||
openSession(agent());
|
||||
const shared = agentComposer(serverId, agentId);
|
||||
const detach = shared.attachInput({ isLive: () => true, setText() {}, insertText() {} });
|
||||
detach();
|
||||
expect(agentComposer(serverId, agentId)).toBe(shared);
|
||||
const stop = shared.subscribe(() => {});
|
||||
stop();
|
||||
expect(agentComposer(serverId, agentId)).toBe(shared);
|
||||
});
|
||||
|
||||
it("is released when the last holder lets go of a composer whose agent was removed meanwhile", () => {
|
||||
openSession(agent());
|
||||
const mounted = agentComposer(serverId, agentId);
|
||||
const detach = mounted.attachInput({ isLive: () => true, setText() {}, insertText() {} });
|
||||
useSessionStore.getState().setAgents(serverId, new Map());
|
||||
// Held, so the session change did not release it, and nothing changes in the session after.
|
||||
expect(agentComposer(serverId, agentId)).toBe(mounted);
|
||||
detach();
|
||||
expect(agentComposer(serverId, agentId)).not.toBe(mounted);
|
||||
|
||||
useSessionStore.getState().setAgents(serverId, new Map([[agentId, agent()]]));
|
||||
const watched = agentComposer(serverId, agentId);
|
||||
const stop = watched.subscribe(() => {});
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
expect(agentComposer(serverId, agentId)).toBe(watched);
|
||||
stop();
|
||||
expect(agentComposer(serverId, agentId)).not.toBe(watched);
|
||||
});
|
||||
|
||||
it("is released when its agent is archived and nothing holds it", () => {
|
||||
openSession(agent());
|
||||
const idle = agentComposer(serverId, agentId);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents(serverId, new Map([[agentId, agent({ archivedAt: new Date() })]]));
|
||||
expect(agentComposer(serverId, agentId)).not.toBe(idle);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { QueryObserver, type QueryClient } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { providersSnapshotQuery } from "@/data/providers-snapshot";
|
||||
import { replicaQueryOptions } from "@/data/query";
|
||||
import { queryClient as singletonQueryClient } from "@/data/query-client";
|
||||
import type { ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||
import {
|
||||
resolveAgentModelSelection,
|
||||
resolveProviderSnapshotEntry,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
import type { ComposerModel, ComposerState } from "@/composer/model";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
|
||||
/** The mounted composer's input. While attached, prompt commands go through it. */
|
||||
export interface MountedComposerInput {
|
||||
/** False while the composer is retained but its input is not on screen. */
|
||||
isLive(): boolean;
|
||||
setText(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer of a running agent. It exists whether or not the agent's tab is open: the prompt
|
||||
* and attachments are the agent's draft in the draft store, its settings are the session store's
|
||||
* agent, and the model and thinking level resolve through the provider catalog exactly as the
|
||||
* model picker does.
|
||||
*/
|
||||
export interface AgentComposer extends ComposerModel {
|
||||
/** The mounted composer plugs in cursor-aware prompt commands for as long as it is mounted. */
|
||||
attachInput(input: MountedComposerInput): () => void;
|
||||
}
|
||||
|
||||
interface AgentComposerDeps {
|
||||
queryClient: QueryClient;
|
||||
cache?: ProviderSnapshotCache;
|
||||
/** The last watcher or mounted composer let go, so the composer may be releasable. */
|
||||
onUnheld?: () => void;
|
||||
}
|
||||
|
||||
type Catalog = { status: "loading" } | { status: "ready"; entries: ProviderSnapshotEntry[] | null };
|
||||
|
||||
const NO_VALUES = Object.freeze({});
|
||||
const NO_ATTACHMENTS: readonly UserComposerAttachment[] = Object.freeze([]);
|
||||
|
||||
interface ManagedAgentComposer extends AgentComposer {
|
||||
/** The agent is gone or archived, and nothing watches the composer or has one mounted. */
|
||||
isReleasable(): boolean;
|
||||
}
|
||||
|
||||
export function createAgentComposer(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
deps: AgentComposerDeps,
|
||||
): ManagedAgentComposer {
|
||||
const draftKey = buildDraftStoreKey({ serverId, agentId });
|
||||
const listeners = new Set<() => void>();
|
||||
// While something watches, the catalog has an observer: it fetches the catalog, and it is what
|
||||
// makes the catalog an active query that reconnect repair refetches, a failed one included.
|
||||
let observing: { cwd: string; stop: () => void } | null = null;
|
||||
// One pair of store subscriptions serves every listener, from the first to the last.
|
||||
let stopWatchingStores: (() => void) | null = null;
|
||||
let mounted: MountedComposerInput | null = null;
|
||||
let last: {
|
||||
agent: Agent | null;
|
||||
draft: DraftRecord | undefined;
|
||||
catalog: Catalog;
|
||||
state: ComposerState;
|
||||
} | null = null;
|
||||
|
||||
function readAgent(): Agent | null {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
function client() {
|
||||
const connected = useSessionStore.getState().sessions[serverId]?.client;
|
||||
if (!connected) throw new Error("The host is not connected.");
|
||||
return connected;
|
||||
}
|
||||
|
||||
function catalogQuery(cwd: string) {
|
||||
return replicaQueryOptions(
|
||||
providersSnapshotQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
client,
|
||||
queryClient: deps.queryClient,
|
||||
cache: deps.cache,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function observeCatalog() {
|
||||
const agent = listeners.size > 0 ? readAgent() : null;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cwd =
|
||||
agent && session?.client && session.serverInfo?.features?.providersSnapshot === true
|
||||
? agent.cwd
|
||||
: null;
|
||||
if ((observing?.cwd ?? null) === cwd) return;
|
||||
observing?.stop();
|
||||
observing = null;
|
||||
if (cwd === null) return;
|
||||
// Watching again after the catalog was invalidated or failed while nothing watched refreshes it.
|
||||
const options = { ...catalogQuery(cwd), refetchOnMount: true };
|
||||
observing = { cwd, stop: new QueryObserver(deps.queryClient, options).subscribe(notify) };
|
||||
}
|
||||
|
||||
function watchStores() {
|
||||
const stops = [
|
||||
useSessionStore.subscribe(() => {
|
||||
observeCatalog();
|
||||
notify();
|
||||
}),
|
||||
useDraftStore.subscribe((state, previous) => {
|
||||
if (state.drafts[draftKey] !== previous.drafts[draftKey]) notify();
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
for (const stop of stops) stop();
|
||||
};
|
||||
}
|
||||
|
||||
function readCatalog(agent: Agent): Catalog {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
// The picker never loads a catalog from a host without it and shows the configured model.
|
||||
if (session?.serverInfo?.features?.providersSnapshot !== true) {
|
||||
return { status: "ready", entries: null };
|
||||
}
|
||||
const query = catalogQuery(agent.cwd);
|
||||
const state = deps.queryClient.getQueryState<{ entries: ProviderSnapshotEntry[] }>(
|
||||
query.queryKey,
|
||||
);
|
||||
// Nothing observes while nothing watches, so a read starts the one deduplicated load or
|
||||
// refresh it needs and shows the cached catalog meanwhile. A read can run in another
|
||||
// component's render and creating the query tells every query-cache subscriber, so it starts
|
||||
// after the render.
|
||||
const loads = state?.isInvalidated === true || (!state?.data && state?.status !== "error");
|
||||
if (loads && !observing && session.client) {
|
||||
queueMicrotask(() => void deps.queryClient.prefetchQuery(query));
|
||||
}
|
||||
if (state?.data) return { status: "ready", entries: state.data.entries };
|
||||
if (state?.status === "error") return { status: "ready", entries: null };
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
function selectModel(agent: Agent, catalog: Catalog) {
|
||||
// Until the catalog lands there are no models, which is what the picker resolves with too.
|
||||
const entries = catalog.status === "ready" ? catalog.entries : null;
|
||||
return resolveAgentModelSelection({
|
||||
models: filterSelectableModels(
|
||||
resolveProviderSnapshotEntry(entries ?? undefined, agent.provider)?.models ?? null,
|
||||
),
|
||||
runtimeModelId: agent.runtimeInfo?.model ?? null,
|
||||
configuredModelId: agent.model,
|
||||
runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||
explicitThinkingOptionId: agent.thinkingOptionId,
|
||||
});
|
||||
}
|
||||
|
||||
function buildState(
|
||||
agent: Agent | null,
|
||||
catalog: Catalog,
|
||||
record: DraftRecord | undefined,
|
||||
): ComposerState {
|
||||
const draft = toDraftInputIfReady(record);
|
||||
const selection = agent ? selectModel(agent, catalog) : null;
|
||||
return {
|
||||
context: "agent",
|
||||
prompt: draft?.text ?? "",
|
||||
attachments: draft?.attachments ?? NO_ATTACHMENTS,
|
||||
provider: agent?.provider ?? null,
|
||||
model: selection?.activeModelId ?? null,
|
||||
mode: agent?.currentModeId ?? null,
|
||||
thinking: selection?.selectedThinkingId ?? null,
|
||||
options: agent?.options ?? NO_VALUES,
|
||||
metadata: agent?.metadata ?? NO_VALUES,
|
||||
};
|
||||
}
|
||||
|
||||
function readDraft(): DraftRecord | undefined {
|
||||
const record = useDraftStore.getState().drafts[draftKey];
|
||||
return record?.lifecycle === "active" ? record : undefined;
|
||||
}
|
||||
|
||||
function getState(): ComposerState {
|
||||
const agent = readAgent();
|
||||
const draft = readDraft();
|
||||
const catalog = agent ? readCatalog(agent) : ({ status: "loading" } as const);
|
||||
if (
|
||||
last &&
|
||||
last.agent === agent &&
|
||||
last.draft === draft &&
|
||||
sameCatalog(last.catalog, catalog)
|
||||
) {
|
||||
return last.state;
|
||||
}
|
||||
last = { agent, draft, catalog, state: buildState(agent, catalog, draft) };
|
||||
return last.state;
|
||||
}
|
||||
|
||||
function unheld() {
|
||||
if (listeners.size === 0 && mounted === null) deps.onUnheld?.();
|
||||
}
|
||||
|
||||
return {
|
||||
isReleasable() {
|
||||
if (listeners.size > 0 || mounted !== null) return false;
|
||||
const agent = readAgent();
|
||||
return agent === null || agent.archivedAt != null;
|
||||
},
|
||||
getState,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
stopWatchingStores ??= watchStores();
|
||||
observeCatalog();
|
||||
getState();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
stopWatchingStores?.();
|
||||
stopWatchingStores = null;
|
||||
}
|
||||
observeCatalog();
|
||||
unheld();
|
||||
};
|
||||
},
|
||||
attachInput(input) {
|
||||
mounted = input;
|
||||
return () => {
|
||||
if (mounted === input) mounted = null;
|
||||
unheld();
|
||||
};
|
||||
},
|
||||
setPrompt(text) {
|
||||
if (mounted?.isLive()) mounted.setText(text);
|
||||
else useDraftStore.getState().replaceDraftText({ draftKey, text });
|
||||
},
|
||||
insertText(text) {
|
||||
if (mounted?.isLive()) mounted.insertText(text);
|
||||
else {
|
||||
const current = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
|
||||
useDraftStore.getState().replaceDraftText({ draftKey, text: current + text });
|
||||
}
|
||||
},
|
||||
changeAttachments(updater) {
|
||||
useDraftStore.getState().updateDraftInput({
|
||||
draftKey,
|
||||
update: (current) => ({ ...current, attachments: updater(current.attachments) }),
|
||||
});
|
||||
},
|
||||
async setProvider() {
|
||||
throw new Error("A running agent cannot change provider.");
|
||||
},
|
||||
async setModel(modelId) {
|
||||
await client().setAgentModel(agentId, modelId);
|
||||
},
|
||||
async setMode(modeId) {
|
||||
await client().setAgentMode(agentId, modeId);
|
||||
},
|
||||
async setThinking(thinkingOptionId) {
|
||||
await client().setAgentThinkingOption(agentId, thinkingOptionId);
|
||||
},
|
||||
async setOptions(patch) {
|
||||
await client().setAgentOptions(agentId, patch);
|
||||
},
|
||||
async setMetadata(patch) {
|
||||
await client().setAgentMetadata(agentId, patch);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameCatalog(left: Catalog, right: Catalog): boolean {
|
||||
return (
|
||||
left.status === right.status &&
|
||||
(left.status === "loading" || left.entries === (right as typeof left).entries)
|
||||
);
|
||||
}
|
||||
|
||||
const composers = new Map<string, ManagedAgentComposer>();
|
||||
let stopReleasing: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* The composer of a running agent; the same one for every caller for as long as the agent is in the
|
||||
* session and not archived, so a command a plugin saved keeps reaching the composer a later tab
|
||||
* mounts. It is released when the agent is gone or archived and nothing holds it, checked when the
|
||||
* session changes and when the last holder lets go.
|
||||
*/
|
||||
export function agentComposer(serverId: string, agentId: string): AgentComposer {
|
||||
stopReleasing ??= useSessionStore.subscribe(releaseReleasableComposers);
|
||||
const key = `${serverId}:${agentId}`;
|
||||
let composer = composers.get(key);
|
||||
if (!composer) {
|
||||
composer = createAgentComposer(serverId, agentId, {
|
||||
queryClient: singletonQueryClient,
|
||||
onUnheld: releaseReleasableComposers,
|
||||
});
|
||||
composers.set(key, composer);
|
||||
}
|
||||
return composer;
|
||||
}
|
||||
|
||||
function releaseReleasableComposers() {
|
||||
for (const [key, composer] of composers) {
|
||||
if (composer.isReleasable()) composers.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { Scan } from "lucide-react-native";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider } from "@/contexts/toast-api-context";
|
||||
import { ComposerControls, type ComposerControlItem } from "./composer-control";
|
||||
import { useAgentControlItems } from "./control-items";
|
||||
import { resolveComposerControlPresentation } from "./layout";
|
||||
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
afterEach(cleanup);
|
||||
|
||||
const base = { id: "x", title: "Context", icon: Scan };
|
||||
|
||||
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
|
||||
|
||||
function createToast(): RecordingToast {
|
||||
return { error: vi.fn() } as unknown as RecordingToast;
|
||||
}
|
||||
|
||||
function ignoreOpenChange() {}
|
||||
|
||||
function Controls({
|
||||
items,
|
||||
surface,
|
||||
toast,
|
||||
}: {
|
||||
items: ComposerControlItem[];
|
||||
surface: "toolbar" | "sheet";
|
||||
toast: ToastApi;
|
||||
}) {
|
||||
return (
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControls
|
||||
items={items}
|
||||
surface={surface}
|
||||
openId={null}
|
||||
onOpenIdChange={ignoreOpenChange}
|
||||
/>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") {
|
||||
const toast = createToast();
|
||||
render(<Controls items={items} surface={surface} toast={toast} />);
|
||||
return toast;
|
||||
}
|
||||
|
||||
describe("ComposerControls", () => {
|
||||
it("shows a select's chosen label, or only its icon when asked", () => {
|
||||
const select: ComposerControlItem = {
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
choices: [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "large", label: "Large" },
|
||||
],
|
||||
onChange: () => undefined,
|
||||
};
|
||||
renderControls([select]);
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
|
||||
});
|
||||
|
||||
it("names the choice on an icon-only select, without drawing it", () => {
|
||||
renderControls([
|
||||
{
|
||||
...base,
|
||||
type: "select",
|
||||
value: "large",
|
||||
iconOnly: true,
|
||||
choices: [{ value: "large", label: "Large" }],
|
||||
onChange: () => undefined,
|
||||
},
|
||||
]);
|
||||
const trigger = screen.getByRole("button", { name: "Context: Large" });
|
||||
expect(trigger.textContent).not.toContain("Large");
|
||||
});
|
||||
|
||||
it("flips a toggle on press in the toolbar", () => {
|
||||
const onChange = vi.fn();
|
||||
renderControls([{ ...base, type: "toggle", value: true, onChange }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("lists a toggle as On or Off in the sheet", () => {
|
||||
renderControls([{ ...base, type: "toggle", value: true, onChange: () => undefined }], "sheet");
|
||||
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
|
||||
});
|
||||
|
||||
it("reports a failed command the way a built-in control does, and frees the control", async () => {
|
||||
let reject: (error: Error) => void = () => undefined;
|
||||
const onPress = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
}),
|
||||
);
|
||||
const toast = renderControls([{ ...base, type: "action", onPress }]);
|
||||
const button = screen.getByRole("button", { name: "Context" });
|
||||
|
||||
fireEvent.click(button);
|
||||
// While the command is in flight the control cannot be pressed again.
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||
fireEvent.click(button);
|
||||
expect(onPress).toHaveBeenCalledTimes(1);
|
||||
|
||||
reject(new Error("Unknown or unavailable provider: x"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Unknown or unavailable provider: x"),
|
||||
);
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true"));
|
||||
});
|
||||
|
||||
it("reports a command that throws before it returns", () => {
|
||||
const toast = renderControls([
|
||||
{
|
||||
...base,
|
||||
type: "action",
|
||||
onPress: () => {
|
||||
throw new Error("No such control");
|
||||
},
|
||||
},
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Context" }));
|
||||
expect(toast.error).toHaveBeenCalledWith("No such control");
|
||||
});
|
||||
});
|
||||
|
||||
const FAST_ON = { fast_mode: true };
|
||||
|
||||
describe("provider option controls", () => {
|
||||
const fastMode: AgentOptionControl = {
|
||||
type: "toggle",
|
||||
key: "fast_mode",
|
||||
label: "Fast",
|
||||
defaultValue: false,
|
||||
};
|
||||
function OptionControls({
|
||||
options,
|
||||
onSetOption,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
onSetOption: (key: string, value: unknown) => void;
|
||||
}) {
|
||||
const optionControls = React.useMemo(
|
||||
() => ({ controls: [fastMode], options, onSetOption }),
|
||||
[onSetOption, options],
|
||||
);
|
||||
const items = useAgentControlItems({
|
||||
surface: "toolbar",
|
||||
presentation: resolveComposerControlPresentation("full"),
|
||||
thinking: null,
|
||||
optionControls,
|
||||
});
|
||||
return <Controls items={items.features} surface="toolbar" toast={TOAST} />;
|
||||
}
|
||||
const TOAST = createToast();
|
||||
|
||||
function recordWrites(writes: Array<[string, unknown]>) {
|
||||
return (key: string, value: unknown) => void writes.push([key, value]);
|
||||
}
|
||||
|
||||
it("write the toggled option from the agent's current options", () => {
|
||||
const writes: Array<[string, unknown]> = [];
|
||||
const onSetOption = recordWrites(writes);
|
||||
render(<OptionControls options={FAST_ON} onSetOption={onSetOption} />);
|
||||
fireEvent.click(screen.getByTestId("agent-feature-fast_mode"));
|
||||
expect(writes).toEqual([["fast_mode", false]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentControlIcon } from "@/agent-controls/icons";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FieldTrigger } from "@/screens/new-workspace/form/field-trigger";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import { useToast } from "@/contexts/toast-api-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ComposerControlBase {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: AgentControlIcon;
|
||||
/** Tints the icon; muted when absent. */
|
||||
highlight?: "blue" | "green" | "yellow";
|
||||
/** Defaults to the title, or to "title: choice" on an icon-only select. */
|
||||
tooltip?: string;
|
||||
/** Defaults to the tooltip. */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface ComposerControlChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One control, drawn from data. Provider option controls, thinking and plugin controls all come
|
||||
* through here, so each is an icon in the composer toolbar, a row in its sheet, or, in a New
|
||||
* workspace item, a field. The name stays from when the composer was the only place it drew.
|
||||
*/
|
||||
export type ComposerControlItem =
|
||||
| (ComposerControlBase & {
|
||||
type: "toggle";
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void | Promise<void>;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "select";
|
||||
value: string | null;
|
||||
choices: readonly ComposerControlChoice[];
|
||||
/** Names a value the choices do not list, such as a pick that a search no longer returns. */
|
||||
valueLabel?: string;
|
||||
onChange: (value: string) => void | Promise<void>;
|
||||
/** The toolbar trigger shows only the icon. */
|
||||
iconOnly?: boolean;
|
||||
showCaret?: boolean;
|
||||
searchable?: boolean;
|
||||
/** The list answers `onSearchQueryChange` itself, so it is not filtered again. */
|
||||
remoteSearch?: boolean;
|
||||
onSearchQueryChange?: (query: string) => void;
|
||||
emptyText?: string;
|
||||
desktopMinWidth?: number;
|
||||
renderOption?: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
})
|
||||
| (ComposerControlBase & {
|
||||
type: "action";
|
||||
onPress: () => void | Promise<void>;
|
||||
});
|
||||
|
||||
/** `field`: a New workspace setup field, a badge on desktop and a full-width field when compact. */
|
||||
export type ComposerControlSurface = "toolbar" | "sheet" | "field";
|
||||
|
||||
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
|
||||
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
|
||||
|
||||
const ComposerControlLockContext = createContext(false);
|
||||
/** Disables every control below it, for a surface that is not a composer and has its own lock. */
|
||||
export const ComposerControlLockProvider = ComposerControlLockContext.Provider;
|
||||
|
||||
export function useComposerControlLocked(): boolean {
|
||||
return useContext(ComposerControlLockContext);
|
||||
}
|
||||
|
||||
/** Where the control being drawn sits: the toolbar, a row in the overflow sheet, or a field. */
|
||||
export function useComposerControlSurface(): ComposerControlSurface {
|
||||
return useContext(ComposerControlSurfaceContext);
|
||||
}
|
||||
|
||||
interface ComposerControlProps {
|
||||
item: ComposerControlItem;
|
||||
surface: ComposerControlSurface;
|
||||
disabled?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Runs after a toolbar toggle or action, so the composer can take focus back. */
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
interface ControlView {
|
||||
options: ComboboxOption[];
|
||||
/** The list's selected id. */
|
||||
selectedId: string;
|
||||
/** What the trigger shows beside the icon. */
|
||||
value: string;
|
||||
iconOnly: boolean;
|
||||
tooltip: string;
|
||||
/** Whether pressing the trigger opens a list instead of acting. */
|
||||
opensList: boolean;
|
||||
}
|
||||
|
||||
function describeControl(
|
||||
item: ComposerControlItem,
|
||||
surface: ComposerControlSurface,
|
||||
t: TFunction,
|
||||
): ControlView {
|
||||
const isSheet = surface === "sheet";
|
||||
if (item.type === "action") {
|
||||
return {
|
||||
options: [],
|
||||
selectedId: "",
|
||||
value: "",
|
||||
iconOnly: true,
|
||||
tooltip: item.tooltip ?? item.title,
|
||||
opensList: false,
|
||||
};
|
||||
}
|
||||
if (item.type === "toggle") {
|
||||
const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off");
|
||||
return {
|
||||
options: [
|
||||
{ id: "true", label: t("agentControls.features.on") },
|
||||
{ id: "false", label: t("agentControls.features.off") },
|
||||
],
|
||||
selectedId: String(item.value),
|
||||
value: label,
|
||||
iconOnly: true,
|
||||
tooltip: item.tooltip ?? item.title,
|
||||
opensList: isSheet,
|
||||
};
|
||||
}
|
||||
const choiceLabel =
|
||||
item.choices.find((choice) => choice.value === item.value)?.label ??
|
||||
item.valueLabel ??
|
||||
item.title;
|
||||
const iconOnly = item.iconOnly === true;
|
||||
return {
|
||||
options: item.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
label: choice.label,
|
||||
description: choice.description,
|
||||
})),
|
||||
selectedId: String(item.value ?? ""),
|
||||
value: choiceLabel,
|
||||
iconOnly,
|
||||
// An icon-only trigger names its value; a sheet row shows the value, so it names the action.
|
||||
tooltip: iconOnly && !isSheet ? `${item.title}: ${choiceLabel}` : (item.tooltip ?? item.title),
|
||||
opensList: true,
|
||||
};
|
||||
}
|
||||
|
||||
type SelectItem = Extract<ComposerControlItem, { type: "select" }>;
|
||||
|
||||
/** The list options only a select carries; a toggle's two-choice list has none of them. */
|
||||
function listProps(select: SelectItem | null) {
|
||||
if (!select) return { searchable: false };
|
||||
return {
|
||||
desktopMinWidth: select.desktopMinWidth,
|
||||
searchable: select.searchable,
|
||||
remoteSearch: select.remoteSearch,
|
||||
onSearchQueryChange: select.onSearchQueryChange,
|
||||
emptyText: select.emptyText,
|
||||
renderOption: select.renderOption,
|
||||
};
|
||||
}
|
||||
|
||||
// A command that returns a promise keeps the control pending until it settles, and a failure
|
||||
// reaches the user the way a built-in live control's does.
|
||||
function usePendingRun() {
|
||||
const toast = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
const run = useCallback(
|
||||
(invoke: () => void | Promise<void>) => {
|
||||
try {
|
||||
const result = invoke();
|
||||
if (!result) return;
|
||||
setPending(true);
|
||||
result
|
||||
.catch((error: unknown) => toast.error(toErrorMessage(error)))
|
||||
.finally(() => setPending(false));
|
||||
} catch (error) {
|
||||
toast.error(toErrorMessage(error));
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
return { pending, run };
|
||||
}
|
||||
|
||||
export function ComposerControl({
|
||||
item,
|
||||
surface,
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
onActionComplete,
|
||||
}: ComposerControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const isSheet = surface === "sheet";
|
||||
const isField = surface === "field";
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
|
||||
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
|
||||
|
||||
const { pending, run } = usePendingRun();
|
||||
const handlePress = useCallback(() => {
|
||||
if (item.type === "toggle") run(() => item.onChange(!item.value));
|
||||
else if (item.type === "action") run(item.onPress);
|
||||
onActionComplete?.();
|
||||
}, [item, onActionComplete, run]);
|
||||
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (item.type === "select") run(() => item.onChange(value));
|
||||
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
|
||||
},
|
||||
[item, run],
|
||||
);
|
||||
|
||||
const select = item.type === "select" ? item : null;
|
||||
const accessibilityLabel = item.accessibilityLabel ?? view.tooltip;
|
||||
const trigger = isField ? (
|
||||
<FieldControlTrigger
|
||||
ref={anchorRef}
|
||||
item={item}
|
||||
view={view}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
disabled={disabled || pending}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
/>
|
||||
) : (
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
icon={item.icon}
|
||||
iconColor={item.highlight ? styles[item.highlight].color : undefined}
|
||||
surface={surface}
|
||||
label={item.title}
|
||||
value={view.value}
|
||||
showToolbarLabel={!view.iconOnly}
|
||||
showCaret={select?.showCaret === true && !isSheet}
|
||||
open={view.opensList && open}
|
||||
disabled={disabled || pending}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSheet || isField ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{view.tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{view.opensList ? (
|
||||
<Combobox
|
||||
options={view.options}
|
||||
value={view.selectedId}
|
||||
onSelect={handleSelect}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
presentation={isSheet ? "push" : undefined}
|
||||
desktopPlacement={DESKTOP_PLACEMENT[surface]}
|
||||
{...listProps(select)}
|
||||
header={isSheet ? sheetHeader : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a list opens on desktop; the sheet has its own presentation. */
|
||||
const DESKTOP_PLACEMENT = {
|
||||
toolbar: "top-start",
|
||||
sheet: undefined,
|
||||
field: "bottom-start",
|
||||
} as const;
|
||||
|
||||
/** What a field shows: a badge has no label, so a toggle names itself beside its state. */
|
||||
function fieldValue(item: ComposerControlItem, view: ControlView, compact: boolean): string {
|
||||
if (item.type === "action") return item.title;
|
||||
if (item.type === "toggle" && !compact) return `${item.title}: ${view.value}`;
|
||||
return view.value;
|
||||
}
|
||||
|
||||
const FieldControlTrigger = forwardRef<
|
||||
View,
|
||||
{
|
||||
item: ComposerControlItem;
|
||||
view: ControlView;
|
||||
accessibilityLabel: string;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
>(function FieldControlTrigger({ item, view, accessibilityLabel, disabled, onPress }, ref) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const Icon = item.icon;
|
||||
const iconColor = item.highlight ? styles[item.highlight].color : styles.fieldIcon.color;
|
||||
const icon = useMemo(() => <Icon size={ICON_SIZE.sm} color={iconColor} />, [Icon, iconColor]);
|
||||
return (
|
||||
<FieldTrigger
|
||||
ref={ref}
|
||||
variant={compact ? "field" : "badge"}
|
||||
label={item.title}
|
||||
icon={icon}
|
||||
value={fieldValue(item, view, compact)}
|
||||
tooltip={view.tooltip}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface ComposerControlsProps {
|
||||
items: readonly ComposerControlItem[];
|
||||
surface: ComposerControlSurface;
|
||||
disabled?: boolean;
|
||||
/** The id of the item whose list is open. */
|
||||
openId: string | null;
|
||||
onOpenIdChange: (id: string | null) => void;
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
/** A row of Paseo-owned controls that share one open list at a time. */
|
||||
export function ComposerControls({
|
||||
items,
|
||||
onOpenIdChange,
|
||||
openId,
|
||||
...rest
|
||||
}: ComposerControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<ComposerControlSlot
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={openId === item.id}
|
||||
onOpenIdChange={onOpenIdChange}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerControlSlot({
|
||||
item,
|
||||
open,
|
||||
onOpenIdChange,
|
||||
...rest
|
||||
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => onOpenIdChange(next ? item.id : null),
|
||||
[item.id, onOpenIdChange],
|
||||
);
|
||||
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
fieldIcon: { color: theme.colors.foregroundMuted },
|
||||
blue: { color: theme.colors.palette.blue[400] },
|
||||
green: { color: theme.colors.palette.green[400] },
|
||||
yellow: { color: theme.colors.palette.yellow[400] },
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
|
||||
import {
|
||||
getControlTooltip,
|
||||
isControlActive,
|
||||
readSelectOption,
|
||||
readToggleOption,
|
||||
type ComposerOptionControls,
|
||||
} from "@/agent-controls/option-controls";
|
||||
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import {
|
||||
type ComposerControlItem,
|
||||
type ComposerControlSurface,
|
||||
} from "@/composer/agent-controls/composer-control";
|
||||
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
|
||||
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
|
||||
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
function getFeatureHighlight(
|
||||
control: AgentOptionControl,
|
||||
options: ProviderOptions,
|
||||
): ComposerControlItem["highlight"] {
|
||||
if (!isControlActive(control, options)) return undefined;
|
||||
const color = getFeatureHighlightColor(control.key);
|
||||
return color === "default" ? undefined : color;
|
||||
}
|
||||
|
||||
function toFeatureItem(
|
||||
control: AgentOptionControl,
|
||||
optionControls: ComposerOptionControls,
|
||||
): ComposerControlItem {
|
||||
const { options, onSetOption } = optionControls;
|
||||
const base = {
|
||||
id: `feature-${control.key}`,
|
||||
title: control.label,
|
||||
icon: getAgentFeatureIcon(control.icon),
|
||||
highlight: getFeatureHighlight(control, options),
|
||||
testID: `agent-feature-${control.key}`,
|
||||
};
|
||||
if (control.type === "toggle") {
|
||||
return {
|
||||
...base,
|
||||
type: "toggle",
|
||||
tooltip: getControlTooltip(control),
|
||||
value: readToggleOption(control, options),
|
||||
onChange: (value) => onSetOption(control.key, value),
|
||||
};
|
||||
}
|
||||
const iconOnly = control.desktopTrigger === "icon";
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
tooltip: getControlTooltip(control),
|
||||
value: readSelectOption(control, options),
|
||||
choices: control.choices,
|
||||
iconOnly,
|
||||
onChange: (value) => onSetOption(control.key, value),
|
||||
};
|
||||
}
|
||||
|
||||
interface ThinkingControl {
|
||||
options: readonly { id: string; label: string }[];
|
||||
selectedId: string | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
/** The select's displayed label, falling back like the compact model sheet. */
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
|
||||
export function useAgentControlItems({
|
||||
surface,
|
||||
presentation,
|
||||
thinking,
|
||||
optionControls,
|
||||
}: {
|
||||
surface: ComposerControlSurface;
|
||||
presentation: ComposerControlPresentation;
|
||||
thinking: ThinkingControl | null;
|
||||
optionControls: ComposerOptionControls | undefined;
|
||||
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const thinkingItem = useMemo(
|
||||
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
|
||||
[presentation, surface, t, thinking],
|
||||
);
|
||||
const features = useMemo(
|
||||
() =>
|
||||
optionControls
|
||||
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
|
||||
: [],
|
||||
[optionControls],
|
||||
);
|
||||
return { thinking: thinkingItem, features };
|
||||
}
|
||||
|
||||
function toThinkingItem({
|
||||
thinking,
|
||||
surface,
|
||||
presentation,
|
||||
t,
|
||||
}: {
|
||||
thinking: ThinkingControl;
|
||||
surface: ComposerControlSurface;
|
||||
presentation: ComposerControlPresentation;
|
||||
t: TFunction;
|
||||
}): ComposerControlItem {
|
||||
const isSheet = surface === "sheet";
|
||||
return {
|
||||
id: "thinking",
|
||||
type: "select",
|
||||
title: t("agentControls.thinking.title"),
|
||||
icon: ThinkingIcon,
|
||||
tooltip: t(getAgentControlHintKey("thinking")),
|
||||
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
|
||||
value: thinking.displayLabel,
|
||||
}),
|
||||
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
|
||||
value: thinking.options.some((option) => option.id === thinking.selectedId)
|
||||
? (thinking.selectedId ?? null)
|
||||
: (thinking.options[0]?.id ?? null),
|
||||
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
|
||||
onChange: thinking.onSelect,
|
||||
iconOnly: !presentation.showThinkingLabel,
|
||||
showCaret: presentation.showCarets,
|
||||
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
|
||||
desktopMinWidth: 200,
|
||||
renderOption: (args) => <ThinkingOption {...args} />,
|
||||
};
|
||||
}
|
||||
|
||||
function ThinkingOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const leadingSlot = useMemo(
|
||||
() => <DensityIcon icon={ThinkingIcon} size="md" color="foreground" />,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
@@ -115,6 +116,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
@@ -127,6 +129,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
}),
|
||||
@@ -139,6 +142,7 @@ describe("composer control layout", () => {
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" }],
|
||||
pluginItems: 0,
|
||||
fontScale: 1.25,
|
||||
},
|
||||
}),
|
||||
@@ -154,6 +158,7 @@ describe("composer control layout", () => {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
pluginItems: 0,
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
@@ -175,6 +180,36 @@ describe("composer control layout", () => {
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("condenses to make room for plugin items and keeps them in the overflow", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "full" as const,
|
||||
geometry: POINTER,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [],
|
||||
fontScale: 1,
|
||||
pluginItems: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
|
||||
).toBe("condensed");
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
...base,
|
||||
availableWidth: 250,
|
||||
controls: { ...base.controls, pluginItems: 2 },
|
||||
}),
|
||||
).toBe("tight");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
|
||||
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
|
||||
expect(POINTER).toEqual({
|
||||
@@ -205,6 +240,7 @@ describe("composer control layout", () => {
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
fontScale: 1,
|
||||
pluginItems: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -221,6 +257,7 @@ describe("composer control layout", () => {
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
|
||||
fontScale: 1,
|
||||
pluginItems: 0,
|
||||
};
|
||||
|
||||
it("keeps the model family label with icon-only controls on an empty draft", () => {
|
||||
@@ -279,6 +316,7 @@ describe("composer control layout", () => {
|
||||
hasMode: true,
|
||||
features: [],
|
||||
fontScale: 1,
|
||||
pluginItems: 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ComposerControlPresence {
|
||||
hasThinking: boolean;
|
||||
hasMode: boolean;
|
||||
features: readonly ComposerFeatureControlPresence[];
|
||||
/** Plugin composer items. Each is budgeted like a select with a short label. */
|
||||
pluginItems: number;
|
||||
fontScale: number;
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG
|
||||
}
|
||||
|
||||
const DENSITY_HYSTERESIS = 12;
|
||||
const PLUGIN_ITEM_LABEL = "Plugin item";
|
||||
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
|
||||
const MODEL_FAMILY_LABEL_WIDTH = 56;
|
||||
|
||||
@@ -119,7 +122,9 @@ function resolveCondensedFloor(
|
||||
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(geometry.controlSize);
|
||||
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(geometry.controlSize);
|
||||
if (controls.features.length > 0 || controls.pluginItems > 0) {
|
||||
widths.push(geometry.controlSize);
|
||||
}
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
@@ -137,6 +142,11 @@ function resolveFullFloor(
|
||||
for (const feature of controls.features) {
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
|
||||
}
|
||||
for (let index = 0; index < controls.pluginItems; index++) {
|
||||
widths.push(
|
||||
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale, geometry),
|
||||
);
|
||||
}
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
@@ -152,7 +162,9 @@ function resolveTightFloor(
|
||||
}
|
||||
if (controls.hasThinking) widths.push(geometry.controlSize);
|
||||
if (controls.hasMode) widths.push(geometry.controlSize);
|
||||
if (controls.features.length > 0) widths.push(geometry.controlSize);
|
||||
if (controls.features.length > 0 || controls.pluginItems > 0) {
|
||||
widths.push(geometry.controlSize);
|
||||
}
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => {
|
||||
onSelectMode: () => undefined,
|
||||
models: [],
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
onSelectModel: () => true,
|
||||
isModelLoading: false,
|
||||
modelSelectorProviders: [],
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
onSelectProviderAndModel: () => true,
|
||||
options: {},
|
||||
onSetOption: () => undefined,
|
||||
thinkingOptions: [],
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onApplyAgentProfile: () => undefined,
|
||||
metadata: {},
|
||||
onSetMetadata: () => undefined,
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { TailEllipsisText } from "@/components/ui/tail-ellipsis-text";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
@@ -61,6 +62,8 @@ interface CompactModelSheetProps {
|
||||
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
|
||||
showLabels: boolean;
|
||||
canSwitchProvider: boolean;
|
||||
/** Carries the composer's contexts into the sheets, which draw away from it. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -151,6 +154,7 @@ export function CompactModelSheet({
|
||||
glyphs,
|
||||
showLabels,
|
||||
canSwitchProvider,
|
||||
contextBridge = null,
|
||||
children,
|
||||
}: CompactModelSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -331,6 +335,7 @@ export function CompactModelSheet({
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
contentStyle={styles.sheetBody}
|
||||
contextBridge={contextBridge}
|
||||
testID="agent-controls-model-sheet"
|
||||
>
|
||||
<View
|
||||
@@ -379,6 +384,7 @@ export function CompactModelSheet({
|
||||
scrollable={false}
|
||||
presentation="push"
|
||||
contentStyle={styles.sheetBody}
|
||||
contextBridge={contextBridge}
|
||||
testID="agent-controls-model-browser-sheet"
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from "react";
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ComposerControlLayoutProvider, useComposerControlLayout } from "./layout-context";
|
||||
import {
|
||||
ComposerAgentProvider,
|
||||
ComposerInputProvider,
|
||||
useComposerAgent,
|
||||
useComposerInput,
|
||||
} from "@/composer/session";
|
||||
import { ComposerModelProvider, useComposerModel } from "@/composer/model";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
|
||||
|
||||
/**
|
||||
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
|
||||
* composer. This carries across what a control in it reads from the composer: the agent, model and input
|
||||
* a plugin's `useComposer()` sits on, the layout, and the toast and query client the controls use.
|
||||
* Call it from inside the composer, outside any sheet.
|
||||
*/
|
||||
export function useComposerSheetBridge(): ContextBridge {
|
||||
const layout = useComposerControlLayout();
|
||||
const agent = useComposerAgent();
|
||||
const input = useComposerInput();
|
||||
const model = useComposerModel();
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback<ContextBridge>(
|
||||
(content) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastApiProvider api={toast}>
|
||||
<ComposerControlLayoutProvider value={layout}>
|
||||
<ComposerAgentProvider value={agent}>
|
||||
<ComposerModelProvider value={model}>
|
||||
{input ? (
|
||||
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</ComposerModelProvider>
|
||||
</ComposerAgentProvider>
|
||||
</ComposerControlLayoutProvider>
|
||||
</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
[agent, input, layout, model, queryClient, toast],
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getAgentControlHintKey,
|
||||
isFeatureActive,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from "./utils";
|
||||
@@ -17,51 +15,12 @@ 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 { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import {
|
||||
@@ -35,16 +35,6 @@ 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:
|
||||
@@ -59,6 +49,14 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveProviderSnapshotEntry(
|
||||
entries: readonly ProviderSnapshotEntry[] | null | undefined,
|
||||
provider: string | null | undefined,
|
||||
): ProviderSnapshotEntry | null {
|
||||
if (!entries || !provider) return null;
|
||||
return entries.find((entry) => entry.provider === provider) ?? null;
|
||||
}
|
||||
|
||||
function findModelById(
|
||||
models: AgentModelDefinition[] | null,
|
||||
modelId: string | null,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
draftControlsRequest,
|
||||
openDraftOptions,
|
||||
shownDraftOptions,
|
||||
} from "@/agent-controls/draft-options-model";
|
||||
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
|
||||
import { draftComposerAgent } from "./composer-agent";
|
||||
|
||||
function definition(id: string, modeId: string): AgentProviderDefinition {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
description: id,
|
||||
defaultModeId: modeId,
|
||||
modes: [{ id: modeId, label: modeId, icon: "ShieldCheck", colorTier: "safe" }],
|
||||
};
|
||||
}
|
||||
|
||||
function model(provider: string, id: string, isDefault = false): AgentModelDefinition {
|
||||
return { provider, id, label: id, isDefault };
|
||||
}
|
||||
|
||||
/** The host: two providers with catalogs, and one that is ready but lists no models. */
|
||||
const catalog: AgentFormCatalog = {
|
||||
selectableProviders: new Map([
|
||||
["claude", definition("claude", "default")],
|
||||
["codex", definition("codex", "auto")],
|
||||
["opencode", definition("opencode", "build")],
|
||||
]),
|
||||
models: new Map([
|
||||
["claude", [model("claude", "sonnet", true)]],
|
||||
["codex", [model("codex", "gpt-5.4", true), model("codex", "gpt-5.5")]],
|
||||
["opencode", []],
|
||||
]),
|
||||
providerPreferences: () => undefined,
|
||||
};
|
||||
|
||||
/** A draft composer on the real form and options models. */
|
||||
function openDraft(context: "draft" | "new-workspace") {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// The screen hands the options the provider the form reaches.
|
||||
form.subscribe(() => options.observeProvider(form.getState().form.provider));
|
||||
form.setProviderAndModel("claude", "");
|
||||
|
||||
function agent() {
|
||||
const { form: current } = form.getState();
|
||||
return draftComposerAgent({
|
||||
context,
|
||||
selectedProvider: current.provider,
|
||||
selectedModel: current.model,
|
||||
selectedMode: current.modeId,
|
||||
thinking: current.thinkingOptionId || null,
|
||||
isLocked: () => false,
|
||||
options: shownDraftOptions(options.getState(), {
|
||||
provider: current.provider,
|
||||
requestKey: "",
|
||||
}),
|
||||
metadata: {},
|
||||
onSelectProviderAndModel: form.setProviderAndModel,
|
||||
onSelectModel: form.setModel,
|
||||
onSelectMode: () => undefined,
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onSetOption: options.setOption,
|
||||
onSetMetadata: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** What the daemon is asked to resolve for the agent this draft would create. */
|
||||
function request() {
|
||||
const { form: current } = form.getState();
|
||||
return draftControlsRequest(options.getState(), {
|
||||
provider: current.provider,
|
||||
cwd: "/repo",
|
||||
modeId: current.modeId,
|
||||
modelId: current.model,
|
||||
});
|
||||
}
|
||||
|
||||
return { form, agent, request };
|
||||
}
|
||||
|
||||
describe("a draft's options", () => {
|
||||
it("belong to the provider the form has now, before the screen has rendered the change", () => {
|
||||
const form = openAgentForm(() => catalog);
|
||||
const options = openDraftOptions({
|
||||
provider: null,
|
||||
currentProvider: () => form.getState().form.provider,
|
||||
});
|
||||
// No subscription: the screen observes the provider in an effect, after the command ran.
|
||||
form.setProviderAndModel("claude", "");
|
||||
options.observeProvider("claude");
|
||||
options.setOption("context", "1m");
|
||||
|
||||
form.setProviderAndModel("codex", "");
|
||||
options.setOption("effort", "high");
|
||||
|
||||
expect(options.getState()).toMatchObject({ provider: "codex", edits: { effort: "high" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
|
||||
it("applies each awaited command before the next one runs", async () => {
|
||||
const draft = openDraft(context);
|
||||
// A plugin keeps the object it was handed across its awaits.
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await retained.setModel("gpt-5.5");
|
||||
await retained.setOptions({ context: "1m" });
|
||||
|
||||
const agent = draft.agent();
|
||||
expect(agent).toMatchObject({
|
||||
context,
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
options: { context: "1m" },
|
||||
});
|
||||
expect(draft.request()).toMatchObject({
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
providerOptions: { context: "1m" },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the options over when the provider is chosen by hand", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m" });
|
||||
expect(draft.agent().options).toEqual({ context: "1m" });
|
||||
await retained.setProvider("codex");
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
});
|
||||
|
||||
it("creates with the provider's default again once a plugin resets an option", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setOptions({ context: "1m", effort: "high" });
|
||||
await retained.setOptions({ context: null });
|
||||
|
||||
expect(draft.agent().options).toEqual({ effort: "high" });
|
||||
expect(draft.request()?.providerOptions).toEqual({ effort: "high" });
|
||||
|
||||
await retained.setOptions({ effort: null });
|
||||
|
||||
expect(draft.agent().options).toEqual({});
|
||||
expect(draft.request()?.providerOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a provider the host cannot run", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await expect(retained.setProvider("nope")).rejects.toThrow(
|
||||
"Unknown or unavailable provider: nope",
|
||||
);
|
||||
expect(draft.agent().provider).toBe("claude");
|
||||
});
|
||||
|
||||
it("accepts a model id the catalog does not list; the form resolves it as the model picker does", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("codex");
|
||||
await expect(retained.setModel("missing")).resolves.toBeUndefined();
|
||||
|
||||
// The form keeps a listed model when the one asked for is not in the catalog.
|
||||
expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" });
|
||||
});
|
||||
|
||||
it("keeps a model id as given when the provider lists no catalog", async () => {
|
||||
const draft = openDraft(context);
|
||||
const retained = draft.agent();
|
||||
|
||||
await retained.setProvider("opencode");
|
||||
await retained.setModel("anthropic/claude-x");
|
||||
|
||||
expect(draft.agent().model).toBe("anthropic/claude-x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ComposerAgent } from "@/composer/session";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
|
||||
/**
|
||||
* The agent a draft composer creates, as a plugin sees it. Its commands go to the form and the
|
||||
* options model, which apply them as they run, so a plugin can `await` one and issue the next.
|
||||
*/
|
||||
export function draftComposerAgent(
|
||||
props: Pick<
|
||||
DraftAgentControlsProps,
|
||||
| "context"
|
||||
| "disabled"
|
||||
| "selectedProvider"
|
||||
| "selectedModel"
|
||||
| "selectedMode"
|
||||
| "options"
|
||||
| "metadata"
|
||||
| "onSelectProviderAndModel"
|
||||
| "onSelectModel"
|
||||
| "onSelectMode"
|
||||
| "onSelectThinkingOption"
|
||||
| "onSetOption"
|
||||
| "onSetMetadata"
|
||||
> & { thinking: string | null; isLocked: () => boolean },
|
||||
): ComposerAgent {
|
||||
return {
|
||||
context: props.context ?? "draft",
|
||||
locked: props.disabled ?? false,
|
||||
isLocked: props.isLocked,
|
||||
provider: props.selectedProvider,
|
||||
model: props.selectedModel || null,
|
||||
mode: props.selectedMode || null,
|
||||
thinking: props.thinking,
|
||||
options: props.options,
|
||||
metadata: props.metadata,
|
||||
// An empty model id picks the provider's default model.
|
||||
setProvider: async (provider) => {
|
||||
if (!props.onSelectProviderAndModel(provider, "")) {
|
||||
throw new Error(`Unknown or unavailable provider: ${provider}`);
|
||||
}
|
||||
},
|
||||
setModel: async (modelId) => props.onSelectModel(modelId),
|
||||
setMode: async (modeId) => props.onSelectMode(modeId),
|
||||
setThinking: async (thinkingOptionId) => props.onSelectThinkingOption(thinkingOptionId),
|
||||
setOptions: async (patch) => {
|
||||
for (const [key, value] of Object.entries(patch)) props.onSetOption(key, value);
|
||||
},
|
||||
setMetadata: async (patch) => props.onSetMetadata(patch),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
@@ -20,12 +21,26 @@ export function resolveDraftKey(input: {
|
||||
|
||||
export function buildDraftAgentControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
features?: DraftAgentControlsProps["features"];
|
||||
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
|
||||
optionControls?: DraftAgentControlsProps["optionControls"];
|
||||
options: DraftAgentControlsProps["options"];
|
||||
onSetOption: DraftAgentControlsProps["onSetOption"];
|
||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||
context: NonNullable<DraftAgentControlsProps["context"]>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
onSetMetadata: (patch: Record<string, JsonValue>) => void;
|
||||
}): DraftAgentControlsProps {
|
||||
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
|
||||
const {
|
||||
formState,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onApplyAgentProfile,
|
||||
onDropdownClose,
|
||||
context,
|
||||
metadata,
|
||||
onSetMetadata,
|
||||
} = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
@@ -43,13 +58,17 @@ export function buildDraftAgentControls(input: {
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
onApplyAgentProfile,
|
||||
features,
|
||||
onSetFeature,
|
||||
optionControls,
|
||||
options,
|
||||
onSetOption,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||
onRetryModelProvider: formState.refreshProviderModels,
|
||||
isRetryingModelProvider: formState.isProviderModelsRefreshing,
|
||||
modelSelectorServerId: formState.selectedServerId,
|
||||
context,
|
||||
metadata,
|
||||
onSetMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
textReplacementRequestByDraftKey: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,6 +487,37 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows text another module put in the draft while the input is retained off screen", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) throw new Error("Expected hook result");
|
||||
return latest;
|
||||
}
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:replaced" });
|
||||
return null;
|
||||
}
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("Missing root container");
|
||||
const root = createTestRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Probe />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
const before = getLatest().textReplacement.key;
|
||||
|
||||
await act(async () => {
|
||||
useDraftStore.getState().replaceDraftText({ draftKey: "draft:replaced", text: "from away" });
|
||||
});
|
||||
|
||||
expect(getLatest().textReplacement).toMatchObject({ kind: "replace", text: "from away" });
|
||||
expect(getLatest().textReplacement.key).not.toBe(before);
|
||||
expect(getLatest().textSource.getSnapshot()).toBe("from away");
|
||||
});
|
||||
|
||||
it("clear resets text and attachments", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user