Compare commits

...

56 Commits

Author SHA1 Message Date
Mohamed Boudra
0ab41fbd9a chore(release): cut 0.1.83 2026-05-26 20:50:25 +07:00
Mohamed Boudra
ed6caa11c0 Update changelog for 0.1.83 2026-05-26 20:49:15 +07:00
Mohamed Boudra
74c8942a28 Confirm create-agent start before returning
Wait for the initial background turn to actually start before create returns a snapshot, without waiting for completion.

Freshly created agents now surface start failures as create failures, and both the WS/client and MCP surfaces reflect the shared create-agent lifecycle helper.
2026-05-26 20:38:27 +07:00
Mohamed Boudra
3eb1ba7d73 Narrow MCP schedule cadence handling to blank values 2026-05-26 20:15:12 +07:00
Mohamed Boudra
1d2c8b1648 Fix MCP schedule cadence placeholder handling
Treat undefined, blank, whitespace-only, and /__omit__ cadence values as absent at the MCP boundary for schedule create and update.

That keeps placeholder inputs from tripping cadence validation while preserving the real invalid cases for conflicting or missing cadence values.
2026-05-26 20:15:11 +07:00
Mohamed Boudra
88914ccba6 Fix draft mode chip for non-thinking models
Keep the desktop controls row alive when desktop extras exist, so draft mode chips still render even when a model has no thinking options.\n\nRestore the replacement draft e2e assertion for the mode chip now that it renders again.
2026-05-26 20:15:11 +07:00
paseo-ai[bot]
f79101a1f0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-26 10:13:47 +00:00
Mohamed Boudra
b492c70825 chore(release): cut 0.1.82 2026-05-26 17:09:51 +07:00
Mohamed Boudra
3ace6c5602 Defer slash-clear draft mode assertion to follow-up
After the composer refactor the replacement draft's mode picker
stopped rendering because the draft's provider snapshot does not
surface modes for the running provider. The model still carries
over correctly; only the mode picker UI is missing. Comment out
the assertion with a TODO so CI is unblocked while the picker bug
is tracked separately.
2026-05-26 16:47:27 +07:00
Mohamed Boudra
be7979fb34 Update slash clear test to expect formatted mode label
The composer refactor changed the agent-mode button's accessibility
label from the raw mode id ("load-test") to the formatted label
("Load test"). The user-facing change is intentional. Bring the test
in line with the new label.
2026-05-26 16:25:24 +07:00
Mohamed Boudra
58e7fd0e3e Close rewind menu when the action errors
handleSelect only ran setIsOpen(false) on the success path. When the
rewind action failed, the catch block swallowed the error but the menu
stayed open, blocking subsequent interactions with the page.

Close in finally so success and error both dismiss the menu.
2026-05-26 15:37:17 +07:00
Mohamed Boudra
81407c5ffc Derive tab testIDs from the tab target
The tab testID was reading from tabId, which intentionally stays as the
draft id after a draft -> agent handoff so the panel React key remains
stable and the component does not remount. The data-testid was riding
along on that identity and stayed workspace-tab-draft_xxx forever, even
though the tab semantically became an agent. Tests that look for
workspace-tab-agent_* timed out.

Read testIDs from tab.target via buildDeterministicWorkspaceTabId so the
testID reflects what the tab represents. React key is unchanged.
2026-05-26 15:37:08 +07:00
Mohamed Boudra
9c5f6007be Update changelog for 0.1.82 2026-05-26 15:04:41 +07:00
Mohamed Boudra
fab721205d Fix autocomplete popover render warning 2026-05-26 14:51:43 +07:00
Mohamed Boudra
e74c7f9554 Show OpenCode agent colors in modes 2026-05-26 14:36:45 +07:00
Mohamed Boudra
4231dbbba4 Organize composer into its own module 2026-05-26 14:32:45 +07:00
Mohamed Boudra
7e792e899a Antialias web app fonts 2026-05-26 12:56:38 +07:00
Mohamed Boudra
1d38aacf5c Fix OpenCode replacement prompts after interrupt 2026-05-26 11:54:36 +07:00
Mohamed Boudra
a3071b46a4 Fix optimistic user message reconciliation 2026-05-26 11:37:13 +07:00
Mohamed Boudra
483790d8c9 Replay completed tool results from provider history 2026-05-26 11:36:27 +07:00
Mohamed Boudra
bde0efc9b0 Stop rereading provider snapshots while loading 2026-05-26 11:35:20 +07:00
Mohamed Boudra
6f16900b8a Hide paseo-system prompts from the agent timeline
System-injected prompts (finish notifications, schedule fires) are wrapped
in a <paseo-system> envelope and dispatched as regular user messages to
the provider. The app was showing them as user-message turns. Detect the
envelope at the manager boundary and skip them during live runs and
history replay so only real user input renders.
2026-05-26 11:28:22 +07:00
Mohamed Boudra
c31ab074e9 Fix autocomplete popover anchoring
Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
2026-05-26 11:17:03 +07:00
Mohamed Boudra
bf69ebeddf Stop persisting agent prompt as the backend title
config.title is now strictly the caller's creation intent and is never
mutated. The prompt-derived placeholder lives only in record.title at
creation, so the metadata generator's upfront gate (hasExplicitTitle) is
the only one and writes unconditionally when it runs. Drops the two
projection fallbacks to record.config.title that were the second
hidden path for the prompt to surface as a title.
2026-05-26 10:42:36 +07:00
Mohamed Boudra
22e014aaff Fix mobile autocomplete sidebar layering 2026-05-26 10:02:27 +07:00
Mohamed Boudra
91b05b4111 Fix autocomplete popover flicker 2026-05-26 09:35:28 +07:00
Mohamed Boudra
6e56ee9e32 Fix terminal color query replies
Answer OSC color queries from the daemon and suppress browser-side replies so response bytes do not race back into the shell prompt as user input.
2026-05-26 09:32:15 +07:00
Mohamed Boudra
48d4e3e408 Reduce draft tab descriptor updates 2026-05-25 21:37:04 +07:00
Mohamed Boudra
30a8bffc24 Show optimistic chat for new workspaces 2026-05-25 21:26:31 +07:00
Mohamed Boudra
d271597ee8 Clean up read tool activity content 2026-05-25 21:08:11 +07:00
Mohamed Boudra
893dd6d1fe Fix write tool activity mapping 2026-05-25 20:59:44 +07:00
Mohamed Boudra
53faba64ca Fix draft agent handoff continuity
Move optimistic user message reconciliation into the stream reducers and keep draft-created agents in a handoff lifecycle until authoritative history lands.
2026-05-25 20:53:49 +07:00
Mohamed Boudra
e5658654c0 Open rewind in an anchored dropdown menu
Replace the full-screen rewind sheet with a dropdown anchored to the
trigger, with a muted warning header and inline pending state on the
chosen action. Also realign the user message action row so timestamp,
rewind, and copy share a single 24px baseline — the copy button was
inheriting assistant-footer paddings that pushed the row taller than
the icons.
2026-05-25 20:18:57 +07:00
Mohamed Boudra
f0730d5eac Polish compact workspace header actions 2026-05-25 19:50:31 +07:00
Mohamed Boudra
e312e0d6f3 Focus mobile terminal only on tap 2026-05-25 19:27:45 +07:00
Mohamed Boudra
9806a893ed Improve mobile terminal keyboard layout 2026-05-25 19:26:38 +07:00
Mohamed Boudra
5dd2afaac9 Hide terminal keyboard when opening sidebars 2026-05-25 19:26:38 +07:00
Mohamed Boudra
2252046f56 Fix mobile terminal focus and background 2026-05-25 19:26:25 +07:00
Mohamed Boudra
423956c6a0 Preserve draft composer permission mode (#1175) 2026-05-25 19:19:44 +08:00
Mohamed Boudra
19289286a6 Accept dropped files in the terminal (#1173) 2026-05-25 19:16:39 +08:00
Mohamed Boudra
e40ad0c00e Link terminal file paths to workspace previews (#1174) 2026-05-25 19:12:43 +08:00
Mohamed Boudra
1e68283565 Allow PR merge when GitHub reports it ready (#1172) 2026-05-25 10:05:35 +00:00
ElliotWu
8a2e5c786e fix(app): swallow URIError in assistant file link parser on bare '%'
decodeURIComponent throws on '%' not followed by two hex digits. The two
calls inside parseAssistantFileLink and normalizeFileUrlPath had no
try/catch, so any timeline message containing strings like '100% packet
loss' (ping output), '%PATH%' or '0% off' could escape into the React
tree and unmount the agent route, leaving the renderer blank.

Wrap both calls in a local safeDecodeURIComponent that returns the input
unchanged when decoding fails, mirroring the helper already used in
host-routes.ts.

Closes #1148
2026-05-25 10:05:00 +00:00
Mohamed Boudra
178708a44e Show OpenCode tool activity consistently (#1171)
* fix(server): map OpenCode tool details

* test(server): update OpenCode history client stub
2026-05-25 18:04:33 +08:00
Mohamed Boudra
05eec04b10 Format settings latency readouts (#1170) 2026-05-25 17:47:31 +08:00
ayhan malkoc
b7ea5b4c9d Show workspace scripts on mobile header (#1093) 2026-05-25 17:45:48 +08:00
Alcimério Rangel
655d05add8 Add Devin CLI to the ACP provider catalog 2026-05-25 17:38:56 +08:00
Mohamed Boudra
f137705f1e Fix provider models per workspace (#1167)
* Scope provider snapshots by workspace

* Fix provider snapshot cwd test on Windows
2026-05-25 17:12:15 +08:00
Mohamed Boudra
6b35c0a640 Add persistent OpenCode permission actions (#1168) 2026-05-25 17:11:53 +08:00
Mohamed Boudra
a91dfb63c6 Treat OpenCode abort as cancellation, not error (#1169)
OpenCode emits session.error with MessageAbortedError when the user
interrupts a running turn. Paseo was mapping every session.error to
turn_failed, which set the agent lifecycle to error and appended a
raw JSON system error message to the timeline.

Now MessageAbortedError translates to turn_canceled (same as an
explicit cancel), so the agent returns to idle with no error state.
Real errors still produce turn_failed.
2026-05-25 17:11:44 +08:00
Mohamed Boudra
132e572d6e Rewind chat or files from any user message (#1154)
* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
2026-05-25 17:11:34 +08:00
Mohamed Boudra
e966f70322 Merge remote-tracking branch 'origin/main' 2026-05-25 12:19:49 +07:00
Mohamed Boudra
62780448dc refactor(app): organize agent stream rendering 2026-05-25 12:15:20 +07:00
Mohamed Boudra
5755ca77f8 Show cumulative session cost (#1163) 2026-05-25 12:13:37 +08:00
Mohamed Boudra
ba724956df feat(website): serve /llms.txt and raw markdown for docs
Adds an llms.txt at the site root with a static product preamble so
LLMs can answer "what is Paseo" without following any links, plus a
curated index of docs, alternatives, and the 38 agent pages. Doc pages
now expose Copy/View as markdown buttons and a raw .md version at
<path>.md following the llms.txt convention.

Also skips the canonical-host redirect for localhost so the dev server
stops punting every request to production.
2026-05-24 21:38:33 +07:00
paseo-ai[bot]
7fbbb44ad6 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 06:16:43 +00:00
265 changed files with 15949 additions and 5660 deletions

View File

@@ -1,5 +1,43 @@
# Changelog
## 0.1.83 - 2026-05-26
### Fixed
- Creating an agent via MCP now waits for it to actually start, so failures surface as a clear create error
- Scheduling an agent via MCP no longer rejects blank cadence placeholders
- Draft messages show the agent mode chip again on models without thinking options
## 0.1.82 - 2026-05-26
### Added
- **Rewind chat or files from any user message** ([#1154](https://github.com/getpaseo/paseo/pull/1154))
- **See the cumulative cost of an agent session** ([#1163](https://github.com/getpaseo/paseo/pull/1163))
- **Drop files onto the terminal to insert their paths** ([#1173](https://github.com/getpaseo/paseo/pull/1173))
- **Tap a file path in the terminal to open it in the workspace preview** ([#1174](https://github.com/getpaseo/paseo/pull/1174))
- **Approve OpenCode permissions for the whole session** ([#1168](https://github.com/getpaseo/paseo/pull/1168))
- **Workspace scripts now appear on the mobile header** ([#1093](https://github.com/getpaseo/paseo/pull/1093) by [@ayhanmalkoc](https://github.com/ayhanmalkoc))
- Devin CLI in the ACP provider catalog (by [@Alcimerio](https://github.com/Alcimerio))
- OpenCode agents show their mode colors
### Improved
- Mobile terminal keyboard hides when you open a sidebar
- Tool activity for read, write, and OpenCode tools renders more consistently ([#1171](https://github.com/getpaseo/paseo/pull/1171))
- Compact workspace header actions are tidier
- Settings latency readouts are easier to scan ([#1170](https://github.com/getpaseo/paseo/pull/1170))
- Pull request merge is available as soon as GitHub reports the PR is ready ([#1172](https://github.com/getpaseo/paseo/pull/1172))
### Fixed
- Mobile slash command autocomplete no longer flickers or mis-layers
- Interrupting an OpenCode agent returns it to idle instead of showing an error ([#1169](https://github.com/getpaseo/paseo/pull/1169))
- Provider model selection per workspace is honored ([#1167](https://github.com/getpaseo/paseo/pull/1167))
- Draft composer keeps the permission mode you selected ([#1175](https://github.com/getpaseo/paseo/pull/1175))
- Terminal color queries no longer return malformed replies
- File links in chat no longer crash when a message contains a bare '%' (by [@Elliotwu-7](https://github.com/Elliotwu-7))
## 0.1.81 - 2026-05-24
### Added

View File

@@ -56,7 +56,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
## The subagents track
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
```
parentAgentId === thisAgent.id AND !archivedAt

View File

@@ -79,6 +79,7 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)

View File

@@ -7,18 +7,18 @@ canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | -------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Inline slash-command autocomplete above a focused TextInput |
| File | Use case |
| ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete coexists with a
focused TextInput. There is no shared "floating panel" primitive yet — when a
fifth use case shows up we can revisit; until then prefer copying the closest
file and trimming. The four gotchas below are what each of them re-learned.
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
input focused while its scrollable list lives in a Portal. There is no shared
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
until then prefer copying the closest file and trimming.
## Gotcha 1 — Android touch hit-test by parent bounds
@@ -40,13 +40,14 @@ Two escape hatches in the codebase:
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point (`PortalHost name="root"` in
`app/_layout.tsx`) whose bounds cover the screen. Same window, same IME,
hit-test works because the new parent is full-screen. This is the right
default when you must keep IME attachment.
hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is
full-screen. This is the right default when you must keep IME attachment.
Choose the host by layer: app-global overlays use the root host; content
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
cover them.
Choose Modal vs Portal by whether you need the underlying input to keep its
keyboard.
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
@@ -58,13 +59,20 @@ quietly relying on:
component may stay mounted (offscreen, in a tab) — the popover stays with it.
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`. See
`composer.tsx:1604` and `autocomplete-popover.tsx`.
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is at the
app root — it does not get that transform unless you apply it yourself.
together because they share the SharedValue. A portal'd popover is outside
the composer tree — it does not get that transform unless you apply it
yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
The fix for transforms is Gotcha 3.
@@ -76,7 +84,8 @@ properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
Do not try to track the keyboard by re-measuring on every frame. Instead,
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
@@ -86,31 +95,29 @@ Do not try to track the keyboard by re-measuring on every frame. Instead,
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed. See `autocomplete-popover.tsx`
`openShift` / `shift` / `animatedTransformStyle`.
lockstep, no re-measurement needed.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
## Gotcha 4 — Status-bar offset for Portal-based positioning on Android
## Gotcha 4 — Host-relative positioning before platform offsets
On Android, `measureInWindow` returns coords **below** the status bar (the
status bar is system-owned space). A Portal overlay positioned at `top: 0`
inside the `PortalProvider` may start at the **top of the window** (above the
status bar) depending on the wrapper chain. The result: position the popover
at the measured `rect.y` and it sits `StatusBar.currentHeight` higher than the
anchor on Android.
The generic anchored-overlay rule is:
Mirror what tooltip does: shift the measured rect down by the status bar height
on Android only.
1. Measure the anchor with `measureInWindow`.
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
```ts
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
left = anchorRect.x - hostRect.x;
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
```
See `tooltip.tsx` and `autocomplete-popover.tsx`. iOS and web do not need this.
Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
## Gotcha 5 — The two-measurement flash
@@ -156,18 +163,17 @@ Before you write a new one, ask:
If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Does the panel sit above something that moves with the keyboard?** If
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
4. **Will the panel's content height vary?** If yes, you need both
5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded** — autocomplete's inner detail card escapes the 220-cap
container, which is why bottom-anchored looked clean but rendered
wrong. Verify before you commit.
5. **Are you on Android with Portal?** Add the status-bar offset (Gotcha 4).
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.

View File

@@ -21,6 +21,13 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
## Inconsistencies (documented, not papered over)

View File

@@ -249,6 +249,23 @@ const { theme } = useUnistyles();
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
## Native Chat Stream Layout
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
- chronological stream order
- strategy-ordered array order
- native inverted cell visual order
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
Platform-specific stream edges belong on `StreamStrategy`:
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
## iOS Simulator
```bash

View File

@@ -28,17 +28,23 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
---
## Provider Snapshot Refresh Contract
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
---

View File

@@ -111,7 +111,7 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
### Test setup

View File

@@ -1 +1 @@
sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=
sha256-WAxd4FSYNGYPT7v0QYTkYF5AacMrpYU+zjMyMTcWufo=

26
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.81",
"version": "0.1.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.81",
"version": "0.1.83",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36323,7 +36323,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.81",
"version": "0.1.83",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36544,10 +36544,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.81",
"version": "0.1.83",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.81",
"@getpaseo/server": "0.1.83",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36902,7 +36902,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.81",
"version": "0.1.83",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37263,7 +37263,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.81",
"version": "0.1.83",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37299,7 +37299,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.81",
"version": "0.1.83",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37637,7 +37637,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.81",
"version": "0.1.83",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37964,12 +37964,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.81",
"version": "0.1.83",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.81",
"@getpaseo/relay": "0.1.81",
"@getpaseo/highlight": "0.1.83",
"@getpaseo/relay": "0.1.83",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38843,7 +38843,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.81",
"version": "0.1.83",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.81",
"version": "0.1.83",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -126,7 +126,7 @@ async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<v
await expect(
page.getByRole("button", { name: "Select model (Ten second stream)" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
}
async function createAgentFromReplacementDraft(page: Page): Promise<void> {

View File

@@ -0,0 +1,536 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
const TEST_COMMANDS = [
{
name: "tdd",
description: "Write a red test, verify it fails for the right reason, implement to green",
argumentHint: "",
},
{
name: "help",
description: "Show help for the current agent session and available slash commands",
argumentHint: "",
},
{
name: "hello",
description: "Insert a friendly greeting prompt into the current composer",
argumentHint: "",
},
{
name: "heapdump",
description: "Dump the JavaScript heap for local desktop debugging",
argumentHint: "",
},
{
name: "health",
description: "Show runtime health checks and connection diagnostics",
argumentHint: "",
},
{
name: "history",
description: "Summarize recent session history",
argumentHint: "",
},
{
name: "handoff",
description: "Prepare a complete handoff note for another agent",
argumentHint: "[agent]",
},
{
name: "hover",
description: "Audit hover behavior in desktop web surfaces",
argumentHint: "",
},
{
name: "harness",
description: "Inspect the local test harness configuration",
argumentHint: "",
},
{
name: "hydrate",
description: "Refresh persisted state used by the current workspace",
argumentHint: "",
},
{
name: "highlight",
description: "Highlight important changes in the active diff",
argumentHint: "",
},
{
name: "home",
description: "Navigate back to the workspace home surface",
argumentHint: "",
},
{
name: "host",
description: "Inspect host connection metadata",
argumentHint: "",
},
] as const;
interface PopoverFrame {
exists: boolean;
top: number;
bottom: number;
height: number;
opacity: number;
display: string;
visibility: string;
timestamp: number;
}
interface PopoverFrameRecorderWindow extends Window {
__composerAutocompleteFrames?: PopoverFrame[];
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort || daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
}
return daemonPort;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
ws.send(message);
return;
}
try {
const parsed = JSON.parse(message) as {
type?: string;
message?: {
type?: string;
payload?: {
commands?: unknown;
error?: string | null;
};
};
};
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
parsed.message.payload
) {
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
return;
}
} catch {
// Forward non-JSON frames unchanged.
}
ws.send(message);
});
});
}
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
const opened = await client.openProject(cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
}
}
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
const operation = cleanup().catch(() => undefined);
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
): Promise<{
cleanup: () => Promise<void>;
}> {
const repo = await createTempGitRepo("autocomplete-popover-");
const client = await connectTerminalClient();
try {
await openProject(client, repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Autocomplete popover regression",
modeId: "load-test",
model: "ten-second-stream",
});
const route = `${buildHostWorkspaceRoute(
getServerId(),
repo.path,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(route);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
if (options?.expectWorkspaceTab !== false) {
await expectWorkspaceTabVisible(page, agent.id);
}
await expectComposerVisible(page);
return {
cleanup: async () => {
await cleanupWithin(2_000, () => client.close());
await cleanupWithin(2_000, () => repo.cleanup());
},
};
} catch (error) {
await cleanupWithin(2_000, () => client.close());
await cleanupWithin(2_000, () => repo.cleanup());
throw error;
}
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover).toBeVisible({ timeout: 30_000 });
await expect
.poll(
async () =>
popover.evaluate((element) => {
const style = window.getComputedStyle(element);
return Number(style.opacity);
}),
{ timeout: 30_000 },
)
.toBeGreaterThan(0.95);
const box = await popover.boundingBox();
if (!box) {
throw new Error("Autocomplete popover did not produce a bounding box.");
}
return {
top: box.y,
bottom: box.y + box.height,
height: box.height,
};
}
async function startPopoverFrameRecorder(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__composerAutocompleteFrames = [];
let active = true;
const record = () => {
if (!active) return;
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
if (element instanceof HTMLElement) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
win.__composerAutocompleteFrames?.push({
exists: true,
top: rect.top,
bottom: rect.bottom,
height: rect.height,
opacity: Number(style.opacity),
display: style.display,
visibility: style.visibility,
timestamp: performance.now(),
});
} else {
win.__composerAutocompleteFrames?.push({
exists: false,
top: 0,
bottom: 0,
height: 0,
opacity: 0,
display: "none",
visibility: "hidden",
timestamp: performance.now(),
});
}
window.requestAnimationFrame(record);
};
win.__stopComposerAutocompleteFrameRecorder = () => {
active = false;
};
window.requestAnimationFrame(record);
});
}
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
return page.evaluate(() => {
const win = window as PopoverFrameRecorderWindow;
win.__stopComposerAutocompleteFrameRecorder?.();
return win.__composerAutocompleteFrames ?? [];
});
}
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
return frames.filter(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
}
function formatFrame(frame: PopoverFrame | undefined): string {
if (!frame) return "none";
return JSON.stringify({
top: Math.round(frame.top),
bottom: Math.round(frame.bottom),
height: Math.round(frame.height),
opacity: frame.opacity,
});
}
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
const visibleFrames = visiblePopoverFrames(frames);
expect(visibleFrames.length).toBeGreaterThan(0);
const finalFrame = visibleFrames[visibleFrames.length - 1];
const jumpingFrame = visibleFrames.find(
(frame) =>
Math.abs(frame.top - finalFrame.top) > 4 ||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
Math.abs(frame.height - finalFrame.height) > 4,
);
expect(
jumpingFrame,
`expected first visible popover paint to be stable; first=${formatFrame(
visibleFrames[0],
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
).toBeUndefined();
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
frame.exists &&
frame.height > 0 &&
frame.opacity > 0.95 &&
frame.display !== "none" &&
frame.visibility !== "hidden",
);
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
const hiddenFrame = frames
.slice(firstVisibleIndex)
.find((frame) => !frame.exists || frame.opacity < 0.95);
expect(
hiddenFrame,
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
).toBeUndefined();
}
test.describe("Composer autocomplete", () => {
test("does not flash at the wrong position on the first slash command paint", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.click();
await startPopoverFrameRecorder(page);
await page.keyboard.type("/");
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverFramesStable(frames);
} finally {
await agent.cleanup();
}
});
test("does not jump when deleting a slash command search", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/he");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
} finally {
await agent.cleanup();
}
});
test("shrinks to filtered slash command results without moving the bottom edge", async ({
page,
}) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const allCommands = await visiblePopoverBox(page);
await input.fill("/tdd");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const oneCommand = await visiblePopoverBox(page);
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
} finally {
await agent.cleanup();
}
});
test("stays visible while filtering slash command results", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await startPopoverFrameRecorder(page);
await page.keyboard.type("tdd", { delay: 40 });
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.waitForTimeout(250);
const frames = await stopPopoverFrameRecorder(page);
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
} finally {
await agent.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const composerBox = await page.getByTestId("message-input-root").boundingBox();
const popoverBox = await popover.boundingBox();
expect(composerBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
});
});
});

View File

@@ -537,13 +537,22 @@ async function awaitRelayReady(
}
}
async function startRelay(): Promise<number> {
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const relayPort = await getAvailablePortExcluding(excludedPorts);
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -729,7 +738,7 @@ export default async function globalSetup() {
const dictation = await resolveDictationConfig();
try {
const relayPort = await startRelay();
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,

View File

@@ -0,0 +1,342 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expectComposerEditable, submitMessage } from "./composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
export type RewindFlowMode = "conversation" | "files" | "both";
export interface AgentHandle {
page: Page;
client: TerminalPerfDaemonClient;
agentId: string;
cwd: string;
provider: RewindFlowProvider;
}
export interface TranscriptMessage {
role: "user" | "assistant";
text: string | RegExp;
}
interface ProviderLaunchConfig {
provider: RewindFlowProvider;
model?: string;
thinkingOptionId?: string;
modeId?: string;
}
const SEND_TIMEOUT_MS = 240_000;
const REWIND_TIMEOUT_MS = 120_000;
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
switch (provider) {
case "claude":
return { provider, model: "haiku", modeId: "bypassPermissions" };
case "codex":
return {
provider,
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
};
case "opencode":
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
case "pi":
return {
provider,
model: "openrouter/google/gemini-2.5-flash-lite",
thinkingOptionId: "medium",
};
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await assertComposerIdle({ page });
}
function visibleChatMessages(page: Page) {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
}
async function transcript(
page: Page,
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
elements.map((element) => ({
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
| "user"
| "assistant",
text: (element.textContent ?? "")
.replace(/\s+/g, " ")
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
.trim(),
})),
);
return coalesceAssistantTurnSegments(rawMessages);
}
function coalesceAssistantTurnSegments(
messages: Array<{ role: "user" | "assistant"; text: string }>,
): Array<{ role: "user" | "assistant"; text: string }> {
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const message of messages) {
const previous = transcriptMessages.at(-1);
if (message.role === "assistant" && previous?.role === "assistant") {
const joinedText =
previous.text && message.text
? `${previous.text}\n${message.text}`
: previous.text || message.text;
transcriptMessages[transcriptMessages.length - 1] = {
role: "assistant",
text: joinedText,
};
continue;
}
transcriptMessages.push(message);
}
return transcriptMessages.filter(
(message) => message.role !== "assistant" || message.text.length > 0,
);
}
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
if (typeof expected === "string") {
return actual === expected;
}
return expected.test(actual);
}
function formatExpectedMessage(message: TranscriptMessage): string {
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
return `${message.role}:${text}`;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export async function launchAgent(input: {
page: Page;
provider: RewindFlowProvider;
cwd: string;
mode: "full-access";
}): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: input.cwd,
stdio: "ignore",
});
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectTerminalClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
return handle;
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.close().catch(() => undefined);
}
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
const before = await transcript(handle.page);
await submitMessage(handle.page, text);
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
if (finish.status !== "idle") {
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
throw new Error(
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
);
}
if (finish.final?.lastError) {
throw new Error(finish.final.lastError);
}
await expect
.poll(async () => transcript(handle.page), { timeout: 30_000 })
.toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", text }),
expect.objectContaining({ role: "assistant" }),
]),
);
await expect
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
timeout: 30_000,
})
.toBeGreaterThanOrEqual(before.length + 2);
await assertComposerIdle(handle);
}
export async function assertChatTranscript(
handle: Pick<AgentHandle, "page">,
expected: TranscriptMessage[],
): Promise<void> {
await expect
.poll(
async () => {
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
return JSON.stringify(actual);
}
const matches = actual.every(
(message, index) =>
message.role === expected[index]?.role &&
expectedTextMatches(message.text, expected[index]!.text),
);
return matches ? "match" : JSON.stringify(actual);
},
{ timeout: 30_000 },
)
.toBe("match");
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
throw new Error(
`Expected ${expected.length} chat messages (${expected
.map(formatExpectedMessage)
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
);
}
}
export async function rewindMessage(
handle: AgentHandle,
userMessageIndex: number,
mode: RewindFlowMode,
): Promise<void> {
const beforeEpoch = await fetchTimelineEpoch(handle);
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
await expect(userMessage).toBeVisible({ timeout: 30_000 });
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
const userText = userMessages[userMessageIndex]?.text;
if (!userText) {
throw new Error(`No user message found at index ${userMessageIndex}`);
}
await userMessage
.getByText(new RegExp(escapeRegExp(userText)))
.first()
.hover();
const trigger = userMessage.getByTestId("rewind-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
await expect(modeItem).toBeVisible({ timeout: 10_000 });
await modeItem.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
if (mode !== "files") {
await waitForNextTimelineEpoch(handle, beforeEpoch);
}
await assertComposerIdle(handle);
}
export async function assertFileExists(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
}
export async function assertFileMissing(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
}
export async function assertFileContains(filePath: string, text: string): Promise<void> {
await assertFileExists(filePath);
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
}
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
}
export async function cleanupRewindFlow(input: {
handle?: AgentHandle;
cwd: string;
}): Promise<void> {
if (input.handle) {
await closeAgent(input.handle);
}
rmSync(input.cwd, { recursive: true, force: true });
}
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
const client = handle.client as TerminalPerfDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
limit: 0,
});
return timeline.epoch;
}
async function waitForNextTimelineEpoch(
handle: AgentHandle,
beforeEpoch: string | undefined,
): Promise<void> {
await expect
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
.not.toBe(beforeEpoch);
}

View File

@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -29,12 +30,20 @@ export interface TerminalPerfDaemonClient {
featureValues?: Record<string, unknown>;
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
}>;
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -68,6 +77,7 @@ interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
@@ -91,12 +101,22 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory,
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, workspaceId);

View File

@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "claude",
rewindMode: "both",
fileReverted: true,
});

View File

@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "codex",
rewindMode: "conversation",
fileReverted: false,
});

View File

@@ -0,0 +1,8 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "opencode",
initialRewindMode: "both",
rewindMode: "both",
fileReverted: true,
});

View File

@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "pi",
rewindMode: "conversation",
fileReverted: false,
});

View File

@@ -0,0 +1,88 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "./fixtures";
import {
assertChatTranscript,
assertComposerIdle,
assertFileContains,
assertFileExists,
assertFileMissing,
cleanupRewindFlow,
launchAgent,
rewindMessage,
sendMessage,
type AgentHandle,
type RewindFlowMode,
type RewindFlowProvider,
} from "./helpers/rewind-flow";
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
interface RewindFlowCase {
provider: RewindFlowProvider;
initialRewindMode?: RewindFlowMode;
rewindMode: RewindFlowMode;
fileReverted: boolean;
}
export function defineRewindFlowSpec(input: RewindFlowCase): void {
test.describe(`rewind flow - ${input.provider}`, () => {
test.setTimeout(600_000);
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: input.provider,
cwd,
mode: "full-access",
});
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
await assertChatTranscript(handle, []);
await assertComposerIdle(handle);
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await sendMessage(handle, FILE_PROMPT);
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
{ role: "user", text: /Use the Write tool/ },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 1, input.rewindMode);
if (input.fileReverted) {
await assertFileMissing(path.join(cwd, "qa.txt"));
} else {
await assertFileExists(path.join(cwd, "qa.txt"));
}
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await assertComposerIdle(handle);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
}

View File

@@ -0,0 +1,167 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const repo = await createTempGitRepo("rewind-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
} finally {
await client.close();
await repo.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const repo = await createTempGitRepo("rewind-failure-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind failure e2e",
modeId: "load-test",
model: "ten-second-stream",
featureValues: {
mockRewindError: rewindError,
},
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
const OSC11_CAPTURE_SCRIPT = `
let captured = Buffer.alloc(0);
function finish() {
process.stdout.write("PASEO_OSC11_CAPTURE:" + JSON.stringify(captured.toString("latin1")) + "\\n");
process.exit(0);
}
process.stdout.write("\\x1b]11;?\\x07");
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.on("data", (chunk) => {
captured = Buffer.concat([captured, chunk]);
if (captured.includes(Buffer.from("rgb:"))) {
finish();
}
});
setTimeout(finish, 700);
`;
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("does not send browser OSC 11 color-query replies back to the PTY", async ({ page }) => {
const terminalInstance = await harness.createTerminal({ name: "osc11-query" });
try {
await harness.openTerminal(page, { terminalId: terminalInstance.id });
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
await terminal.pressSequentially("node osc11-capture.cjs\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes("PASEO_OSC11_CAPTURE:"), 10_000);
await page.waitForTimeout(500);
const text = await getTerminalBufferText(page);
expect(text).toContain("rgb:0b0b/0b0b/0b0b");
expect(text).not.toContain("rgb:ffff/ffff/ffff");
} finally {
await harness.killTerminal(terminalInstance.id);
}
});
});

View File

@@ -0,0 +1,92 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerEditable,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
}
async function expectIdleComposer(page: Page): Promise<void> {
await expectComposerEditable(page);
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 15_000,
});
}
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
await expectIdleComposer(page);
await page.waitForTimeout(1_000);
await expectIdleComposer(page);
}
test.describe("User message UI contract", () => {
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
const repo = await createTempGitRepo("user-message-contract-e2e-");
const client = await connectTerminalClient();
const prompts = [
"emit 1 coalesced agent stream updates for user message contract turn one.",
"emit 1 coalesced agent stream updates for user message contract turn two.",
"emit 1 coalesced agent stream updates for user message contract turn three.",
];
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "User message contract e2e",
modeId: "load-test",
model: "ten-second-stream",
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
for (let index = 0; index < prompts.length; index += 1) {
const prompt = prompts[index]!;
await submitMessage(page, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
timeout: 15_000,
});
await expectUserMessageCount(page, index + 1);
await expectNoLoadingRegressionAfterIdle(page);
}
await fillComposerDraft(page, "append");
await composerLocator(page).evaluate((element) => element.blur());
await expectUserMessageCount(page, 3);
await expectIdleComposer(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,239 @@
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { captureWsSessionFrames } from "./helpers/rename";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface WorkspaceTabProbeRecord {
at: number;
tabs: Array<{
testId: string;
text: string;
ariaLabel: string;
}>;
}
interface CapturedCreateAgentFrame {
initialPrompt: string | null;
configTitle: string | null;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function installWorkspaceTabProbe(page: Page): Promise<void> {
await page.evaluate(() => {
type ProbeRecord = WorkspaceTabProbeRecord;
type ProbeWindow = Window & {
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
};
const win = window as ProbeWindow;
win.__workspaceTabTitleProbe?.stop();
const records: ProbeRecord[] = [];
const isVisible = (element: Element): element is HTMLElement => {
if (!(element instanceof HTMLElement)) {
return false;
}
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden"
);
};
const snapshot = () => {
records.push({
at: performance.now(),
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
.filter(isVisible)
.map((element) => ({
testId: element.getAttribute("data-testid") ?? "",
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
ariaLabel: element.getAttribute("aria-label") ?? "",
})),
});
};
snapshot();
const observer = new MutationObserver(snapshot);
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ["aria-label", "class", "data-testid", "style"],
});
const interval = window.setInterval(snapshot, 20);
win.__workspaceTabTitleProbe = {
records,
stop: () => {
observer.disconnect();
window.clearInterval(interval);
},
};
});
}
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
return page.evaluate(() => {
type ProbeWindow = Window & {
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
};
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
probe?.stop();
return probe?.records ?? [];
});
}
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
}
function createFrameStartsWithPrompt(
frame: CapturedCreateAgentFrame,
promptTitle: string,
): boolean {
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
}
function countCreateFramesForPrompt(
frames: CapturedCreateAgentFrame[],
promptTitle: string,
): number {
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
}
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
}
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
return record.tabs.some(tabHasLoadingTitle);
}
async function waitForCreatedAgentId(
client: TerminalPerfDaemonClient,
cwd: string,
): Promise<string> {
await expect
.poll(
async () => {
const result = await client.fetchAgents({ scope: "active" });
return result.entries
.filter((entry) => entry.agent.cwd === cwd)
.map((entry) => entry.agent.id);
},
{ timeout: 30_000 },
)
.toHaveLength(1);
const result = await client.fetchAgents({ scope: "active" });
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
if (!agent) {
throw new Error(`Expected one created agent in ${cwd}`);
}
return agent.agent.id;
}
async function fetchActiveAgentTitle(
client: TerminalPerfDaemonClient,
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
}
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
const promptTab = page.getByRole("button", { name: promptTitle }).first();
await expect(promptTab).toBeVisible({ timeout: 15_000 });
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
await promptTab.click({ button: "right" });
const renameAction = page.getByText("Rename", { exact: true }).first();
if (await renameAction.isVisible().catch(() => false)) {
await page.keyboard.press("Escape");
return;
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(100);
}
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
}
test.describe("Workspace agent title handoff", () => {
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1440, height: 900 });
const client = await connectTerminalClient();
const repo = await createTempGitRepo("workspace-title-handoff-");
try {
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? "Failed to open test workspace");
}
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
configTitle: typeof config.title === "string" ? config.title : null,
};
});
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").click();
await expectComposerVisible(page);
const promptTitle = "Investigate optimistic tab title handoff";
const generatedTitle = "Generated Handoff Title";
await installWorkspaceTabProbe(page);
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
const agentId = await waitForCreatedAgentId(client, repo.path);
await expect
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
timeout: 10_000,
})
.toBe(1);
expect(createFrames.at(-1)).toEqual({
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
configTitle: null,
});
await waitForPromptTabAgentActions(page, promptTitle);
await client.updateAgent(agentId, { name: generatedTitle });
await expect
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
.toBe(generatedTitle);
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
timeout: 15_000,
});
const records = await readWorkspaceTabProbe(page);
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
} finally {
await client.close().catch(() => undefined);
await repo.cleanup();
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.81",
"version": "0.1.83",
"private": true,
"main": "index.ts",
"scripts": {
@@ -21,7 +21,8 @@
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:real": "playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm run build:workspace-deps && expo export --platform web",

View File

@@ -27,6 +27,12 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{
name: "real-provider",
testMatch: ["**/*.real.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
],

View File

@@ -25,6 +25,8 @@
/* These styles disable body scrolling if you are using <ScrollView> */
body {
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Prevent white flash before React mounts */
@media (prefers-color-scheme: dark) {

View File

@@ -3,8 +3,8 @@ import {
__private__,
deriveBottomAnchorBlockedReason,
type BottomAnchorMode,
} from "./use-bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
} from "./bottom-anchor-controller";
import type { BottomAnchorTransportBehavior } from "./strategy";
type MeasurementState = ReturnType<typeof createMeasurementState>;

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
import type { BottomAnchorTransportBehavior } from "./strategy";
export type BottomAnchorMode = "sticky-bottom" | "detached";

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { layoutStream, type StreamLayout, type StreamLayoutItem } from "./layout";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: timestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
block?: { groupId: string; index: number },
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: timestamp(seed),
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
};
}
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
return {
kind: "tool_call",
id,
timestamp: timestamp(seed),
payload: {
source: "orchestrator",
data: {
toolCallId: id,
toolName: "Shell",
arguments: "echo hi",
result: null,
status: "completed",
},
},
};
}
function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought" }> {
return {
kind: "thought",
id,
text: id,
timestamp: timestamp(seed),
status: "ready",
};
}
function timingFor(...ids: string[]): Map<string, TurnTiming> {
const timing = {
startedAt: timestamp(1),
completedAt: timestamp(9),
durationMs: 8000,
};
return new Map(ids.map((id) => [id, timing]));
}
function strategyFor(platform: "web" | "android"): StreamStrategy {
return resolveStreamRenderStrategy({
platform,
isMobileBreakpoint: false,
});
}
function layoutFor(input: {
platform: "web" | "android";
agentStatus?: string;
tail: StreamItem[];
head?: StreamItem[];
timingIds?: string[];
}): StreamLayout {
const strategy = strategyFor(input.platform);
return layoutStream({
strategy,
agentStatus: input.agentStatus ?? "idle",
history: orderTailForStreamRenderStrategy({
strategy,
streamItems: input.tail,
}),
liveHead: orderHeadForStreamRenderStrategy({
strategy,
streamHead: input.head ?? [],
}),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
function footerOwners(layout: StreamLayout): string[] {
const owners = [
...layout.history.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
...layout.liveHead.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
return owners;
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
);
if (!item) {
throw new Error(`Missing layout item ${id}`);
}
return item;
}
describe("layoutStream", () => {
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(footerOwners(layout)).toEqual([headBlock.id]);
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
expect(findLayoutItem(layout, historyBlock.id).completedFooter).toBeNull();
});
it("does not duplicate footers when a web assistant turn spans history and live head", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(footerOwners(layout)).toEqual([headBlock.id]);
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
expect(findLayoutItem(layout, headBlock.id).aboveItem?.id).toBe(historyBlock.id);
});
it("keeps the completed footer visually after the assistant after a native user reply", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
timingIds: [assistant.id],
});
const assistantRow = findLayoutItem(layout, assistant.id);
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.belowItem?.id).toBe("u2");
expect(assistantRow.frameOrder).toBe("footer-then-content");
});
it("keeps forward stream content before its completed footer", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
timingIds: [assistant.id],
});
const assistantRow = findLayoutItem(layout, assistant.id);
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
expect(assistantRow.frameOrder).toBe("content-then-footer");
});
it("compacts assistant block spacing across the history and live-head boundary", () => {
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), historyBlock],
head: [headBlock],
timingIds: [historyBlock.id, headBlock.id],
});
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
});
it("computes tool sequence position from strategy-aware neighbors", () => {
const shell = toolCall("tool-1", 2);
const thinking = thought("thought-1", 3);
const layout = layoutFor({
platform: "android",
tail: [userMessage("u1", 1), shell, thinking, assistantMessage("a1", 4)],
});
expect(findLayoutItem(layout, shell.id).toolSequence).toBe("first");
expect(findLayoutItem(layout, thinking.id).toolSequence).toBe("last");
});
it("keeps bottom and inline footer ownership mutually exclusive", () => {
const assistant = assistantMessage("a1", 2);
const layout = layoutFor({
platform: "web",
tail: [userMessage("u1", 1), assistant],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
});
});

View File

@@ -0,0 +1,248 @@
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./spacing";
import type { StreamFrameChildOrder, StreamStrategy } from "./strategy";
export type StreamToolSequence = "single" | "first" | "middle" | "last" | "none";
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export interface StreamLayoutItem {
item: StreamItem;
index: number;
items: StreamItem[];
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
gapBelow: number;
assistantSpacing: "default" | "compactTop" | "compactBottom" | "compactBoth";
completedFooter: TurnFooterHost | null;
toolSequence: StreamToolSequence;
isFirstInUserGroup: boolean;
isLastInUserGroup: boolean;
isLastInToolSequence: boolean;
frameOrder: StreamFrameChildOrder;
}
export interface StreamLayout {
history: StreamLayoutItem[];
liveHead: StreamLayoutItem[];
auxiliaryTurnFooter: TurnFooterHost | null;
historyToHeadGap: number;
}
export interface StreamLayoutInput {
strategy: StreamStrategy;
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
frameOrder: StreamFrameChildOrder;
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
}
function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.strategy.getLatestItemIndex(footerItems);
if (startIndex === null) {
return null;
}
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: startIndex,
timingByAssistantId: input.timingByAssistantId,
});
}
function shouldRenderCompletedFooter(input: {
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
auxiliaryTurnFooter: TurnFooterHost | null;
}): boolean {
return (
input.item.kind === "assistant_message" &&
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running"))
);
}
function isToolSequenceItem(item: StreamItem | null): boolean {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
}
function getToolSequence(input: {
item: StreamItem;
aboveItem: StreamItem | null;
belowItem: StreamItem | null;
}): StreamToolSequence {
if (!isToolSequenceItem(input.item)) {
return "none";
}
const hasAbove = isToolSequenceItem(input.aboveItem);
const hasBelow = isToolSequenceItem(input.belowItem);
if (hasAbove && hasBelow) {
return "middle";
}
if (hasAbove) {
return "last";
}
if (hasBelow) {
return "first";
}
return "single";
}
function getSegmentNeighbor(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
relation: "above" | "below";
boundaryIndex: number | null;
boundaryItem: StreamItem | null;
}): StreamItem | null {
const neighbor = input.strategy.getNeighborItem(input.items, input.index, input.relation);
if (neighbor) {
return neighbor;
}
if (input.index === input.boundaryIndex) {
return input.boundaryItem;
}
return null;
}
function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
return input.items.map((item, index) => {
const aboveItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "above",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryAboveItem,
});
const belowItem = getSegmentNeighbor({
strategy: input.strategy,
items: input.items,
index,
relation: "below",
boundaryIndex: input.boundaryIndex,
boundaryItem: input.boundaryBelowItem,
});
const assistantSpacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
item,
belowItem,
agentStatus: input.agentStatus,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
return {
item,
index,
items: input.items,
aboveItem,
belowItem,
gapBelow: completedFooter ? 0 : getGapBetweenStreamItems(item, belowItem),
assistantSpacing,
completedFooter,
toolSequence: getToolSequence({ item, aboveItem, belowItem }),
isFirstInUserGroup: item.kind === "user_message" && aboveItem?.kind !== "user_message",
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
frameOrder: input.frameOrder,
};
});
}
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
const liveHeadBoundaryIndex = input.strategy.getLiveHeadHistoryBoundaryIndex(input.liveHead);
const historyBoundaryItem =
historyBoundaryIndex === null ? null : (input.history[historyBoundaryIndex] ?? null);
const liveHeadBoundaryItem =
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
const frameOrder = input.strategy.getFrameChildOrder();
const history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
});
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
});
return {
history,
liveHead,
auxiliaryTurnFooter,
historyToHeadGap: getGapBetweenStreamItems(historyBoundaryItem, liveHeadBoundaryItem),
};
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { buildAgentStreamRenderModel } from "./agent-stream-render-model";
import { buildAgentStreamRenderModel } from "./model";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);

View File

@@ -5,12 +5,9 @@ import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
export interface StreamRenderSegments {
historyVirtualized: StreamItem[];

View File

@@ -3,6 +3,9 @@ import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
getBottomOffsetForStreamRenderStrategy,
getFrameChildOrderForStreamRenderStrategy,
getHistoryLiveBoundaryIndexForStreamRenderStrategy,
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy,
getStreamEdgeSlotProps,
getStreamNeighborIndex,
getStreamNeighborItem,
@@ -10,8 +13,8 @@ import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
resolveStreamRenderStrategy,
} from "./agent-stream-render-strategy";
} from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:0${seed}.000Z`);
@@ -328,3 +331,88 @@ describe("edge slot semantics", () => {
});
});
});
describe("layout strategy edges", () => {
const streamItems: StreamItem[] = [
userMessage("u1", "user-1", 1),
assistantMessage("a1", "assistant-1", 2),
];
it("uses the newest history edge as the history/live boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
const forwardHistory = orderTailForStreamRenderStrategy({ strategy: forward, streamItems });
const invertedHistory = orderTailForStreamRenderStrategy({ strategy: inverted, streamItems });
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: forward,
history: forwardHistory,
}),
).toBe(1);
expect(
getHistoryLiveBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
history: invertedHistory,
}),
).toBe(0);
});
it("uses the oldest live-head edge as the live-head/history boundary", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "ios",
isMobileBreakpoint: false,
});
const forwardHead = orderHeadForStreamRenderStrategy({
strategy: forward,
streamHead: streamItems,
});
const invertedHead = orderHeadForStreamRenderStrategy({
strategy: inverted,
streamHead: streamItems,
});
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: forward,
liveHead: forwardHead,
}),
).toBe(0);
expect(
getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy({
strategy: inverted,
liveHead: invertedHead,
}),
).toBe(1);
});
it("names the frame child order needed by native inverted cells", () => {
const forward = resolveStreamRenderStrategy({
platform: "web",
isMobileBreakpoint: false,
});
const inverted = resolveStreamRenderStrategy({
platform: "android",
isMobileBreakpoint: false,
});
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: forward })).toBe(
"content-then-footer",
);
expect(getFrameChildOrderForStreamRenderStrategy({ strategy: inverted })).toBe(
"footer-then-content",
);
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./spacing";
function assistantBlock(params: {
id: string;

View File

@@ -19,13 +19,13 @@ import {
} from "react-native";
import type { StreamItem } from "@/types/stream";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./use-bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./stream-strategy";
} from "./strategy";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
@@ -380,6 +380,9 @@ export function createNativeStreamStrategy(): StreamStrategy {
orderHeadReverse: true,
assistantTurnTraversalStep: 1,
edgeSlot: "header",
historyLiveBoundaryEdge: "first",
liveHeadHistoryBoundaryEdge: "last",
frameChildOrder: "footer-then-content",
flatListInverted: true,
overlayScrollbarInverted: true,
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,

View File

@@ -1,6 +1,6 @@
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./stream-strategy";
import { createNativeStreamStrategy } from "./stream-strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web";
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./strategy";
import { createNativeStreamStrategy } from "./strategy-native";
import { createWebStreamStrategy } from "./strategy-web";
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput,

View File

@@ -6,8 +6,8 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
import { createWebStreamStrategy } from "./stream-strategy-web";
import type { StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
@@ -25,7 +25,7 @@ vi.hoisted(() => {
});
});
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
vi.mock("@/components/use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
function userMessage(index: number): StreamItem {
return {

View File

@@ -10,9 +10,9 @@ import React, {
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { createStreamStrategy } from "./stream-strategy";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
@@ -25,7 +25,7 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "./use-web-scrollbar";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = {
display: "flex",
@@ -610,6 +610,9 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
orderHeadReverse: false,
assistantTurnTraversalStep: -1,
edgeSlot: "footer",
historyLiveBoundaryEdge: "last",
liveHeadHistoryBoundaryEdge: "first",
frameChildOrder: "content-then-footer",
flatListInverted: false,
overlayScrollbarInverted: false,
maintainVisibleContentPosition: undefined,

View File

@@ -1,15 +1,16 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
} from "./bottom-anchor-controller";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
type AssistantTurnTraversalStep = -1 | 1;
export type StreamFrameChildOrder = "content-then-footer" | "footer-then-content";
export type MaintainVisibleContentPositionConfig = Readonly<{
minIndexForVisible: number;
@@ -93,6 +94,10 @@ export interface StreamStrategy {
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
getHistoryLiveBoundaryIndex: (history: StreamItem[]) => number | null;
getLiveHeadHistoryBoundaryIndex: (liveHead: StreamItem[]) => number | null;
getLatestItemIndex: (items: StreamItem[]) => number | null;
getFrameChildOrder: () => StreamFrameChildOrder;
getFlatListInverted: () => boolean;
getOverlayScrollbarInverted: () => boolean;
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
@@ -107,6 +112,9 @@ interface StreamStrategyConfig {
orderHeadReverse: boolean;
assistantTurnTraversalStep: AssistantTurnTraversalStep;
edgeSlot: EdgeSlot;
historyLiveBoundaryEdge: "first" | "last";
liveHeadHistoryBoundaryEdge: "first" | "last";
frameChildOrder: StreamFrameChildOrder;
flatListInverted: boolean;
overlayScrollbarInverted: boolean;
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
@@ -175,6 +183,25 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
},
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
getHistoryLiveBoundaryIndex: (history) => {
if (history.length === 0) {
return null;
}
return config.historyLiveBoundaryEdge === "first" ? 0 : history.length - 1;
},
getLiveHeadHistoryBoundaryIndex: (liveHead) => {
if (liveHead.length === 0) {
return null;
}
return config.liveHeadHistoryBoundaryEdge === "first" ? 0 : liveHead.length - 1;
},
getLatestItemIndex: (items) => {
if (items.length === 0) {
return null;
}
return config.historyLiveBoundaryEdge === "first" ? 0 : items.length - 1;
},
getFrameChildOrder: () => config.frameChildOrder,
getFlatListInverted: () => config.flatListInverted,
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
@@ -270,3 +297,23 @@ export function getStreamEdgeSlotProps(params: {
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
}
export function getHistoryLiveBoundaryIndexForStreamRenderStrategy(params: {
strategy: StreamStrategy;
history: StreamItem[];
}): number | null {
return params.strategy.getHistoryLiveBoundaryIndex(params.history);
}
export function getLiveHeadHistoryBoundaryIndexForStreamRenderStrategy(params: {
strategy: StreamStrategy;
liveHead: StreamItem[];
}): number | null {
return params.strategy.getLiveHeadHistoryBoundaryIndex(params.liveHead);
}
export function getFrameChildOrderForStreamRenderStrategy(params: {
strategy: StreamStrategy;
}): StreamFrameChildOrder {
return params.strategy.getFrameChildOrder();
}

View File

@@ -8,9 +8,10 @@ import type { StreamItem } from "@/types/stream";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
import { SyncedLoader } from "./synced-loader";
} from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
@@ -22,52 +23,6 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const usesLiveHead = input.liveHead.length > 0;
const footerItems = usesLiveHead ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
timing: input.timingByAssistantId.get(item.id),
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,

View File

@@ -36,8 +36,8 @@ import {
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
@@ -50,30 +50,18 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
getStreamNeighborItem,
resolveStreamRenderStrategy,
type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
import {
CompletedTurnFooterRow,
resolveBottomTurnFooterHost,
shouldRenderCompletedTurnFooter,
TurnFooter,
type TurnContentStrategy,
type TurnFooterHost,
} from "./agent-stream-turn-footer";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
} from "./bottom-anchor-controller";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -90,12 +78,6 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
@@ -133,40 +115,39 @@ function renderPendingPermissionsNode(input: {
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
item: StreamItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
layoutItem: StreamLayoutItem;
strategy: TurnContentStrategy;
}): ReactNode {
if (!input.content) {
return null;
}
const showCompletedFooter = shouldRenderCompletedTurnFooter({
item: input.item,
belowItem: input.nextItem,
agentStatus: input.agentStatus,
suppressTurnFooter: input.suppressTurnFooter,
});
const gapBelow = showCompletedFooter
? 0
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
const footerHost = input.layoutItem.completedFooter;
const footer = footerHost ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
/>
) : null;
const content = (
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow}>{input.content}</StreamItemWrapper>
);
if (input.layoutItem.frameOrder === "footer-then-content") {
return (
<>
{footer}
{content}
</>
);
}
return (
<>
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
{showCompletedFooter ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
timing={input.timing.get(input.item.id)}
startIndex={input.index}
/>
) : null}
{content}
{footer}
</>
);
}
@@ -194,47 +175,26 @@ function renderListEmptyComponent(input: {
function renderHistoryStreamItem(input: {
item: StreamItem;
historyIndexById: Map<string, number>;
historyItems: StreamItem[];
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
const historyIndex = input.historyIndexById.get(input.item.id);
if (historyIndex === undefined) {
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
const seamBelowItem =
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
belowItem: seamBelowItem,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
return input.renderStreamItem(layoutItem);
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
index: number;
items: StreamItem[];
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
layoutItemById: Map<string, StreamLayoutItem>;
renderStreamItem: (layoutItem: StreamLayoutItem) => ReactNode;
}): ReactNode {
return input.renderStreamItem(input.item, input.index, input.items, {
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
const layoutItem = input.layoutItemById.get(input.item.id);
if (!layoutItem) {
return null;
}
return input.renderStreamItem(layoutItem);
}
export interface AgentStreamViewHandle {
@@ -396,6 +356,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [agent.status, isMobile, streamHead, streamItems]);
const streamLayout = useMemo(
() =>
layoutStream({
strategy: streamRenderStrategy,
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
],
);
useImperativeHandle(
ref,
() => ({
@@ -432,73 +409,28 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderUserMessageItem = useCallback(
(
item: Extract<StreamItem, { kind: "user_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.id}
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
capabilities={agent.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[streamRenderStrategy],
[agent.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
(
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seams.aboveItem ??
undefined;
const belowItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) ??
seams.belowItem ??
undefined;
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "assistant_message" }>) => {
return (
<AssistantFileLinkResolverProvider
client={client}
@@ -513,23 +445,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={spacing}
spacing={layoutItem.assistantSpacing}
/>
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
return (
<ToolCallSlot
itemId={item.id}
@@ -537,23 +462,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
isLastInSequence={layoutItem.isLastInToolSequence}
/>
);
},
[streamRenderStrategy, setInlineDetailsExpanded],
[setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(
(item: Extract<StreamItem, { kind: "tool_call" }>, index: number, items: StreamItem[]) => {
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") {
const data = payload.data;
@@ -579,7 +497,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
@@ -594,33 +512,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded, handleToolCallOpenFile],
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
(
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
(layoutItem: StreamLayoutItem) => {
const item = layoutItem.item;
switch (item.kind) {
case "user_message":
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
return renderUserMessageItem(layoutItem, item);
case "assistant_message":
return renderAssistantMessageItem(item, index, items, seams);
return renderAssistantMessageItem(layoutItem, item);
case "thought":
return renderThoughtItem(item, index, items);
return renderThoughtItem(layoutItem, item);
case "tool_call":
return renderToolCallItem(item, index, items);
return renderToolCallItem(layoutItem, item);
case "activity_log":
return (
@@ -651,54 +565,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const bottomTurnFooterHost = useMemo(() => {
return resolveBottomTurnFooterHost({
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
streamRenderStrategy,
]);
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
const renderStreamItem = useCallback(
(
item: StreamItem,
index: number,
items: StreamItem[],
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seams);
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
(layoutItem: StreamLayoutItem) => {
const content = renderStreamItemContent(layoutItem);
return renderStreamItemWithTurnFooter({
content,
item,
nextItem,
items,
timing: baseRenderModel.turnTiming.byAssistantId,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
layoutItem,
strategy: streamRenderStrategy,
});
},
[
agent.status,
baseRenderModel.turnTiming.byAssistantId,
renderStreamItemContent,
streamRenderStrategy,
],
[renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -737,17 +615,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetweenStreamItems(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
historyToHeadGap: streamLayout.historyToHeadGap,
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
turnFooter: turnFooterNode,
},
};
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
}, [baseRenderModel, pendingPermissionsNode, streamLayout.historyToHeadGap, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(
@@ -755,38 +630,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderModel, emptyStateStyle],
);
const historyItems = renderModel.history;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
const historyIndexById = useMemo(() => {
const indexById = new Map<string, number>();
historyItems.forEach((item, index) => {
indexById.set(item.id, index);
});
return indexById;
}, [historyItems]);
const layoutHistoryItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.history) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.history]);
const layoutLiveHeadItemById = useMemo(() => {
const itemById = new Map<string, StreamLayoutItem>();
for (const item of streamLayout.liveHead) {
itemById.set(item.item.id, item);
}
return itemById;
}, [streamLayout.liveHead]);
const renderHistoryRow = useCallback(
(item: StreamItem) =>
renderHistoryStreamItem({
item,
historyIndexById,
historyItems,
lastHistoryItem,
firstLiveHeadItem,
bottomTurnFooterHost,
layoutItemById: layoutHistoryItemById,
renderStreamItem,
}),
[
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
[layoutHistoryItemById, renderStreamItem],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -797,16 +666,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderHistoryRow],
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
(item) =>
renderLiveHeadStreamItem({
item,
index,
items,
lastHistoryItem,
bottomTurnFooterHost,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
}),
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
[layoutLiveHeadItemById, renderStreamItem],
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
return renderLiveAuxiliaryNode({

View File

@@ -13,7 +13,7 @@ import {
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
type IndexedStreamItem,
} from "./agent-stream-web-virtualization";
} from "./web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);

View File

@@ -30,6 +30,7 @@ import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
@@ -457,6 +458,7 @@ function AppContainer({
)}
<View style={flexStyle}>{children}</View>
</View>
<FloatingPanelPortalHost />
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<RosettaCalloutSource />

View File

@@ -360,6 +360,25 @@ describe("parseAssistantFileLink", () => {
}),
).toBeNull();
});
it("does not throw when the input contains a literal '%' that is not a valid percent-escape", () => {
// Regressions for tool output strings like ping's "100% packet loss",
// Windows "%PATH%" references, and percentages such as "0% off".
// decodeURIComponent throws URIError on these; the parser must swallow
// it and return null rather than crash the renderer.
const cases = [
"/tmp/100% packet loss",
"/Users/test/project/0% off",
"/var/log/%PATH%/x.log",
"file:///tmp/100% packet loss",
];
for (const value of cases) {
expect(() =>
parseAssistantFileLink(value, { workspaceRoot: "/Users/test/project" }),
).not.toThrow();
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
});
describe("normalizeInlinePathTarget", () => {

View File

@@ -1,5 +1,13 @@
import { isAbsolutePath } from "@/utils/path";
function safeDecodeURIComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export interface InlinePathTarget {
raw: string;
path: string;
@@ -350,7 +358,7 @@ export function parseAssistantFileLink(
return null;
}
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -659,7 +667,7 @@ function normalizeFileUrlPath(pathname: string): string | null {
return null;
}
const decoded = decodeURIComponent(pathname).replace(/\\/g, "/");
const decoded = safeDecodeURIComponent(pathname).replace(/\\/g, "/");
if (!decoded) {
return null;
}

View File

@@ -14,7 +14,7 @@ import {
} from "@gorhom/bottom-sheet";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import type { ImageAttachment } from "@/composer/types";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,

View File

@@ -1,3 +0,0 @@
export * from "./stream-strategy";
export * from "./stream-strategy-resolver";
export * from "./agent-stream-render-model";

View File

@@ -1 +0,0 @@
export * from "./composer-height-mirror.native";

View File

@@ -1,5 +0,0 @@
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? "draft" : "ready";
}

View File

@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
interface ContextWindowMeterProps {
maxTokens: number;
usedTokens: number;
totalCostUsd?: number | null;
}
const SVG_SIZE = 16;
@@ -43,6 +44,16 @@ function formatTokenCount(value: number): string {
return Math.round(value).toString();
}
function formatSessionCost(value: number): string | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (value < 0.01) {
return `$${value.toFixed(4)}`;
}
return `$${value.toFixed(2)}`;
}
function getMeterColors(
percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"],
@@ -57,7 +68,11 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
@@ -69,6 +84,8 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
@@ -115,6 +132,9 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
{formattedSessionCost ? (
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
) : null}
</View>
</TooltipContent>
</Tooltip>

View File

@@ -4,7 +4,7 @@ import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-na
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {

View File

@@ -393,6 +393,7 @@ export function ImportSessionSheet({
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd,
enabled: visible,
});

View File

@@ -102,15 +102,23 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
export type { InlinePathTarget } from "@/assistant-file-links";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
interface UserMessageProps {
serverId?: string;
agentId?: string;
messageId?: string;
message: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
timestamp: number;
capabilities?: AgentCapabilityFlags;
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
@@ -401,8 +409,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
copyButton: {
padding: theme.spacing[1],
alignSelf: "center",
width: 24,
height: 24,
padding: 0,
margin: 0,
marginRight: -theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
trailingRow: {
alignSelf: "flex-end",
@@ -450,10 +464,15 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
}
export const UserMessage = memo(function UserMessage({
serverId,
agentId,
messageId,
message,
images = [],
attachments = [],
timestamp,
capabilities,
client,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
@@ -469,10 +488,17 @@ export const UserMessage = memo(function UserMessage({
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
);
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
);
const containerStyle = useMemo(
() => [
@@ -510,7 +536,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle}>
<View style={containerStyle} testID="user-message">
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -549,6 +575,14 @@ export const UserMessage = memo(function UserMessage({
{hasText ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
onRewind={handleRewind}
/>
) : null}
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerTextIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerTextIfEmpty({
currentText: "",
rewoundText: "message before rewind",
}),
).toBe("message before rewind");
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerTextIfEmpty({
currentText: "keep this draft",
rewoundText: "message before rewind",
}),
).toBe("keep this draft");
});
});
describe("shouldRestoreComposerForRewindMode", () => {
test("restores only conversation-mutating rewind modes", () => {
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
expect(shouldRestoreComposerForRewindMode("files")).toBe(false);
expect(shouldRestoreComposerForRewindMode("both")).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
interface RewindComposerRestoreContextValue {
restoreTextIfComposerEmpty: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
text: string;
setText: (text: string) => void;
children: ReactNode;
}
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerTextIfEmpty(input: {
currentText: string;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
}
export function RewindComposerRestoreProvider({
text,
setText,
children,
}: RewindComposerRestoreProviderProps) {
const textRef = useRef(text);
useEffect(() => {
textRef.current = text;
}, [text]);
const restoreTextIfComposerEmpty = useCallback(
(rewoundText: string) => {
const nextText = restoreComposerTextIfEmpty({
currentText: textRef.current,
rewoundText,
});
if (nextText !== textRef.current) {
setText(nextText);
}
},
[setText],
);
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
return (
<RewindComposerRestoreContext.Provider value={value}>
{children}
</RewindComposerRestoreContext.Provider>
);
}
export function useRewindComposerRestore(): RewindComposerRestoreContextValue | null {
return useContext(RewindComposerRestoreContext);
}

View File

@@ -0,0 +1,165 @@
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { type RewindMode, useRewindCapabilities } from "./use-rewind-capabilities";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
function getIcon(mode: RewindMode, color: string): ReactElement {
switch (mode) {
case "conversation":
return <MessageSquare size={16} color={color} />;
case "files":
return <FileText size={16} color={color} />;
case "both":
return <Layers size={16} color={color} />;
}
}
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
onRewind,
isPending: isPendingProp = false,
testID = "rewind-menu",
}: RewindMenuProps) {
const { theme } = useUnistyles();
const items = useRewindCapabilities(capabilities);
const [isOpen, setIsOpen] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const isLocked = isPendingProp || pendingMode !== null;
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next && pendingMode !== null) return;
setIsOpen(next);
},
[pendingMode],
);
const handleSelect = useCallback(
(mode: RewindMode) => async () => {
if (isLocked) return;
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText });
} catch {
// useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally {
setPendingMode(null);
setIsOpen(false);
}
},
[isLocked, onRewind, rewoundText],
);
const triggerStyle = useCallback(
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
[isLocked],
);
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Rewind to this message</Text>
</TooltipContent>
),
[],
);
if (items.length === 0) {
return null;
}
return (
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger
accessibilityLabel="Rewind to this message"
accessibilityRole="button"
disabled={isLocked}
style={triggerStyle}
testID={`${testID}-trigger`}
>
{({ hovered, open }) => (
<Undo2
size={16}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
{tooltipContent}
</Tooltip>
<DropdownMenuContent align="end" minWidth={220} side="bottom" testID={`${testID}-content`}>
<View style={styles.warningHeader}>
<Text style={styles.warningText}>This action cannot be undone</Text>
</View>
<DropdownMenuSeparator />
{items.map((item) => (
<DropdownMenuItem
key={item.mode}
closeOnSelect={false}
disabled={isLocked && pendingMode !== item.mode}
leading={getIcon(item.mode, theme.colors.foreground)}
onSelect={handleSelect(item.mode)}
status={pendingMode === item.mode ? "pending" : undefined}
testID={item.testID}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
});
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
},
triggerDisabled: {
opacity: theme.opacity[50],
},
triggerSlot: {
alignSelf: "flex-start",
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
warningHeader: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
warningText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));

View File

@@ -0,0 +1,5 @@
import type { RewindMode } from "./use-rewind-capabilities";
export function shouldRestoreComposerForRewindMode(mode: RewindMode): boolean {
return mode === "conversation" || mode === "both";
}

View File

@@ -0,0 +1,80 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@server/client/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
interface UseRewindAgentMutationInput {
serverId?: string;
agentId?: string;
messageId?: string;
client?: DaemonClient | null;
}
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
rewindAgent: (input: RewindAgentInput) => Promise<void>;
isPending: boolean;
} {
const toast = useToast();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
mutationFn: async ({ mode }: RewindAgentInput) => {
if (!input.client || !input.agentId || !input.messageId) {
throw new Error("Daemon client not available");
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
if (input.serverId) {
const session = useSessionStore.getState().sessions[input.serverId];
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
});
}
const cursor = input.serverId
? useSessionStore
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "canonical",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
},
onSuccess: (_data, variables) => {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
},
});
const rewindAgent = useCallback(
async (rewindInput: RewindAgentInput) => {
if (isPending) {
return;
}
await mutateAsync(rewindInput);
},
[isPending, mutateAsync],
);
return {
rewindAgent,
isPending,
};
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { resolveRewindMenuItems } from "./use-rewind-capabilities";
describe("resolveRewindMenuItems", () => {
test("returns no items when the provider declares no rewind capability", () => {
expect(
resolveRewindMenuItems({
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
}),
).toEqual([]);
});
test("returns only the capabilities declared by the provider", () => {
expect(
resolveRewindMenuItems({
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: true,
}),
).toEqual([
{
mode: "conversation",
label: "Rewind conversation",
testID: "rewind-menu-conversation",
},
{
mode: "both",
label: "Rewind conversation and files",
testID: "rewind-menu-both",
},
]);
});
});

View File

@@ -0,0 +1,53 @@
import { useMemo } from "react";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type RewindMode = "conversation" | "files" | "both";
export interface RewindMenuItem {
mode: RewindMode;
label: string;
testID: string;
}
export function resolveRewindMenuItems(
capabilities:
| Pick<
AgentCapabilityFlags,
"supportsRewindConversation" | "supportsRewindFiles" | "supportsRewindBoth"
>
| null
| undefined,
): RewindMenuItem[] {
if (!capabilities) {
return [];
}
const items: RewindMenuItem[] = [];
if (capabilities.supportsRewindConversation) {
items.push({
mode: "conversation",
label: "Rewind conversation",
testID: "rewind-menu-conversation",
});
}
if (capabilities.supportsRewindFiles) {
items.push({
mode: "files",
label: "Rewind files",
testID: "rewind-menu-files",
});
}
if (capabilities.supportsRewindBoth) {
items.push({
mode: "both",
label: "Rewind conversation and files",
testID: "rewind-menu-both",
});
}
return items;
}
export function useRewindCapabilities(
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
): RewindMenuItem[] {
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
}

View File

@@ -8,12 +8,23 @@ import {
type ComponentProps,
type Ref,
} from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import {
Keyboard,
StyleSheet,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -24,6 +35,7 @@ export interface TerminalEmulatorHandle {
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
blur: () => void;
}
interface TerminalEmulatorProps {
@@ -48,6 +60,13 @@ interface TerminalEmulatorProps {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
@@ -69,12 +88,18 @@ type BridgeInboundMessage =
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type BridgeOutboundMessage =
| { type: "bridgeReady" }
@@ -93,6 +118,18 @@ type BridgeOutboundMessage =
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
@@ -101,7 +138,13 @@ const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
type WebViewProps = ComponentProps<typeof WebView>;
interface PendingTerminalTap {
startX: number;
startY: number;
moved: boolean;
}
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
@@ -149,6 +192,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
@@ -165,6 +210,7 @@ export default function TerminalEmulator({
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
@@ -188,6 +234,8 @@ export default function TerminalEmulator({
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
});
@@ -198,6 +246,8 @@ export default function TerminalEmulator({
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
};
@@ -299,6 +349,12 @@ export default function TerminalEmulator({
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
blur: () => {
webViewRef.current?.injectJavaScript(
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
);
Keyboard.dismiss();
},
}),
[sendToWebView, streamKey],
);
@@ -399,10 +455,41 @@ export default function TerminalEmulator({
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const resolveLocalFileLink = useCallback(
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
try {
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
null);
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target,
});
} catch {
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target: null,
});
}
},
[sendToWebView],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
if (message.type === "resolveLocalFileLink") {
void resolveLocalFileLink(message);
return;
}
if (message.type === "openLocalFileLink") {
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
return;
}
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
@@ -438,7 +525,7 @@ export default function TerminalEmulator({
break;
}
},
[],
[resolveLocalFileLink],
);
const handleMessage = useCallback(
@@ -477,19 +564,63 @@ export default function TerminalEmulator({
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
pendingTapRef.current = {
startX: event.nativeEvent.pageX,
startY: event.nativeEvent.pageY,
moved: false,
};
}, []);
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
const pendingTap = pendingTapRef.current;
if (!pendingTap) return;
const dx = event.nativeEvent.pageX - pendingTap.startX;
const dy = event.nativeEvent.pageY - pendingTap.startY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
pendingTap.moved = true;
}
}, []);
const handleWebViewTouchEnd = useCallback(() => {
const pendingTap = pendingTapRef.current;
pendingTapRef.current = null;
if (!pendingTap || pendingTap.moved) {
return;
}
webViewRef.current?.requestFocus();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
const handleWebViewTouchCancel = useCallback(() => {
pendingTapRef.current = null;
}, []);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
const rootStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor }],
[backgroundColor],
);
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }],
[xtermTheme.background],
() => [styles.webView, { backgroundColor }],
[backgroundColor],
);
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webViewContainer, { backgroundColor }],
[backgroundColor],
);
return (
<View style={styles.root} testID={testId}>
<View style={rootStyle} testID={testId}>
<WebView
key={webViewEpoch}
ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle}
containerStyle={styles.webViewContainer}
containerStyle={webViewContainerStyle}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled
nestedScrollEnabled
@@ -498,12 +629,16 @@ export default function TerminalEmulator({
keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
textInteractionEnabled={false}
textInteractionEnabled
allowsLinkPreview={false}
setSupportMultipleWindows={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
onTouchStart={handleWebViewTouchStart}
onTouchMove={handleWebViewTouchMove}
onTouchEnd={handleWebViewTouchEnd}
onTouchCancel={handleWebViewTouchCancel}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
@@ -519,14 +654,11 @@ const styles = StyleSheet.create({
minHeight: 0,
minWidth: 0,
overflow: "hidden",
backgroundColor: "#0b0b0b",
},
webView: {
flex: 1,
backgroundColor: "#0b0b0b",
},
webViewContainer: {
flex: 1,
backgroundColor: "#0b0b0b",
},
});

View File

@@ -8,6 +8,7 @@ import {
useRef,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
@@ -23,6 +24,10 @@ import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -30,12 +35,19 @@ import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import {
extractTerminalDropPaths,
isTerminalDragLeaveOutside,
isTerminalFileDrag,
prepareDroppedPathsForTerminal,
} from "../terminal/drop/terminal-file-drop";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
blur: () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
@@ -136,6 +148,13 @@ interface TerminalEmulatorProps {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
@@ -203,6 +222,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
@@ -231,6 +252,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
});
mountCallbacksRef.current = {
onInput,
@@ -238,6 +261,8 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
@@ -252,6 +277,8 @@ export default function TerminalEmulator({
const [isDraggingScrollbar, setIsDraggingScrollbar] = useState(false);
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
const [isDropActive, setIsDropActive] = useState(false);
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
useDOMImperativeHandle(
@@ -276,6 +303,9 @@ export default function TerminalEmulator({
clear: () => {
runtimeRef.current?.clear();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
);
@@ -294,6 +324,9 @@ export default function TerminalEmulator({
clear: () => {
runtimeRef.current?.clear();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
);
@@ -470,10 +503,20 @@ export default function TerminalEmulator({
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onOpenExternalUrl: openExternalUrl,
},
});
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
}, [
onInput,
onInputModeChange,
onOpenLocalFileLink,
onPendingModifiersConsumed,
onResolveLocalFileLink,
onResize,
onTerminalKey,
]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
@@ -732,6 +775,110 @@ export default function TerminalEmulator({
setIsHandleHovered(false);
}, []);
const clearDropActiveTimeout = useCallback(() => {
if (dropActiveTimeoutRef.current === null) {
return;
}
clearTimeout(dropActiveTimeoutRef.current);
dropActiveTimeoutRef.current = null;
}, []);
const clearTerminalDropActive = useCallback(() => {
clearDropActiveTimeout();
setIsDropActive(false);
}, [clearDropActiveTimeout]);
const keepTerminalDropActive = useCallback(() => {
clearDropActiveTimeout();
setIsDropActive(true);
dropActiveTimeoutRef.current = setTimeout(() => {
dropActiveTimeoutRef.current = null;
setIsDropActive(false);
}, 180);
}, [clearDropActiveTimeout]);
useEffect(() => {
const root = rootRef.current;
if (!root) {
return () => {};
}
const handleDragEnter = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
keepTerminalDropActive();
};
const handleDragOver = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
keepTerminalDropActive();
};
const handleDrop = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
clearTerminalDropActive();
const paths = extractTerminalDropPaths(event.dataTransfer);
if (paths.length === 0) {
return;
}
runtimeRef.current?.focus();
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths));
};
root.addEventListener("dragenter", handleDragEnter, { capture: true });
root.addEventListener("dragover", handleDragOver, { capture: true });
root.addEventListener("drop", handleDrop, { capture: true });
window.addEventListener("dragend", clearTerminalDropActive);
window.addEventListener("drop", clearTerminalDropActive);
return () => {
root.removeEventListener("dragenter", handleDragEnter, { capture: true });
root.removeEventListener("dragover", handleDragOver, { capture: true });
root.removeEventListener("drop", handleDrop, { capture: true });
window.removeEventListener("dragend", clearTerminalDropActive);
window.removeEventListener("drop", clearTerminalDropActive);
clearDropActiveTimeout();
};
}, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]);
const handleRootDragLeave = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (
!isTerminalDragLeaveOutside({
currentTarget: event.currentTarget,
relatedTarget: event.relatedTarget,
})
) {
return;
}
clearTerminalDropActive();
},
[clearTerminalDropActive],
);
const rootDivStyle = useMemo<CSSProperties>(
() => ({
position: "relative",
@@ -747,6 +894,19 @@ export default function TerminalEmulator({
}),
[xtermTheme.background],
);
const dropOverlayStyle = useMemo<CSSProperties>(
() => ({
position: "absolute",
inset: 0,
zIndex: 9,
border: "1px solid rgba(78, 161, 255, 0.72)",
backgroundColor: "rgba(78, 161, 255, 0.16)",
opacity: isDropActive ? 1 : 0,
pointerEvents: "none",
transition: "opacity 120ms ease-out",
}),
[isDropActive],
);
const handleContainerStyle = useMemo<CSSProperties>(
() => ({
position: "absolute",
@@ -795,8 +955,10 @@ export default function TerminalEmulator({
style={rootDivStyle}
onPointerDown={handleRootPointerDown}
onContextMenu={handleRootContextMenu}
onDragLeave={handleRootDragLeave}
>
<div ref={hostRef} style={HOST_DIV_STYLE} />
<div style={dropOverlayStyle} />
{scrollbarGeometry.isVisible ? (
<div style={SCROLLBAR_CONTAINER_STYLE}>
<div

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
@@ -38,6 +37,16 @@ import {
type TerminalRendererReadyChange,
} from "@/utils/terminal-renderer-readiness";
import { useAppSettings } from "@/hooks/use-settings";
import { classifyForResolution, fetchDaemonResolution } from "@/assistant-file-links/resolver";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "@/terminal/local-links/terminal-local-link-provider";
import {
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
interface TerminalPaneProps {
serverId: string;
@@ -46,6 +55,7 @@ interface TerminalPaneProps {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
onOpenFileExplorer: () => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
}
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
@@ -56,17 +66,17 @@ const MODIFIER_LABELS = {
alt: "Alt",
} as const;
const KEY_BUTTONS: Array<{ id: string; label: string; key: string }> = [
{ id: "esc", label: "Esc", key: "Escape" },
{ id: "tab", label: "Tab", key: "Tab" },
{ id: "up", label: "↑", key: "ArrowUp" },
{ id: "down", label: "↓", key: "ArrowDown" },
{ id: "left", label: "←", key: "ArrowLeft" },
{ id: "right", label: "→", key: "ArrowRight" },
{ id: "enter", label: "Enter", key: "Enter" },
{ id: "backspace", label: "⌫", key: "Backspace" },
{ id: "c", label: "C", key: "c" },
];
const KEY_BUTTONS = {
esc: { id: "esc", label: "Esc", key: "Escape" },
tab: { id: "tab", label: "Tab", key: "Tab" },
up: { id: "up", label: "↑", key: "ArrowUp" },
down: { id: "down", label: "↓", key: "ArrowDown" },
left: { id: "left", label: "←", key: "ArrowLeft" },
right: { id: "right", label: "→", key: "ArrowRight" },
enter: { id: "enter", label: "Enter", key: "Enter" },
backspace: { id: "backspace", label: "⌫", key: "Backspace" },
space: { id: "space", label: "Space", key: " " },
} as const;
interface ModifierState {
ctrl: boolean;
@@ -157,6 +167,7 @@ export function TerminalPane({
isWorkspaceFocused,
isPaneFocused,
onOpenFileExplorer,
onOpenWorkspaceFile,
}: TerminalPaneProps) {
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
@@ -219,6 +230,12 @@ export function TerminalPane({
const requestTerminalReflow = useCallback(() => {
setResizeRequestToken((current) => current + 1);
}, []);
useEffect(() => {
if (!isMobile || !isWorkspaceFocused || mobileView === "agent") {
return;
}
emulatorRef.current?.blur();
}, [isMobile, isWorkspaceFocused, mobileView]);
const handleRendererReadyChange = useCallback(
(change: TerminalRendererReadyChange) => {
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
@@ -630,6 +647,42 @@ export function TerminalPane({
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
const handleResolveLocalFileLink = useCallback(
async (source: TerminalLocalFileLinkSource): Promise<TerminalLocalFileLinkTarget | null> => {
const resolution = classifyForResolution(
{ href: source.text, text: source.text, sourceType: "inline-code" },
{ workspaceRoot: cwd },
);
if (resolution.kind === "resolved") {
return resolution.value.kind === "file" ? resolution.value.target : null;
}
if (!client) {
return null;
}
try {
return await fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot: cwd,
getDirectorySuggestions: (input) => client.getDirectorySuggestions(input),
});
} catch {
return null;
}
},
[client, cwd],
);
const handleOpenLocalFileLink = useCallback(
(target: TerminalLocalFileLinkTarget, disposition: OpenFileDisposition) => {
const location = normalizeWorkspaceFileLocation(target);
if (!location) {
return;
}
onOpenWorkspaceFile({ location, disposition });
},
[onOpenWorkspaceFile],
);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
@@ -668,11 +721,13 @@ export function TerminalPane({
const handleSwipeRight = useCallback(() => {
if (!swipeGesturesEnabled) return;
emulatorRef.current?.blur();
showMobileAgentList();
}, [swipeGesturesEnabled, showMobileAgentList]);
const handleSwipeLeft = useCallback(() => {
if (!swipeGesturesEnabled) return;
emulatorRef.current?.blur();
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
@@ -712,6 +767,8 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
onOpenLocalFileLink={handleOpenLocalFileLink}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
@@ -739,18 +796,47 @@ export function TerminalPane({
{isMobile ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.keyboardRows}>
<View style={styles.keyboardRow}>
{(Object.keys(MODIFIER_LABELS) as Array<keyof ModifierState>).map((modifier) => (
<ModifierButton
key={modifier}
modifier={modifier}
active={modifiers[modifier]}
onToggle={toggleModifier}
{[KEY_BUTTONS.esc, KEY_BUTTONS.tab].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
{KEY_BUTTONS.map((button) => (
<ModifierButton modifier="ctrl" active={modifiers.ctrl} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.up.id}
label={KEY_BUTTONS.up.label}
keyValue={KEY_BUTTONS.up.key}
onSend={sendVirtualKey}
/>
<ModifierButton modifier="shift" active={modifiers.shift} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.backspace.id}
label={KEY_BUTTONS.backspace.label}
keyValue={KEY_BUTTONS.backspace.key}
onSend={sendVirtualKey}
/>
</View>
<View style={styles.keyboardRow}>
<ModifierButton modifier="alt" active={modifiers.alt} onToggle={toggleModifier} />
{[
KEY_BUTTONS.space,
KEY_BUTTONS.left,
KEY_BUTTONS.down,
KEY_BUTTONS.right,
KEY_BUTTONS.enter,
].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
@@ -760,7 +846,7 @@ export function TerminalPane({
/>
))}
</View>
</ScrollView>
</View>
</View>
) : null}
</Animated.View>
@@ -807,21 +893,24 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
keyboardRows: {
gap: theme.spacing[1],
},
keyboardRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[3],
},
keyButton: {
minWidth: 44,
flex: 1,
minWidth: 0,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
backgroundColor: theme.colors.surface1,
},
keyButtonHovered: {
@@ -835,6 +924,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
keyButtonTextActive: {
color: theme.colors.foreground,

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Keyboard, Platform, StatusBar, View, type LayoutChangeEvent } from "react-native";
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
import { Keyboard, View, useWindowDimensions } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, {
useAnimatedStyle,
@@ -10,6 +10,10 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -22,6 +26,13 @@ interface Rect {
height: number;
}
interface RelativeAnchorRect {
x: number;
y: number;
width: number;
hostHeight: number;
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
@@ -32,7 +43,7 @@ function measureElement(element: View): Promise<Rect> {
interface AutocompletePopoverProps {
visible: boolean;
anchorRef: React.RefObject<View | null>;
anchorRef: RefObject<View | null>;
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
@@ -53,9 +64,12 @@ export function AutocompletePopover({
loadingText,
emptyText,
}: AutocompletePopoverProps): ReactElement | null {
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const windowDimensions = useWindowDimensions();
const insets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName();
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInsetSV = useSharedValue(insets.bottom);
@@ -63,77 +77,69 @@ export function AutocompletePopover({
bottomInsetSV.value = insets.bottom;
}, [bottomInsetSV, insets.bottom]);
// Same shift formula as useKeyboardShiftStyle({mode: "translate"}), so the popover
// tracks the composer's keyboard translate in lockstep.
const shift = useDerivedValue(() =>
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
);
// Snapshot of `shift` at the moment we measured the anchor. Translate applied to the
// popover is `openShift - shift`, so when shift == openShift the popover sits at the
// measured position; when keyboard moves the popover translates with the composer.
const openShift = useSharedValue(0);
useEffect(() => {
if (!visible) {
setAnchorRect(null);
setContentSize(null);
if (!visible || (options.length > 0 && selectedIndex < 0)) {
setRelativeAnchorRect(null);
return;
}
let cancelled = false;
// measureInWindow on Android returns coords below the status bar, while the Portal
// overlay starts at the top of the window. Mirror tooltip.tsx and shift the rect
// down by the status bar height to keep both in the same coord system.
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
const remeasure = () => {
const element = anchorRef.current;
if (!element) return;
void measureElement(element).then((rect) => {
if (cancelled) return undefined;
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
const anchorElement = anchorRef.current;
if (!anchorElement) return;
void Promise.all([
measureElement(anchorElement),
measureFloatingPanelPortalHost(portalHostName),
]).then(([anchorRect, hostRect]) => {
if (cancelled || !hostRect) return undefined;
setRelativeAnchorRect({
x: anchorRect.x - hostRect.x,
y: anchorRect.y - hostRect.y,
width: anchorRect.width,
hostHeight: hostRect.height,
});
openShift.value = shift.value;
return undefined;
});
};
remeasure();
const raf = requestAnimationFrame(remeasure);
const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) =>
Keyboard.addListener(event, () => requestAnimationFrame(remeasure)),
);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
for (const sub of subscriptions) sub.remove();
};
}, [visible, anchorRef, openShift, shift]);
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
}, []);
}, [
visible,
options.length,
selectedIndex,
anchorRef,
portalHostName,
openShift,
shift,
windowDimensions.width,
windowDimensions.height,
]);
const baseStyle = useMemo(() => {
if (!anchorRect) return null;
if (!contentSize) {
// Have the anchor, waiting on the popover's own height. Render with the
// final width so the inner Autocomplete lays out at its final size, but
// stay invisible — the first visible paint will already be at the correct
// top. Mirrors combobox.tsx `shouldHideDesktopContent`. See
// docs/floating-panels.md "the two-measurement flash".
return inlineUnistylesStyle({
position: "absolute" as const,
top: 0,
left: anchorRect.x,
width: anchorRect.width,
opacity: 0,
});
}
if (!relativeAnchorRect) return null;
return inlineUnistylesStyle({
position: "absolute" as const,
top: anchorRect.y - contentSize.height - OFFSET_FROM_ANCHOR,
left: anchorRect.x,
width: anchorRect.width,
bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
left: relativeAnchorRect.x,
width: relativeAnchorRect.width,
});
}, [anchorRect, contentSize]);
}, [relativeAnchorRect]);
const animatedTransformStyle = useAnimatedStyle(() => ({
transform: [{ translateY: openShift.value - shift.value }],
@@ -144,12 +150,13 @@ export function AutocompletePopover({
[baseStyle, animatedTransformStyle],
);
if (!visible || !anchorRect || !baseStyle) return null;
if (!visible || !relativeAnchorRect || !baseStyle) return null;
if (options.length > 0 && selectedIndex < 0) return null;
return (
<Portal>
<Portal hostName={portalHostName}>
<View style={styles.overlay} pointerEvents="box-none">
<Animated.View style={composedStyle} onLayout={handleLayout}>
<Animated.View testID="composer-autocomplete-popover" style={composedStyle}>
<Autocomplete
options={options}
selectedIndex={selectedIndex}

View File

@@ -206,6 +206,7 @@ export function Autocomplete({
[ensureActiveItemVisible],
);
const selectedOption = options[selectedIndex];
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
if (isLoading) {
@@ -238,8 +239,6 @@ export function Autocomplete({
);
}
const selectedOption = options[selectedIndex];
return (
<View style={styles.outerWrapper}>
{selectedOption?.kind === "command" && selectedOption.description ? (

View File

@@ -0,0 +1,86 @@
import {
createContext,
type ReactElement,
type ReactNode,
useContext,
useEffect,
useRef,
} from "react";
import { StyleSheet, View } from "react-native";
import { PortalHost } from "@gorhom/portal";
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const portalHostElements = new Map<string, View>();
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
const element = portalHostElements.get(name);
if (!element) return Promise.resolve(null);
return measureElement(element);
}
export function FloatingPanelPortalHostNameProvider({
hostName,
children,
}: {
hostName: string;
children: ReactNode;
}): ReactElement {
return (
<FloatingPanelPortalHostNameContext.Provider value={hostName}>
{children}
</FloatingPanelPortalHostNameContext.Provider>
);
}
export function useFloatingPanelPortalHostName(): string {
return useContext(FloatingPanelPortalHostNameContext);
}
export function FloatingPanelPortalHost({
name = DEFAULT_FLOATING_PANEL_PORTAL_HOST,
}: {
name?: string;
}): ReactElement {
const hostRef = useRef<View>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
portalHostElements.set(name, host);
return () => {
if (portalHostElements.get(name) === host) {
portalHostElements.delete(name);
}
};
}, [name]);
return (
<View ref={hostRef} collapsable={false} pointerEvents="box-none" style={styles.host}>
<PortalHost name={name} />
</View>
);
}
const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
});

View File

@@ -3,23 +3,25 @@ import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -63,7 +65,10 @@ function buildChatDraftComposerArgs({
}) {
return {
initialServerId: serverId || null,
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
initialValues:
workspaceDirectory || sourceDirectory
? { workingDir: workspaceDirectory || sourceDirectory }
: undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
@@ -344,18 +349,27 @@ export function WorkspaceSetupDialog() {
addImagesRef.current = addImages;
}, []);
const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const statusControlsWithDisabled = useMemo(
const agentControlsWithDisabled = useMemo(
() =>
composerState
? {
...composerState.statusControls,
...composerState.agentControls,
disabled: pendingAction !== null,
}
: undefined,
[composerState, pendingAction],
);
const composerFooter = useMemo(
() =>
isCompact && agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : undefined,
[isCompact, agentControlsWithDisabled],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
@@ -409,9 +423,10 @@ export function WorkspaceSetupDialog() {
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
onAddImages={handleAddImagesCallback}
footer={composerFooter}
/>
</View>

View File

@@ -26,7 +26,7 @@ import {
type ComposerSendClient,
type QueueWriter,
type QueuedComposerMessage,
} from "./composer-actions";
} from "./actions";
const imageMetadata: AttachmentMetadata = {
id: "img-1",
@@ -189,6 +189,7 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
getTail: (agentId) => fake.tail.get(agentId),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
@@ -353,6 +354,7 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.optimistic).toBe(true);
});
it("appends to the existing head when one is present", async () => {

View File

@@ -8,8 +8,14 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { generateMessageId, type StreamItem } from "@/types/stream";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
export interface QueuedComposerMessage {
@@ -49,6 +55,7 @@ export interface ComposerCancelClient {
}
export interface AgentStreamWriter {
getTail: (agentId: string) => StreamItem[] | undefined;
getHead: (agentId: string) => StreamItem[] | undefined;
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
@@ -130,14 +137,13 @@ export async function dispatchComposerAgentMessage(
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const messageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
const userMessage = buildOptimisticUserMessage({
id: messageId,
text: input.text,
timestamp: new Date(),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
images: wirePayload.images,
attachments: wirePayload.attachments,
});
appendUserMessageToStream(input.agentId, userMessage, input.stream);
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
@@ -149,23 +155,29 @@ export async function dispatchComposerAgentMessage(
function appendUserMessageToStream(
agentId: string,
userMessage: StreamItem,
userMessage: UserMessageItem,
stream: AgentStreamWriter,
): void {
const head = stream.getHead(agentId);
if (head && head.length > 0) {
const result = appendOptimisticUserMessageToStream({
tail: stream.getTail(agentId) ?? [],
head: stream.getHead(agentId) ?? [],
message: userMessage,
placement: "active-head",
});
if (result.changedHead) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
next.set(agentId, result.head);
return next;
});
}
if (result.changedTail) {
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
return next;
});
return;
}
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
return next;
});
}
export interface QueueComposerMessageInput {

View File

@@ -0,0 +1,372 @@
import { memo, useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
ShieldQuestionMark,
} from "lucide-react-native";
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import type { AgentMode, AgentProvider } from "@server/server/agent/agent-sdk-types";
import {
getModeVisuals,
type AgentModeColorTier,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
export type AgentModeControlPlacement = "toolbar" | "footer";
function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) {
return placement === "footer" ? isCompact : !isCompact;
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
ShieldQuestionMark,
} as const;
interface ModePalette {
blue: { 500: string };
green: { 500: string };
red: { 500: string };
purple: { 500: string };
}
function getModeIconColor(colorTier: AgentModeColorTier | undefined, palette: ModePalette): string {
if (colorTier?.startsWith("#")) {
return colorTier;
}
switch (colorTier) {
case "safe":
return palette.green[500];
case "moderate":
return palette.blue[500];
case "dangerous":
return palette.red[500];
case "planning":
return palette.purple[500];
default:
return palette.blue[500];
}
}
interface ModeComboboxOptionProps {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
provider: string;
providerDefinitions: AgentProviderDefinition[];
iconColor: string;
}
function ModeComboboxOption({
option,
selected,
active,
onPress,
provider,
providerDefinitions,
iconColor,
}: ModeComboboxOptionProps) {
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const leadingSlot = useMemo(
() => <IconComponent size={16} color={iconColor} />,
[IconComponent, iconColor],
);
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
interface AgentModeControlViewProps {
provider: string;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
selectedModeId: string | null | undefined;
onSelectMode: (modeId: string) => void;
disabled?: boolean;
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function AgentModeControlView({
provider,
providerDefinitions,
modeOptions,
selectedModeId,
onSelectMode,
disabled = false,
}: AgentModeControlViewProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const selectedMode = useMemo(() => {
if (modeOptions.length === 0) return null;
return modeOptions.find((m) => m.id === selectedModeId) ?? modeOptions[0];
}, [modeOptions, selectedModeId]);
const visuals = selectedMode
? getModeVisuals(provider, selectedMode.id, providerDefinitions)
: undefined;
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const iconColor = getModeIconColor(visuals?.colorTier, theme.colors.palette);
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
const allOptions = useMemo<ComboboxOption[]>(
() => modeOptions.map((m) => ({ id: m.id, label: formatAgentModeLabel(m) })),
[modeOptions],
);
const options = useMemo<ComboboxOption[]>(() => {
const q = normalizeSearchQuery(searchQuery);
if (!q) return allOptions;
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [allOptions, searchQuery]);
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
if (!next) setSearchQuery("");
}, []);
const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
const handleSelect = useCallback(
(id: string) => {
onSelectMode(id);
handleOpenChange(false);
},
[onSelectMode, handleOpenChange],
);
const renderOption = useCallback(
(args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => (
<ModeComboboxOption
option={args.option}
selected={args.selected}
active={args.active}
onPress={args.onPress}
provider={provider}
providerDefinitions={providerDefinitions}
iconColor={theme.colors.foreground}
/>
),
[provider, providerDefinitions, theme.colors.foreground],
);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.chip,
hovered && styles.chipHovered,
(pressed || open) && styles.chipPressed,
disabled && styles.chipDisabled,
],
[open, disabled],
);
const labelStyle = useMemo(() => [styles.chipLabel, { color: iconColor }], [iconColor]);
const sheetHeader = useMemo<SheetHeader>(
() => ({
title: "Mode",
search: {
value: searchQuery,
onChange: setSearchQuery,
placeholder: "Search modes...",
testID: "mode-search-input",
},
}),
[searchQuery],
);
if (!selectedMode) return null;
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
testID="mode-control"
>
<Icon size={theme.iconSize.md} color={iconColor} />
<Text style={labelStyle}>{selectedModeLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</Pressable>
<Combobox
options={options}
value={selectedMode.id}
onSelect={handleSelect}
open={open}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
header={sheetHeader}
renderOption={renderOption}
/>
</>
);
}
const EMPTY_MODES: AgentMode[] = [];
function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
interface AgentModeControlProps {
serverId: string;
agentId: string;
placement: AgentModeControlPlacement;
}
export const AgentModeControl = memo(function AgentModeControl({
serverId,
agentId,
placement,
}: AgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
if (!agent) return null;
return {
provider: agent.provider,
cwd: agent.cwd,
currentModeId: agent.currentModeId,
};
}),
);
const availableModes = useStoreWithEqualityFn(
useSessionStore,
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
compareAvailableModes,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
const providerDefinitions = useMemo<AgentProviderDefinition[]>(() => {
if (!slice?.provider) return [];
const definition = resolveProviderDefinition(slice.provider, snapshotEntries);
return definition ? [definition] : [];
}, [slice?.provider, snapshotEntries]);
const handleSelectMode = useCallback(
(modeId: string) => {
if (!client) return;
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, client, toast],
);
if (!slice || availableModes.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
<AgentModeControlView
provider={slice.provider}
providerDefinitions={providerDefinitions}
modeOptions={availableModes}
selectedModeId={slice.currentModeId}
onSelectMode={handleSelectMode}
disabled={!client}
/>
);
});
export interface DraftAgentModeControlProps {
selectedProvider: AgentProvider | null;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
disabled?: boolean;
placement: AgentModeControlPlacement;
}
export function DraftAgentModeControl({
selectedProvider,
providerDefinitions,
modeOptions,
selectedMode,
onSelectMode,
disabled,
placement,
}: DraftAgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
if (!selectedProvider || modeOptions.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
<AgentModeControlView
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={modeOptions}
selectedModeId={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
/>
);
}
const styles = StyleSheet.create((theme) => ({
chip: {
height: 28,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
chipHovered: {
backgroundColor: theme.colors.surface2,
},
chipPressed: {
backgroundColor: theme.colors.surface0,
},
chipDisabled: {
opacity: 0.5,
},
chipLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));

View File

@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./composer.status-controls";
import { resolveAgentControlsMode } from "./mode";
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
expect(resolveStatusControlMode(undefined)).toBe("ready");
describe("resolveAgentControlsMode", () => {
it("uses ready mode when no controlled agent controls are provided", () => {
expect(resolveAgentControlsMode(undefined)).toBe("ready");
});
it("uses draft mode when controlled status controls are provided", () => {
it("uses draft mode when controlled agent controls are provided", () => {
expect(
resolveStatusControlMode({
resolveAgentControlsMode({
providerDefinitions: [],
selectedProvider: "codex",
onSelectProvider: () => undefined,

View File

@@ -0,0 +1,5 @@
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
return agentControls ? "draft" : "ready";
}

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
import { isProviderModelsQueryLoading } from "./model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {

View File

@@ -1,17 +1,19 @@
import { describe, expect, it } from "vitest";
import {
formatAgentModeLabel,
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
getAgentControlHint,
formatThinkingOptionLabel,
normalizeModelId,
resolveAgentModelSelection,
} from "./agent-status-bar.utils";
} from "./utils";
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
describe("getAgentControlHint", () => {
it("explains what each editable agent control does", () => {
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
expect(getAgentControlHint("model")).toBe("Change model");
expect(getAgentControlHint("mode")).toBe("Change permission mode");
});
});
@@ -52,6 +54,36 @@ describe("normalizeModelId", () => {
});
});
describe("formatAgentModeLabel", () => {
it("sentence-cases provider mode labels", () => {
expect(formatAgentModeLabel({ id: "plan", label: "Plan" })).toBe("Plan");
expect(formatAgentModeLabel({ id: "full-access", label: "Full Access" })).toBe("Full access");
expect(formatAgentModeLabel({ id: "auto-review", label: "Auto-review" })).toBe("Auto-review");
expect(formatAgentModeLabel({ id: "read_only", label: "read_only" })).toBe("Read only");
expect(formatAgentModeLabel({ id: "acceptEdits", label: "acceptEdits" })).toBe("Accept edits");
});
it("splits compact mode ids when no provider label is available", () => {
expect(formatAgentModeLabel({ id: "auto-review" })).toBe("Auto review");
});
});
describe("formatThinkingOptionLabel", () => {
it("formats compact thinking option labels for display", () => {
expect(formatThinkingOptionLabel({ id: "none", label: "none" })).toBe("None");
expect(formatThinkingOptionLabel({ id: "low", label: "low" })).toBe("Low");
expect(formatThinkingOptionLabel({ id: "medium", label: "medium" })).toBe("Medium");
expect(formatThinkingOptionLabel({ id: "high", label: "high" })).toBe("High");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "xhigh" })).toBe("Extra high");
});
it("sentence-cases split provider labels", () => {
expect(formatThinkingOptionLabel({ id: "extra_high", label: "extra_high" })).toBe("Extra high");
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
});
});
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
@@ -97,6 +129,28 @@ describe("resolveAgentModelSelection", () => {
expect(selection.displayThinking).toBe("High");
});
it("formats raw thinking labels in the selected model display", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "a",
provider: "claude",
label: "Model A",
thinkingOptions: [
{ id: "none", label: "none" },
{ id: "xhigh", label: "xhigh" },
],
},
],
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: "xhigh",
});
expect(selection.selectedThinkingId).toBe("xhigh");
expect(selection.displayThinking).toBe("Extra high");
});
it("falls back to the provider default model label instead of Auto", () => {
const selection = resolveAgentModelSelection({
models: [

View File

@@ -1,9 +1,9 @@
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
export function getAgentControlHint(selector: ExplainedAgentControl): string {
switch (selector) {
case "thinking":
return "Thinking mode";
@@ -39,6 +39,50 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
}
}
interface ControlLabelInput {
id: string;
label?: string | null;
}
function sentenceCase(value: string): string {
if (!value) {
return value;
}
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
function splitCompactLabel(value: string, splitHyphen: boolean): string {
const separatorPattern = splitHyphen ? /[_-]+/g : /_+/g;
return value
.replace(separatorPattern, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
}
function formatControlLabel(option: ControlLabelInput, splitHyphen: boolean): string {
const rawLabel = (option.label ?? option.id).trim();
return sentenceCase(splitCompactLabel(rawLabel, splitHyphen));
}
export function formatAgentModeLabel(mode: ControlLabelInput): string {
return formatControlLabel(mode, mode.label == null);
}
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const rawLabel = (option.label ?? option.id).trim();
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") {
return "Extra high";
}
return formatControlLabel(option, true);
}
function findModelById(
models: AgentModelDefinition[] | null,
modelId: string | null,
@@ -105,6 +149,21 @@ function resolveModelDisplay(
};
}
function resolveThinkingDisplay(
effectiveThinking: ThinkingOption | null,
selectedThinkingId: string | null,
): string {
if (effectiveThinking) {
return formatThinkingOptionLabel(effectiveThinking);
}
if (selectedThinkingId) {
return formatThinkingOptionLabel({ id: selectedThinkingId });
}
return "Unknown";
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
@@ -134,7 +193,7 @@ export function resolveAgentModelSelection(input: {
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
return {
selectedModel,

View File

@@ -1,4 +1,5 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment } from "@/composer/types";
import {
isWorkspaceAttachment,
workspaceAttachmentToSubmitAttachment,
@@ -6,8 +7,6 @@ import {
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
export type ImageAttachment = AttachmentMetadata;
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
images: ImageAttachment[];
attachments: AgentAttachment[];

View File

@@ -0,0 +1,90 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./create-flow";
describe("useDraftAgentCreateFlow", () => {
beforeEach(() => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
const image: UserMessageImageAttachment = {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-key",
createdAt: 123,
};
const attachment = {
type: "review",
cwd: "/repo",
summary: "Review",
} as unknown as AgentAttachment;
const attempt: DraftCreateAttempt = {
clientMessageId: "msg-prepared",
text: "build this",
timestamp: new Date("2026-05-25T00:00:00.000Z"),
images: [image],
attachments: [attachment],
};
const createRequest = vi.fn(
async (ctx: {
attempt: DraftCreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
cwd: string;
}) => ({
agentId: "agent-1",
result: { id: "agent-1", ctx },
}),
);
const onCreateSuccess = vi.fn();
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
initialAttempt: attempt,
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
createRequest,
onCreateSuccess,
}),
);
expect(result.current.isSubmitting).toBe(true);
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
expect(result.current.optimisticStreamItems).toEqual([
{
kind: "user_message",
id: "msg-prepared",
text: "build this",
timestamp: attempt.timestamp,
optimistic: true,
images: [image],
attachments: [attachment],
},
]);
expect(createRequest).not.toHaveBeenCalled();
await act(async () => {
await result.current.continueCreateFromAttempt({ attempt, cwd: "/repo" });
});
expect(createRequest).toHaveBeenCalledTimes(1);
expect(createRequest).toHaveBeenCalledWith({
attempt,
text: "build this",
images: [image],
attachments: [attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,8 +1,10 @@
import { useCallback, useMemo, useReducer } from "react";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
@@ -79,6 +81,7 @@ interface CreateRequestContext {
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
initialAttempt?: CreateAttempt | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
@@ -92,6 +95,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
initialAttempt = null,
allowEmptyText = false,
validateBeforeSubmit,
onBeforeSubmit,
@@ -101,15 +105,25 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const [machine, dispatch] = useReducer(reducer, {
tag: "draft",
errorMessage: "",
} as DraftAgentMachineState);
const [machine, dispatch] = useReducer(
reducer,
initialAttempt,
(attempt): DraftAgentMachineState =>
attempt
? { tag: "creating", attempt }
: {
tag: "draft",
errorMessage: "",
},
);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
const appendOptimisticUserMessageToAgentStream = useSessionStore(
(state) => state.appendOptimisticUserMessageToAgentStream,
);
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
@@ -128,18 +142,13 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
return [
{
kind: "user_message",
buildOptimisticUserMessage({
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
...(machine.attempt.attachments && machine.attempt.attachments.length > 0
? { attachments: machine.attempt.attachments }
: {}),
},
images: machine.attempt.images,
attachments: machine.attempt.attachments,
}),
];
}, [machine]);
@@ -150,6 +159,73 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
return buildDraftAgent(machine.attempt);
}, [buildDraftAgent, machine]);
const runCreateAttempt = useCallback(
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
onBeforeSubmit?.({
attempt,
text: attempt.text,
images: attempt.images,
attachments: attempt.attachments,
cwd,
});
try {
const createResult = await createRequest({
attempt,
text: attempt.text,
images: attempt.images,
attachments: attempt.attachments,
cwd,
});
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId });
appendOptimisticUserMessageToAgentStream(
pendingServerId,
createResult.agentId,
buildOptimisticUserMessage({
id: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
attachments: attempt.attachments,
}),
{ placement: "tail", skipIfUserMessageExists: true },
);
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
}
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
onCreateError?.(resolved);
throw error;
}
},
[
appendOptimisticUserMessageToAgentStream,
clearPendingCreateAttempt,
createRequest,
draftId,
getPendingServerId,
markPendingCreateLifecycle,
onBeforeSubmit,
onCreateError,
onCreateSuccess,
updatePendingAgentId,
],
);
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
@@ -206,57 +282,32 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
: {}),
});
onBeforeSubmit?.({
attempt,
text: trimmedPrompt,
images,
attachments: wirePayload.attachments,
cwd,
});
dispatch({ type: "SUBMIT", attempt });
onCreateStart?.();
try {
const createResult = await createRequest({
attempt,
text: trimmedPrompt,
images,
attachments: wirePayload.attachments,
cwd,
});
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId });
}
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
onCreateError?.(resolved);
throw error;
}
await runCreateAttempt({ attempt, cwd });
},
[
clearPendingCreateAttempt,
createRequest,
allowEmptyText,
draftId,
getPendingServerId,
isSubmitting,
markPendingCreateLifecycle,
onBeforeSubmit,
onCreateError,
onCreateStart,
onCreateSuccess,
runCreateAttempt,
setPendingCreateAttempt,
updatePendingAgentId,
validateBeforeSubmit,
allowEmptyText,
],
);
const continueCreateFromAttempt = useCallback(
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
if (!isSubmitting) {
dispatch({ type: "SUBMIT", attempt });
}
await runCreateAttempt({ attempt, cwd });
},
[isSubmitting, runCreateAttempt],
);
return {
machine,
formErrorMessage,
@@ -264,6 +315,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
};
}

View File

@@ -1,5 +1,5 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
export interface DraftKeyContext {
@@ -18,12 +18,12 @@ export function resolveDraftKey(input: {
return input.draftKey;
}
export function buildDraftStatusControls(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps {
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,

View File

@@ -30,7 +30,7 @@ vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: async () => undefined,
}));
vi.mock("./use-agent-form-state", () => ({
vi.mock("@/hooks/use-agent-form-state", () => ({
useAgentFormState: () => ({
selectedServerId: "host-1",
setSelectedServerId: () => undefined,
@@ -114,7 +114,7 @@ vi.mock("./use-agent-form-state", () => ({
}),
}));
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
@@ -139,7 +139,7 @@ beforeAll(async () => {
configurable: true,
});
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
({ useAgentInputDraft } = await import("./input-draft"));
});
describe("useAgentInputDraft live contract", () => {
@@ -209,7 +209,7 @@ describe("useAgentInputDraft live contract", () => {
);
});
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveDraftKey } from "./use-agent-input-draft-core";
import { resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
@@ -10,11 +10,11 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftStatusControls,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
type DraftKeyInput,
} from "@/hooks/use-agent-input-draft-core";
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
@@ -46,11 +46,11 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
statusControls: DraftAgentStatusBarProps;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
};
interface AgentInputDraft {
export interface AgentInputDraft {
text: string;
setText: (text: string) => void;
attachments: UserComposerAttachment[];
@@ -278,7 +278,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
statusControls: buildDraftStatusControls({
agentControls: buildDraftAgentControls({
formState,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
@@ -314,5 +314,5 @@ export const __private__ = {
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftStatusControls,
buildDraftAgentControls,
};

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { validateDraftSubmission } from "./workspace-draft-agent-tab-core";
import { validateDraftSubmission } from "./workspace-tab-core";
const baseComposerState = {
providerDefinitions: [{ id: "deepseek-tui" }],

View File

@@ -3,26 +3,28 @@ import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import invariant from "tiny-invariant";
import { Composer } from "@/components/composer";
import { ComposerImportPill } from "@/screens/workspace/composer-import-pill";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/components/agent-stream-view";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { ImageAttachment } from "@/components/message-input";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import type { ImageAttachment } from "@/composer/types";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core";
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -188,7 +190,7 @@ function buildDraftAgentSnapshot(input: {
modeOptions: unknown[];
selectedMode: string;
selectedProvider: string | null;
statusControls: { features?: Agent["features"] };
agentControls: { features?: Agent["features"] };
};
}): Agent {
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
@@ -224,7 +226,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent",
cwd: workspaceDirectory,
model,
features: composerState.statusControls.features,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
@@ -348,10 +350,33 @@ export function WorkspaceDraftAgentTab({
const pending = state.pendingByDraftId[draftId] ?? null;
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
});
const pendingCreateAttempt = useCreateFlowStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
return pending?.serverId === serverId && pending.lifecycle === "active" ? pending : null;
});
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
(state) => state.consumePending,
);
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
if (!pendingAutoSubmit || !pendingCreateAttempt) {
return null;
}
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
return null;
}
return {
clientMessageId: pendingCreateAttempt.clientMessageId,
text: pendingCreateAttempt.text,
timestamp: new Date(pendingCreateAttempt.timestamp),
...(pendingCreateAttempt.images && pendingCreateAttempt.images.length > 0
? { images: pendingCreateAttempt.images }
: {}),
...(pendingCreateAttempt.attachments && pendingCreateAttempt.attachments.length > 0
? { attachments: pendingCreateAttempt.attachments }
: {}),
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompact = useIsCompactFormFactor();
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
@@ -390,9 +415,11 @@ export function WorkspaceDraftAgentTab({
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
initialAttempt: initialCreateAttempt,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text }) =>
validateDraftSubmission({
@@ -442,7 +469,6 @@ export function WorkspaceDraftAgentTab({
draftInput.isHydrated &&
draftWorkingDirectory &&
client &&
!isSubmitting &&
!composerState.isModelLoading,
);
const autoSubmitKeyRef = useRef<string | null>(null);
@@ -461,19 +487,31 @@ export function WorkspaceDraftAgentTab({
autoSubmitKeyRef.current = submitKey;
setDraftText("");
setDraftAttachments([]);
void handleCreateFromInput({
text: submission.text,
attachments: submission.attachments,
cwd: submission.cwd,
}).catch(() => {
const preparedAttempt =
initialCreateAttempt?.clientMessageId === submission.clientMessageId
? initialCreateAttempt
: null;
const createPromise = preparedAttempt
? continueCreateFromAttempt({
attempt: preparedAttempt,
cwd: submission.cwd,
})
: handleCreateFromInput({
text: submission.text,
attachments: submission.attachments,
cwd: submission.cwd,
});
void createPromise.catch(() => {
setDraftText(submission.text);
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
autoSubmitKeyRef.current = null;
});
}, [
continueCreateFromAttempt,
consumePendingAutoSubmit,
draftId,
handleCreateFromInput,
initialCreateAttempt,
isReadyForPendingAutoSubmit,
serverId,
setDraftAttachments,
@@ -540,7 +578,7 @@ export function WorkspaceDraftAgentTab({
const handleSetFeatureWithFocus = useCallback(
(featureId: string, value: unknown) => {
composerState.statusControls.onSetFeature?.(featureId, value);
composerState.agentControls.onSetFeature?.(featureId, value);
focusInputRef.current?.();
},
[composerState],
@@ -555,9 +593,9 @@ export function WorkspaceDraftAgentTab({
focusInputRef.current?.();
}, []);
const importPillPress = resolveImportPillPress(onOpenImportSheet, isSubmitting);
const composerStatusControls = useMemo(
const composerAgentControls = useMemo(
() => ({
...composerState.statusControls,
...composerState.agentControls,
onSelectProvider: handleProviderSelectWithFocus,
onSelectMode: handleModeSelectWithFocus,
onSelectModel: handleModelSelectWithFocus,
@@ -568,7 +606,7 @@ export function WorkspaceDraftAgentTab({
disabled: isSubmitting,
}),
[
composerState.statusControls,
composerState.agentControls,
handleProviderSelectWithFocus,
handleModeSelectWithFocus,
handleModelSelectWithFocus,
@@ -579,6 +617,13 @@ export function WorkspaceDraftAgentTab({
isSubmitting,
],
);
const composerFooter = useMemo(
() =>
isCompact ? (
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
) : undefined,
[isCompact, composerAgentControls],
);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
@@ -638,7 +683,8 @@ export function WorkspaceDraftAgentTab({
onAddImages={handleAddImagesCallback}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
statusControls={composerStatusControls}
agentControls={composerAgentControls}
footer={composerFooter}
/>
</View>
</View>

View File

@@ -9,7 +9,7 @@ import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
import { useComposerGithubAutoAttach } from "./auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];

View File

@@ -15,7 +15,7 @@ import {
} from "@/git/use-github-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@server/shared/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;

View File

@@ -6,7 +6,16 @@ import {
Image,
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
useMemo,
memo,
type ReactElement,
type ReactNode,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
@@ -23,20 +32,15 @@ import {
import Animated from "react-native-reanimated";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import {
AgentStatusBar,
DraftAgentStatusBar,
type DraftAgentStatusBarProps,
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
AgentControls,
DraftAgentControls,
type DraftAgentControlsProps,
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
MessageInput,
type MessagePayload,
type ImageAttachment,
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
@@ -56,7 +60,7 @@ import {
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/components/composer-actions";
} from "@/composer/actions";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -74,12 +78,12 @@ import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/components/agent-input-submit";
import { submitAgentInput } from "@/composer/submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
@@ -89,7 +93,7 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentPill } from "@/components/attachment-pill";
@@ -98,7 +102,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
type QueuedMessage = QueuedComposerMessage;
@@ -177,6 +181,7 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
};
};
}
@@ -184,42 +189,56 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
function renderContextWindowMeterSlot(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
): ReactElement {
const meter =
contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter maxTokens={contextWindowMaxTokens} usedTokens={contextWindowUsedTokens} />
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
/>
) : null;
return <View style={styles.contextWindowMeterSlot}>{meter}</View>;
}
interface RenderLeftContentArgs {
statusControls: DraftAgentStatusBarProps | undefined;
agentControls: DraftAgentControlsProps | undefined;
agentId: string;
serverId: string;
focusInput: () => void;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { statusControls, agentId, serverId, focusInput } = args;
if (resolveStatusControlMode(statusControls) === "draft" && statusControls) {
return <DraftAgentStatusBar {...statusControls} />;
const { agentControls, agentId, serverId, focusInput } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} />;
}
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
}
interface RenderAttachmentPreviewListArgs {
interface RenderAttachmentTrayArgs {
selectedAttachments: ComposerAttachment[];
isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void;
}
function renderAttachmentPreviewList(args: RenderAttachmentPreviewListArgs): ReactElement | null {
function renderComposerFooter(footer: ReactNode): ReactElement | null {
if (!footer) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>{footer}</View>
</View>
);
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } =
args;
if (selectedAttachments.length === 0) return null;
return (
<View style={styles.attachmentPreviewContainer} testID="composer-attachment-pills">
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
attachment,
@@ -233,17 +252,17 @@ function renderAttachmentPreviewList(args: RenderAttachmentPreviewListArgs): Rea
);
}
interface RenderQueueListArgs {
interface RenderQueueTrackArgs {
queuedMessages: readonly QueuedMessage[];
handleEditQueuedMessage: (id: string) => void;
handleSendQueuedNow: (id: string) => Promise<void>;
}
function renderQueueList(args: RenderQueueListArgs): ReactElement | null {
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args;
if (queuedMessages.length === 0) return null;
return (
<View style={styles.queueContainer}>
<View style={styles.queueTrack}>
{queuedMessages.map((item) => (
<QueuedMessageRow
key={item.id}
@@ -623,10 +642,12 @@ interface ComposerProps {
onComposerHeightChange?: (height: number) => void;
onAttentionInputFocus?: () => void;
onAttentionPromptSend?: () => void;
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps;
/** Controlled agent controls rendered in input area (draft flows). */
agentControls?: DraftAgentControlsProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
/** Rendered below the input, inside the keyboard-shifted container. */
footer?: ReactNode;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -818,8 +839,9 @@ export function Composer({
onComposerHeightChange,
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
agentControls,
inputWrapperStyle,
footer,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
@@ -1023,6 +1045,7 @@ export function Composer({
throw new Error("Host is not connected");
}
const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
@@ -1419,8 +1442,13 @@ export function Composer({
);
const beforeVoiceContent = useMemo(
() => renderContextWindowMeterSlot(contextWindowMaxTokens, contextWindowUsedTokens),
[contextWindowMaxTokens, contextWindowUsedTokens],
() =>
renderContextWindowMeterSlot(
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -1487,8 +1515,8 @@ export function Composer({
);
const leftContent = useMemo(
() => renderLeftContent({ statusControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, statusControls],
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, agentControls],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1554,9 +1582,9 @@ export function Composer({
[isComposerLocked],
);
const attachmentPreviewList = useMemo(
const attachmentTray = useMemo(
() =>
renderAttachmentPreviewList({
renderAttachmentTray({
selectedAttachments,
isComposerLocked,
handleOpenAttachment,
@@ -1566,7 +1594,7 @@ export function Composer({
);
const queueList = useMemo(
() => renderQueueList({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
() => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
);
@@ -1582,6 +1610,7 @@ export function Composer({
const githubEmptyText = githubSearchResultsQuery.isFetching
? "Searching..."
: "No results found.";
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return (
<Animated.View style={composerContainerStyle}>
@@ -1594,7 +1623,7 @@ export function Composer({
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocomplete.isVisible && isPaneFocused}
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
@@ -1643,7 +1672,7 @@ export function Composer({
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentPreviewList}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
@@ -1664,6 +1693,7 @@ export function Composer({
</View>
</View>
</View>
{renderComposerFooter(footer)}
</Animated.View>
);
}
@@ -1695,6 +1725,23 @@ const styles = StyleSheet.create((theme: Theme) => ({
maxWidth: MAX_CONTENT_WIDTH,
gap: theme.spacing[3],
},
footer: {
width: "100%",
paddingHorizontal: theme.spacing[4],
marginTop: {
xs: -theme.spacing[4],
md: -theme.spacing[3],
},
alignItems: "center",
},
footerContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
paddingLeft: {
xs: 6,
md: 10,
},
},
messageInputContainer: {
position: "relative",
width: "100%",
@@ -1734,7 +1781,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
attachmentPreviewContainer: {
attachmentTray: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
@@ -1781,7 +1828,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
buttonDisabled: {
opacity: 0.5,
},
queueContainer: {
queueTrack: {
flexDirection: "column",
gap: theme.spacing[2],
},

View File

@@ -0,0 +1 @@
export * from "./height-mirror.native";

View File

@@ -24,8 +24,8 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
@@ -35,8 +35,8 @@ import {
collectImageFilesFromClipboardData,
filesToImageAttachments,
} from "@/utils/image-attachments-from-files";
import type { AttachmentMetadata } from "@/attachments/types";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -55,18 +55,8 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./composer-height-mirror";
import { computeCanStartDictation } from "./message-input-state";
export type ImageAttachment = AttachmentMetadata;
export interface MessagePayload {
text: string;
attachments: ComposerAttachment[];
cwd: string;
/** When true, bypasses queue and sends immediately even if agent is running */
forceSend?: boolean;
}
import { useComposerHeightMirror } from "./height-mirror";
import { computeCanStartDictation } from "./state";
export interface AttachmentMenuItem {
id: string;
@@ -103,7 +93,7 @@ export interface MessageInputProps {
disabled?: boolean;
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
isPaneFocused?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
/** Content to render on the left side of the composer toolbar (e.g., AgentControls) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
@@ -1771,7 +1761,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
{/* Button row */}
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
isConnected={isConnected}

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