Compare commits

...

80 Commits

Author SHA1 Message Date
Mohamed Boudra
801140abb2 chore(release): cut 0.1.90-beta.1 2026-06-04 01:02:54 +07:00
Mohamed Boudra
b345ef1242 Stop counting initializing agents as running 2026-06-04 00:53:38 +07:00
Mohamed Boudra
f6c0b60b7c Fix sidebar shortcut hints shifting rows 2026-06-04 00:48:53 +07:00
Mohamed Boudra
ffe4d4046c Update ACP provider catalog pins 2026-06-04 00:27:46 +07:00
Mohamed Boudra
7f9582e197 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-04 00:22:40 +07:00
Mohamed Boudra
6e8b7aaa07 Show PR action when auto-merge is enabled 2026-06-04 00:21:33 +07:00
Mohamed Boudra
31ae545289 Group and clear workspace status in the sidebar (#1317)
* Add status grouping to the workspace sidebar

* Mark workspaces as read from the sidebar

* Clean package builds before release checks

* Fix sidebar status follow-up checks
2026-06-04 01:20:20 +08:00
Mohamed Boudra
71ce96b913 Prioritize ready actions and scheduled agent prompts (#1316)
* Sort sessions by last activity

* Prioritize shipping actions in git controls

* Fix scheduled new agent titles
2026-06-03 16:16:05 +00:00
Mohamed Boudra
eba8de6ab5 Speed up session import 2026-06-03 23:09:45 +07:00
Mohamed Boudra
4c730611e6 Fix markdown code rendering in file previews 2026-06-03 23:08:47 +07:00
Mohamed Boudra
bf81496a4f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 23:00:52 +07:00
Mohamed Boudra
6d29092fe6 Fix older timeline loading after catch-up 2026-06-03 22:32:12 +07:00
Mohamed Boudra
b3ba1572b3 Keep child agents unattended across providers (#1315)
* Keep child agents unattended across providers

* Clean up unattended create config resolution

* Make provider catalog tests tolerate narrow CI fixtures
2026-06-03 23:17:14 +08:00
Mohamed Boudra
369adced52 Archive merged PR workspaces from settings (#1313)
* Add workspace settings for merged PR cleanup

* Fix worktree branch ahead status

* Handle PR worktree upstream status

* Remove stale checkout tracking field

* Update settings E2E host section slugs

* Treat unknown upstream as unsafe for auto-archive
2026-06-03 23:17:08 +08:00
Mohamed Boudra
b510244518 Prioritize shipping actions in git controls 2026-06-03 22:12:27 +07:00
Mohamed Boudra
c44ad7ecd1 Sort sessions by last activity 2026-06-03 22:00:52 +07:00
Mohamed Boudra
bd4889e243 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 19:34:57 +07:00
Mohamed Boudra
3634f5e2d8 Fix compact composer flash on tab switch 2026-06-03 19:32:49 +07:00
Mohamed Boudra
76ae8c19b0 Show delegated agent activity on parent workspaces 2026-06-03 19:10:05 +07:00
Mohamed Boudra
91ef9301c2 Show question prompts one at a time
* Show question prompts one at a time

* Make question prompt submit action literal
2026-06-03 19:49:36 +08:00
Mohamed Boudra
510a299420 Update epic PR babysitting guidance 2026-06-03 18:33:13 +07:00
Mohamed Boudra
38d690e425 Show attachment actions in a mobile sheet 2026-06-03 18:30:43 +07:00
Aaron Zhong
e9a746d4ea feat: open active files in editors and file managers (#1285)
* feat: open active files in editors and file managers

* fix: open the workspace folder alongside the active file in editors

Editor targets received only the file path, so opening from the workspace
file explorer launched the bare file without the surrounding project. Thread
an optional workspace `cwd` through the open_in_editor request so editors
launch as `<workspace> <file>`, loading the project explorer plus the file.
File-manager targets keep their single-path behavior.

* fix: address PR review feedback on file-open path resolution

- Narrow the tilde guard so only genuine home-relative paths (~ or ~/…)
  are rejected; filenames like ~env.ts are now treated as workspace-relative.
- Make the workspace-containment check case-insensitive for Windows drive
  paths so case-only differences still resolve a relative path.
- Centralize the file-manager target id set in the protocol
  (isFileManagerTargetId) so the server and app no longer duplicate it.

* feat(desktop): open workspace files from Electron

* test(desktop): avoid mocked Electron editor IPC

* fix(desktop): avoid Windows shell for editor executables

* test(app): mock desktop editor bridge in e2e

* fix(app): reuse resolved workspace open target path

* fix(app): stabilize active file memo deps

* fix(protocol): tag legacy editor RPC shims

* fix(desktop): allow custom editor target ids

* fix(desktop): open Windows editor paths with metacharacters

Quote metacharacter values for .cmd editor wrappers so cmd.exe preserves them across wrapper execution instead of passing literal carets through.

* fix(app): preserve reviewed file path handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-03 19:20:30 +08:00
Mohamed Boudra
c0a0e3a1c6 Keep desktop skills up to date automatically (#1309)
* Keep installed desktop skills up to date

* Preserve user files during skill sync
2026-06-03 11:09:02 +00:00
Mohamed Boudra
3681fd132b Open workspace files in desktop targets 2026-06-03 18:34:24 +08:00
Mohamed Boudra
bb18cf618a Write persisted files atomically 2026-06-03 17:25:07 +07:00
Mohamed Boudra
fbaa0caedb Persist submitted ACP user messages 2026-06-03 17:22:53 +07:00
Mohamed Boudra
45f412456c Improve switch contrast in zinc theme 2026-06-03 16:02:55 +07:00
Mohamed Boudra
9f67542bd5 Keep keyboard dismissed for selector sheets 2026-06-03 15:53:41 +07:00
Mohamed Boudra
b4fd965c43 Dismiss keyboard when opening selectors 2026-06-03 15:27:51 +07:00
Mohamed Boudra
b365d818f3 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-03 15:17:03 +07:00
Mohamed Boudra
6f6c939624 Pin ACP catalog entries to current versions 2026-06-03 14:08:56 +07:00
paseo-ai[bot]
0d98df4a00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-02 13:55:29 +00:00
Mohamed Boudra
5aecd36194 chore(release): cut 0.1.89 2026-06-02 20:51:07 +07:00
Mohamed Boudra
539441b3d5 Add 0.1.89 changelog 2026-06-02 20:49:48 +07:00
Mohamed Boudra
4b9eff1759 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-02 20:31:09 +07:00
Mohamed Boudra
48bc2f3166 Bump Cline ACP catalog entry to v3 2026-06-02 20:30:51 +07:00
Mohamed Boudra
14af053c66 Keep delegated agents out of workspace alerts (#1293)
* Keep delegated agents out of workspace alerts

* Share delegated agent label parsing
2026-06-02 20:55:19 +08:00
Mohamed Boudra
4f3116d28a Unify workspace service URLs (#1288)
* Unify workspace service proxying

* Clean up service proxy review issues
2026-06-02 19:49:58 +08:00
Mohamed Boudra
5666b3014a Fix Pi extension command turns (#1290) 2026-06-02 10:09:50 +00:00
Mohamed Boudra
07ace19b69 Keep fork PR badges visible in worktrees 2026-06-02 15:38:27 +07:00
Matt Cowger
f1a20a9fe9 Add configurable service proxy (#1280)
* Add configurable service proxy

* Fix service proxy URL used when opening scripts with a public base URL

When a public proxy URL was configured, the View link was falling through
to direct connection logic and opening the wrong URL. Move the public URL
early-return before connection-type checks, and remove the now-dead
duplicate check further down.

* Add service proxy documentation

Covers config options, URL generation format, wildcard DNS setup,
and reverse proxy Host header requirements.

* Cap combined public service label and document hostname format

* Address service proxy review feedback
2026-06-02 14:08:41 +08:00
Mohamed Boudra
eac5d93f78 Tune hero mockup sidebar, composer, and tabs 2026-06-01 23:39:02 +07:00
Mohamed Boudra
541e4c04cf Lower compact composer breakpoint to 500, add footer bottom padding 2026-06-01 22:29:58 +07:00
Mohamed Boudra
ce8ad8c68f Let composer controls adapt to narrow panes (#1270) 2026-06-01 22:58:03 +08:00
Éverton Toffanetto
1ede1bccf7 feat(desktop): persist and restore window state
Fixes #1223
2026-06-01 12:04:58 +00:00
Mohamed Boudra
a5c2f39f65 Tighten social proof marquee spacing 2026-06-01 18:04:20 +07:00
Mohamed Boudra
a807eb0eb2 Update homepage messaging and carousel performance 2026-06-01 16:30:17 +07:00
Matt Cowger
d406c29e16 Add configurable worktree root
* Add configurable worktree root

Allow new Paseo worktrees to be created under a custom worktrees.root from config.json while preserving the existing PASEO_HOME/worktrees default. Thread the resolved root through create/list/archive/ownership flows, MCP/session paths, checkout metadata, schema, docs, and targeted tests.

* Address worktree root review feedback

Use the resolved Paseo worktree base when a custom PASEO_HOME is provided, and remove an unused worktreesRoot dependency from worktree list handling.

* Fix checkout worktree path test on Windows

Use platform-native paths in the custom PASEO_HOME regression test so Windows CI compares against the same resolved root shape as production code.

* Clarify optional worktrees config docs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 16:19:29 +08:00
Geoffrey Marc
8c6abcb41f fix(app): render bold/italic/strikethrough and line breaks via UITextView span on iOS (#1254)
* fix(app): render bold/italic/strikethrough via UITextView span on iOS

strong/em/s had no custom render rule and fell back to
react-native-markdown-display's defaults, which wrap children in a plain
RN <Text>. On iOS the paragraph/textgroup are native UITextViews
(markdown-text.ios.tsx); a plain <Text> nested inside is not hoisted into
a UITextViewChild, so its content rendered invisibly — bold/italic/
strikethrough silently disappeared (regression from #1153, v0.1.84).

Route strong/em/s through MarkdownInheritedText -> MarkdownTextSpan, the
same path text/textgroup already use, so the styled run composes as a
UITextViewChild on iOS and stays visible + selectable. Web/Android use a
plain <Text> for MarkdownTextSpan, so behavior there is unchanged.

Fixes #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(app): render hard/soft line breaks via UITextView span on iOS

hardbreak/softbreak had no custom rule, so react-native-markdown-display
emitted a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
plain <Text> is dropped (same root cause as strong/em/s/link): on iOS
hard line breaks vanished, and a softbreak between words joined them
("one\ntwo" -> "onetwo") — the "adjacent words dropped" symptom from the
original report.

Route both through MarkdownTextSpan so the newline composes as a
UITextViewChild on iOS; web/Android keep the same "\n".

Refs #1253

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:57:06 +08:00
Matt Cowger
b8ccd543c4 fix: archive worktrees even when teardown fails
* Archive workspace record even if teardown fails

* Fix teardown test for cross-platform commands
2026-06-01 15:34:51 +08:00
Éverton Toffanetto
ae5dfc0b3c Fix split-pane right-edge resize clipping (#1261)
* fix(app): clamp split pane resize

Prevent right-edge drags from overflowing the Electron viewport.

* fix(app): isolate resize pointer state

Keep concurrent resize drags from clobbering each other.
2026-06-01 15:32:26 +08:00
Mohamed Boudra
539d2969f1 Add homepage social proof 2026-06-01 14:06:12 +07:00
Mohamed Boudra
7fb3dda20c Add detached agents and heartbeat scheduling (#1266) 2026-06-01 14:07:31 +08:00
paseo-ai[bot]
89ec358d00 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-01 04:31:01 +00:00
Mohamed Boudra
0efa0f3c42 chore(release): cut 0.1.88 2026-06-01 11:26:48 +07:00
Mohamed Boudra
13a1d5ffba Update changelog date for 0.1.88 2026-06-01 11:23:45 +07:00
Mohamed Boudra
f9f6ff2dbc Restore canonical timeline fetches
Older clients still request canonical rows for catch-up. Honor that explicit request while leaving projected as the default and projected app sync path.

Update the pending 0.1.88 changelog with the fixes already on main.
2026-06-01 11:17:19 +07:00
fireblue
3095bcb760 fix(desktop): stop pinning macOS displays at max refresh rate (#1242)
The macOS compositor watchdog called webContents.setBackgroundThrottling(false)
unconditionally for the window's whole lifetime. That disables Chromium's idle
frame-rate throttling, so the GPU process's CADisplayLink never idles — pinning
ProMotion panels at their maximum refresh rate (120Hz) continuously, even while
the app is idle or occluded, which drains the battery.

The call was never needed: the probe already skips non-producing windows via its
screen-lock / isVisible / isMinimized / document.visibilityState guards, and the
freeze it targets happens while the window is visible and focused, where
background throttling never applies. Removing it lets the compositor idle and the
display drop to a low adaptive rate when nothing animates; freeze detection is
unaffected. A comment and a docs note keep it from being re-added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:42:22 +08:00
Mohamed Boudra
eb7495d231 Use Fastlane deliver for review resubmission 2026-06-01 10:38:20 +07:00
Mohamed Boudra
357c31d16e Clarify mobile release completion checks 2026-06-01 10:30:39 +07:00
Geoffrey Marc
a3b0af9ac0 fix(app): render inline links via UITextView span on iOS (#1257)
AssistantMarkdownLink's native branch wrapped its children in a plain RN
<Text>. On iOS that <Text> sits inside the paragraph's native UITextView
and is not hoisted into a UITextViewChild, so link text (markdown links,
bare URLs, and inline-code path links) was silently dropped — the same
root cause as the bold/italic/strikethrough fix in this PR.

Route the native link through MarkdownTextSpan so it composes as a
UITextViewChild and stays visible + selectable. MarkdownTextSpan now
forwards onPress/accessibilityRole across platforms: tappable on
Android/web; on iOS react-native-uitextview nulls onPress on the root
native view, so reliable tap-to-open is still tracked by #21 — but
visible, selectable, copyable link text strictly beats an invisible one.

Refs #1253

Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:29:59 +08:00
Daniel Salmerón Amselem
632c48fde3 Add timezone-aware cron schedules (#1232)
* feat: add timezone-aware cron schedules

* chore: normalize schedule timezone property

* fix: reject blank schedule timezone in mcp

* Stabilize terminal resize browser repro

* Fix terminal resize repro row assertions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-01 00:32:45 +08:00
Mohamed Boudra
922a93af2f Serialize browser app tests 2026-05-31 22:42:25 +07:00
Mohamed Boudra
2227540fad Show context usage beside compact mode selector 2026-05-31 22:38:46 +07:00
Mohamed Boudra
360110accc Stabilize terminal resize browser repro 2026-05-31 22:27:07 +07:00
Mohamed Boudra
6260224514 Update changelog for 0.1.88 2026-05-31 22:05:25 +07:00
Mohamed Boudra
1821f2853a Fix release prep checks 2026-05-31 22:05:18 +07:00
paseo-ai[bot]
c60bc9f5ae fix: update lockfile signatures and Nix hash [skip ci] 2026-05-31 14:45:49 +00:00
Mohamed Boudra
9f88b4e69c Refine appearance settings and syntax highlighting
Keep parsed chat and tool detail content in sync with appearance token changes. Hide font-family controls on native, use One as the default syntax theme, add Swift highlighting, and give Nord a distinct light palette.
2026-05-31 21:40:38 +07:00
Mohamed Boudra
4cc72a84e8 Fix terminal snapshots reflowing after resize
Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
2026-05-31 19:40:15 +07:00
Mohamed Boudra
794cbcef63 Fix scripts menu resizing after service launch
Reanimated entering animations can leave the measured menu surface with an inline height. Release that fixed height after opening and when measured content changes so service rows can grow in place.
2026-05-31 19:40:15 +07:00
Mohamed Boudra
a1c8e1a1f9 Add appearance settings for theme, fonts, and syntax highlighting (#1236)
* Add appearance settings for theme, fonts, and syntax highlighting

New Appearance section in Settings to configure the app theme, UI and
mono font family + size, and a syntax-highlighting theme chosen
independently of light/dark. Font family fields default to empty and
show the active system stack as the placeholder; a live split-diff
preview reflects the choices as they change. The theme picker moves
here from General.

Preferences are client-only (AsyncStorage) and applied at runtime by
patching every registered Unistyles theme via
UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint
with no per-component preference reads and no useUnistyles. No
daemon/protocol change.

* Expand syntax themes and move the preview under the chooser

Replace the small fixed syntax-theme set with eight popular themes — GitHub,
Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a
compact per-theme role palette. The app theme and syntax theme stay separate;
the only coupling is the light/dark axis: a theme with both variants uses its
light palette on a light app and its dark palette on a dark app, while the code
frame (gutter, line numbers, background) keeps following the app theme. Default
is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match).

Move the live preview directly under the syntax-theme chooser, drop the
"preview.ts" filename header, and use a clearer code snippet for it.

* Update settings e2e for the moved theme picker and Appearance section

The theme picker moved from General to the new Appearance section, so the
General-content assertion no longer finds "Theme" — point it at "Default send"
(which stays in General) and add Appearance to the e2e section map plus an
expectAppearanceContent check, and exercise the Appearance section in the
sidebar-navigation test.

* Render the appearance preview as a unified diff

Switch the live preview from side-by-side columns to a single unified diff:
unchanged context lines plus "-"/"+" rows for the change, matching how diffs
read elsewhere in the app. Same snippet, mono font, syntax colors, and live
code-font draft behavior.

* Fix code font size change shrinking all UI text

applyAppearance scaled the live (already-patched) theme ramp instead of the
authored FONT_SIZE ramp, so it was not idempotent: every appearance change
re-scaled the current sizes, compounding the whole-UI font scale. With any
non-default UI size, changing the code size — or any setting — shrank (or grew)
all app text, and it persisted into the theme. Build the ramp from the canonical
FONT_SIZE ramp each apply (code size still set absolutely), so applies are
idempotent and a code-size change never touches the UI ramp.

* Apply the interface font across the app on web

react-native-web stamps a default font onto every text element, so the interface
font only reached the few components that set a font explicitly. Inject one rule
that points all text at a CSS variable (updated live as the setting changes),
with high specificity (1,2,0) so it deterministically beats RN-web's base font
and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and
monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded),
so they keep their code font.

Web-only: React Native has no global font cascade, so native still applies the UI
font only where components opt in. Inline `code` within chat prose is a known
minor gap (its render paths aren't tagged yet).

* Keep preview code in the code font

* Apply code font settings to terminals

* Fix settings host picker import after rebase

* Format terminal webview bundle
2026-05-31 16:09:01 +08:00
Mohamed Boudra
f26a81798b Make workspace tab switching faster (#1251)
* Avoid workspace shell renders when toggling sidebar

* Avoid rerendering inactive workspace screens

* Avoid rerendering unchanged sidebar rows

* Keep sidebar selection state in the list

* Speed up workspace tab switching
2026-05-31 15:46:17 +08:00
Mohamed Boudra
67133e3ebc Fix terminal resize after splitting panes (#1249) 2026-05-31 15:42:39 +08:00
Mohamed Boudra
d1f37b5d72 Route real provider tests through OpenRouter 2026-05-31 13:57:17 +07:00
Mohamed Boudra
8cf80c2486 Mark oversized generated diffs as too large
Gate tracked diff sections by content size before parsing and highlighting while preserving the existing batched git diff path.
2026-05-31 13:57:17 +07:00
Mohamed Boudra
3d96ea6354 Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
2026-05-31 05:38:29 +00:00
Mohamed Boudra
0e3eb9cf75 Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker

Settings had two implicit tiers: app-level sections as flat sidebar rows,
and per-host settings hidden behind a drill-in to one monolithic host page.
This flattens both into a single sidebar with an "App" group and a "Host"
group, where the host's settings are top-level rows scoped by a host picker.

- Split the monolithic HostPage into four section pages: Connections,
  Orchestration (was the unnamed inject-tools + system-prompt block),
  Providers, and Daemon (status, updates, restart, remove).
- Host picker reuses the canonical <Combobox> sidebar host switcher; defaults
  to the local host so a single-local-host user never thinks about "the daemon".
- Host sections become real sub-routes (/settings/hosts/[id]/[section]); the
  old /settings/hosts/[id] redirects to the connections section, so existing
  deep links (pairing, workspace, open-project) keep working.
- "Open providers" from the project screen now lands on the Providers section.

* Fix e2e host-settings navigation for the flat sidebar

The flat-settings refactor moved host settings onto separate section pages,
but three e2e helpers still expected the old monolithic host page:

- openDesktopSettings: the daemon-lifecycle card now lives on the Daemon
  section — navigate there before asserting it.
- acp catalog: "Add provider" now lives on the Providers section — open that
  section before opening the add-provider modal.
- openAddHostFlow: "Add host" is now an item inside the host picker (Combobox),
  not a standalone button — open the picker first.

Test-only; no product changes. Fixes the 8 playwright failures on this branch.
2026-05-31 12:55:08 +08:00
Mohamed Boudra
c8ca503cc3 Show agent terminals created in workspace subdirectories (#1245)
Terminals were bucketed and looked up by exact cwd, so a terminal an
agent opened in a subdirectory of the workspace never appeared in the
workspace panel and its live updates were dropped — the panel only ever
queried and subscribed the exact root.

getTerminals(root) now aggregates every bucket at or below the root, and
the session controller delivers a subdirectory change to each subscribed
root above it, keyed by that root and carrying the full aggregated list.
Keying by the root with the complete set is what keeps the client's
cache replacement from dropping the terminals that live at the root.
2026-05-31 12:29:05 +08:00
430 changed files with 22808 additions and 5402 deletions

View File

@@ -1,5 +1,57 @@
# Changelog
## 0.1.89 - 2026-06-02
### Added
- **Open workspace services through public service proxy links** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger))
- **Choose where new worktrees are created** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger))
- **Desktop windows reopen at the same size and position** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn))
- **Delegated agents can run independently and send recurring heartbeat updates**
### Improved
- Composer controls fit better in narrow panes
- Fork pull request badges stay visible in worktrees
- Cline in the ACP catalog is updated to v3
### Fixed
- Archiving a worktree finishes even if teardown hits an error ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger))
- iOS chat messages render bold, italics, strikethrough, and line breaks correctly ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting))
- Right-edge split pane resizing no longer clips ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn))
- Pi extension command output no longer hangs
- Delegated agents no longer appear in workspace alert counts
## 0.1.88 - 2026-06-01
### Added
- **Choose an app theme from the new Appearance settings**
- **Set a custom interface font**
- **Set a custom code font**
- **Adjust the interface text size**
- **Adjust the code text size**
- **Choose a syntax highlighting theme**
- **Keep cron schedules aligned to a chosen time zone** ([#1232](https://github.com/getpaseo/paseo/pull/1232) by [@damselem](https://github.com/damselem))
### Improved
- Settings now has a flatter sidebar with a host picker
- Workspace tab switching is faster
- Compact composers now show context usage as a percentage
- Agent terminals opened in workspace subdirectories now appear with the rest of the workspace terminals
- macOS displays can idle normally while the desktop app is open ([#1242](https://github.com/getpaseo/paseo/pull/1242) by [@fireblue](https://github.com/fireblue))
- Large generated diffs now show a clear too-large placeholder instead of trying to render the whole file
### Fixed
- Chat history catches up correctly around long-running tool updates
- Terminal panes keep the right size after splitting or resizing panes
- Restored terminal snapshots reflow correctly after the pane size changes
- Workspace scripts menus keep the right size after launching a service
- iOS chat messages no longer hide inline links, URLs, or linked file paths ([#1257](https://github.com/getpaseo/paseo/pull/1257) by [@outofrange-consulting](https://github.com/outofrange-consulting))
## 0.1.87 - 2026-05-30
### Added

View File

@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |

View File

@@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
## Relationships
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
@@ -54,6 +54,12 @@ Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-onl
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
## Workspace activity
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
## The subagents track
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`):
@@ -77,12 +83,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway
## Limitations
### Detached agents are cascade-archived
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
@@ -99,11 +99,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |
| --------------------------------- | ------------- | ------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| Field | Type | Meaning |
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `id` | `string` | Stable identifier |
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.

View File

@@ -183,6 +183,8 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
- 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
### Compatibility rules

View File

@@ -30,7 +30,7 @@ $PASEO_HOME/
└── push-tokens.json # Expo push notification tokens
```
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
---
@@ -147,6 +147,9 @@ Single file, validated with `PersistedConfigSchema`.
app: {
baseUrl: string
},
worktrees?: {
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { apiKey: string },
local: { modelsDir: string }
@@ -184,7 +187,7 @@ Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-
**Path:** `$PASEO_HOME/schedules/{id}.json`
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
@@ -206,7 +209,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
### Nested: ScheduleCadence (discriminated union on `type`)
- `{ type: "every", everyMs: number }` — interval in milliseconds
- `{ type: "cron", expression: string }` — cron expression
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
### Nested: ScheduleTarget (discriminated union on `type`)

View File

@@ -43,6 +43,70 @@ In any worktree-style or portless setup, never assume default ports.
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
### React render profiling
The app has a gated React render profiler in
`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want
to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When
the query param is absent, `RenderProfile` returns children directly and records
nothing.
Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call
`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the
interaction you want to measure. If a memo comparator or subscription boundary
needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling;
reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`.
Use this workflow for any render investigation:
1. Add stable `RenderProfile` boundaries around the suspected root and expensive
children. Keep IDs specific enough to compare before and after.
2. Reproduce against real app state, not toy fixtures, whenever practical.
3. Record an idle baseline first. If idle is noisy, fix or account for that
before optimizing the interaction.
4. Warm up the route, reset profiler samples, run the exact interaction, then
compare `actualDuration`, render counts, and per-commit samples.
5. When a memo boundary still renders, record reasons before changing code. Do
not guess from object identity alone.
6. Keep changes that move the measured profile. Remove probes or memo wrappers
that do not move the number.
What this caught during the workspace tab investigation:
- A large apparent workspace cost was real interaction work, not daemon noise;
the idle baseline stayed near zero.
- The expensive stream rerender was mostly prop identity churn from pane context
callbacks and capability objects, not new stream data.
- Stabilizing provider actions at the pane boundary helped because every mounted
panel consumes that context.
- Comparing value-shaped capability flags beat preserving object identity through
unrelated stores.
- Some plausible fixes did not pay off: memoizing the tab row and composer draft
object barely moved the profile, so they were removed.
Existing scenario script: workspace agent/terminal tab switching. Start Expo on
web, keep a daemon available, then run:
```bash
PASEO_PROFILE_SERVER_ID=<server-id> \
PASEO_PROFILE_WORKSPACE_ID=<workspace-path> \
PASEO_PROFILE_AGENT_ID=<agent-id> \
npm run profile:workspace-tabs --workspace=@getpaseo/app
```
This script opens the app with `?renderProfile=1`, creates a temporary terminal
tab, switches between a real agent and that terminal, prints aggregated React
Profiler timings, then removes the temporary terminal. It is an example of the
workflow above, not the only way to use the profiler. Useful knobs:
```bash
PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL
PASEO_PROFILE_SWITCH_COUNT=1 # number of agent/terminal switch pairs
PASEO_PROFILE_SWITCH_WAIT_MS=250 # delay after each click
PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
```
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
@@ -59,6 +123,12 @@ GPU process so Chromium rebuilds the display link. The probe is skipped while
the screen is locked or the window is hidden or minimized, since a window
legitimately stops producing frames then.
The watchdog deliberately leaves background throttling **enabled**. Calling
`webContents.setBackgroundThrottling(false)` would keep the compositor producing
frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -88,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
| Variable | Value |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
@@ -132,6 +204,23 @@ For tighter loops, you can rebuild a single workspace:
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
- Changed app build dependencies: `npm run build:app-deps`.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
and `uvx`) to exact package versions. Run the drift checker regularly — and
before releases — so catalog installs do not sit on stale agent versions:
```bash
npm run acp:version-drift # report stale/non-exact package pins
npm run acp:version-drift:check # same, exits non-zero on drift
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
```
The checker updates only package-runner catalog entries. Providers that use a
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
are reported as skipped because their versions are owned by the user's local
install.
## CLI reference
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.

View File

@@ -18,7 +18,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/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.

View File

@@ -30,6 +30,8 @@ OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add`
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.
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
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.
---

View File

@@ -9,6 +9,9 @@ A release has exactly two steps. The agent does the first, the user authorizes t
**Preparation** (local, reversible — agent does this):
- format, lint, typecheck all green
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
if stale package-runner pins are intentional, say so explicitly, otherwise run
`npm run acp:version-drift:update` and commit the updated catalog
- draft the changelog, show it to the user, wait for review
- run the pre-release sanity check, surface findings to the user
- confirm CI is green
@@ -191,6 +194,9 @@ cd packages/app
# Recent builds (newest first). Pipe to jq for status only.
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
# Recent EAS workflow runs. This is the source of truth for submit/review jobs.
npx eas workflow:runs --json | jq '.[] | {status, workflowName, trigger, gitCommitHash, startedAt, finishedAt}'
# Filter by platform.
npx eas build:list --platform ios --limit 5 --non-interactive --json
npx eas build:list --platform android --limit 5 --non-interactive --json
@@ -198,35 +204,52 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
# Inspect a specific build.
npx eas build:view <build-id>
# Inspect the full release workflow, including submit_ios, submit_android,
# and submit_ios_for_review.
npx eas workflow:view <workflow-run-id> --json
# Read failed submit/review job logs.
npx eas workflow:logs <workflow-job-id> --all-steps --non-interactive
# Stream logs for a build.
npx eas build:view <build-id> --json | jq '.logFiles[]'
```
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW``IN_QUEUE``IN_PROGRESS``FINISHED` (or `ERRORED`/`CANCELED`).
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW``IN_QUEUE``IN_PROGRESS``FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
For the `Release Mobile` EAS workflow, these jobs must pass:
- `build_ios` — iOS binary built
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
### Babysitting mobile after a release
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
Pattern:
```jsonc
// mcp__paseo__create_schedule arguments
// mcp__paseo__create_heartbeat arguments
{
"name": "vX.Y.Z release babysit heartbeat",
"every": "15m",
"cron": "*/15 * * * *",
"maxRuns": 8, // covers ~2h of build + store-submission window
"target": "self", // heartbeat, NOT "new-agent"
"cwd": "/path/to/paseo",
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
}
```
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build that errors at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as everything is green so the remaining runs do not generate noise.
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
## Release notes on GitHub
@@ -300,8 +323,8 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
## Changelog ownership
- **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
## Changelog voice
@@ -380,19 +403,15 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting a **stable** release, run a Codex review of the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
Review the diff between the latest release tag and `HEAD`. Focus on:
> Review the diff between the latest release tag and HEAD. Focus on:
>
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
> 3. **Regressions** — anything that looks like it could break existing functionality.
>
> Diff: `git diff <latest-release-tag>..HEAD`
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
3. **Regressions** — anything that looks like it could break existing functionality.
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
## Changelog scope
@@ -417,5 +436,9 @@ The changelog covers **stable-to-stable**. Betas are not represented. When you p
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store
- [ ] EAS `Release Mobile` workflow for the same tag is green
- [ ] EAS iOS `build_ios` completes for the same tag
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
- [ ] EAS Android `build_android` completes for the same tag
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track

92
docs/service-proxy.md Normal file
View File

@@ -0,0 +1,92 @@
# Service Proxy
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
The generated hostname is built from the script name, branch, and project:
```
<script>--<branch>--<project>.localhost
```
If the branch is `main` or `master`, the branch segment is omitted:
```
<script>--<project>.localhost
```
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
```
dev--feature-auth--miniweb.localhost
```
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
## Configuration
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
```json
{
"version": 1,
"daemon": {
"serviceProxy": {
"listen": "0.0.0.0:8080",
"publicBaseUrl": "https://paseoapps.my.domain.com"
}
}
}
```
| Field | Required | Description |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
## DNS and reverse proxy setup
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
1. Configure a wildcard DNS record:
```
*.paseoapps.my.domain.com → 10.1.1.1
```
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
Nginx example:
```nginx
server {
listen 443 ssl;
server_name *.paseoapps.my.domain.com;
location / {
proxy_pass http://10.1.1.1:8080;
proxy_set_header Host $host;
}
}
```
## Environment variables
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------- |
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |

View File

@@ -2,8 +2,8 @@
Agent chat delivery has two paths:
1. **Live stream**`agent_stream` WebSocket messages for immediacy.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness.
1. **Live stream**`agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history**`fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The invariant is:
@@ -24,6 +24,8 @@ Heartbeat is used for notification routing. It must not be used as a correctness
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.

View File

@@ -289,6 +289,21 @@ That brief transition is expected with the current storage model. It makes track
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## Runtime Theme Patching For User Preferences
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
Gotchas:
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:

View File

@@ -1 +1 @@
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
sha256-RKIUil3Ev65LbxSqsgzkD4APgAOCXBnIRWz++jEq91k=

112
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -2813,6 +2813,50 @@
"integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==",
"license": "MIT OR Apache-2.0"
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -7874,6 +7918,12 @@
"node": ">=18"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -16979,6 +17029,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -34033,6 +34089,12 @@
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -36027,6 +36089,12 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@@ -36885,7 +36953,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -37110,12 +37178,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/server": "0.1.87",
"@getpaseo/client": "0.1.90-beta.1",
"@getpaseo/protocol": "0.1.90-beta.1",
"@getpaseo/server": "0.1.90-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37361,10 +37429,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@getpaseo/protocol": "0.1.90-beta.1",
"@getpaseo/relay": "0.1.90-beta.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -37384,7 +37452,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37636,7 +37704,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37672,8 +37740,10 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
"@lezer/css": "^1.3.0",
@@ -37901,7 +37971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37922,7 +37992,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38140,14 +38210,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.87",
"@getpaseo/highlight": "0.1.87",
"@getpaseo/protocol": "0.1.87",
"@getpaseo/relay": "0.1.87",
"@getpaseo/client": "0.1.90-beta.1",
"@getpaseo/highlight": "0.1.90-beta.1",
"@getpaseo/protocol": "0.1.90-beta.1",
"@getpaseo/relay": "0.1.90-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38919,7 +38989,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -36,7 +36,8 @@
"scripts": {
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server": "npm run build:server-deps && npm run dev:server:watch",
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
@@ -44,12 +45,19 @@
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
@@ -62,6 +70,9 @@
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"knip": "knip",
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
@@ -73,7 +84,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
@@ -87,7 +98,7 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",

View File

@@ -6,6 +6,7 @@ import {
installAcpCatalogProvider,
openAddProviderModal,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
@@ -18,6 +19,8 @@ test.describe("ACP provider catalog", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);

View File

@@ -0,0 +1,28 @@
import { test } from "./fixtures";
import {
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});

View File

@@ -1,8 +1,9 @@
import { readFileSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost } from "./settings";
import { openSettingsHost, openSettingsHostSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -64,6 +65,21 @@ export interface DesktopBridgeConfig {
* false so tests that only assert copy don't inadvertently trigger state changes.
*/
confirmShouldAccept?: boolean;
editorTargets?: DesktopEditorTargetConfig[];
editorRecordPath?: string;
}
interface DesktopEditorTargetConfig {
id: string;
label: string;
kind: "editor" | "file-manager";
}
interface DesktopEditorOpenRecord {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface ConfirmDialogCall {
@@ -74,6 +90,7 @@ export interface ConfirmDialogCall {
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
}
}
@@ -87,6 +104,15 @@ declare global {
* can assert dialog copy without depending on window.confirm concatenation.
*/
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
if (config.editorRecordPath) {
await page.exposeFunction(
"__recordDesktopEditorOpen",
async (input: DesktopEditorOpenRecord) => {
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
},
);
}
await page.addInitScript((cfg) => {
// Mutable state shared across IPC calls within this page
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
@@ -108,7 +134,19 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
const desktopBridge: {
platform: string;
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
dialog: {
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
getPendingOpenProject: () => Promise<string | null>;
events: { on: () => Promise<() => void> };
editor?: {
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
};
} = {
platform: "darwin",
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === "check_app_update") {
@@ -202,12 +240,26 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
if (cfg.editorTargets) {
desktopBridge.editor = {
listTargets: async () => cfg.editorTargets ?? [],
openTarget: async (input: DesktopEditorOpenRecord) => {
await window.__recordDesktopEditorOpen?.(input);
},
};
}
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
}, config);
}
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Host section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "host");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});

View File

@@ -0,0 +1,61 @@
import { expect, type Page } from "@playwright/test";
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
}
export async function expectCurrentQuestion(
page: Page,
input: { index: number; total: number; question: string },
): Promise<void> {
const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
await expect(
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toHaveAttribute("aria-selected", "true");
}
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: option })
.click();
}
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
}
export async function openQuestion(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
}
export async function fillQuestionAnswer(
page: Page,
input: { question: string; answer: string },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("textbox", { name: input.question })
.fill(input.answer);
}
export async function submitQuestionAnswers(page: Page): Promise<void> {
await page.getByTestId("question-form-primary-action").click();
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
}

View File

@@ -320,12 +320,12 @@ async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefin
const client = handle.client as SeedDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
limit: 0,
});
return timeline.epoch;

View File

@@ -1,9 +1,11 @@
import { expect, type Page } from "@playwright/test";
import { TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
@@ -13,6 +15,8 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
@@ -28,8 +32,22 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
await page.getByTestId("settings-host-section-connections").click();
await expectHostSettingsUrl(page, serverId);
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function openSettingsHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -37,6 +55,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
}
export async function openAddHostFlow(page: Page): Promise<void> {
// "Add host" is now an item inside the host picker (a Combobox); open the
// picker first, then pick it. The picker renders whenever a host exists.
await page.getByTestId("settings-host-picker").click();
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
}
@@ -68,7 +89,7 @@ export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
@@ -105,7 +126,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
);
}
@@ -160,7 +181,11 @@ export async function expectAboutContent(page: Page): Promise<void> {
}
export async function expectGeneralContent(page: Page): Promise<void> {
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
}
export async function expectAppearanceContent(page: Page): Promise<void> {
await expect(page.getByText("Highlight theme", { exact: true }).first()).toBeVisible();
}
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
@@ -181,7 +206,11 @@ export async function expectHostLabelEditMode(page: Page, expectedLabel: string)
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
// "Connections" appears three times on this page: the sidebar section row, the
// detail header title, and the SettingsSection heading above the card. Match
// the first to keep the heading assertion without tripping Playwright strict
// mode.
await expect(page.getByText("Connections", { exact: true }).first()).toBeVisible();
await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
@@ -193,14 +222,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
export async function expectHostActionCards(page: Page): Promise<void> {
export async function openHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await openSettingsHostSection(page, serverId, section);
}
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
// Restart + remove cards live on the Host section; providers moved to its
// own Providers section (asserted via expectHostProvidersCard).
await openSettingsHostSection(page, serverId, "host");
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
}
export async function expectHostProvidersCard(page: Page, serverId: string): Promise<void> {
await openSettingsHostSection(page, serverId, "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
}
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
await page.route(url, async (route) => {
await route.fulfill({
@@ -244,27 +288,36 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// App group rows remain top-level.
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
}
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
// open) that surfaces the local host by its label. The "Local" marker only
// appears on dropdown rows in the multi-host case, which this fixture does not
// exercise.
const picker = sidebar.getByTestId("settings-host-picker");
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
}

View File

@@ -0,0 +1,102 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
interface LongTimelineAgentOptions {
turns: number;
}
interface LongTimelineAgent extends MockAgentWorkspace {
oldestPrompt: string;
newestPrompt: string;
}
const PROMPT_PREFIX = "timeline-pagination-turn";
function promptForTurn(index: number): string {
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
}
export async function seedLongMockAgentTimeline(
options: LongTimelineAgentOptions,
): Promise<LongTimelineAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-pagination-",
title: "Timeline pagination regression",
model: "ten-second-stream",
});
for (let index = 0; index < options.turns; index += 1) {
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return {
...agent,
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
};
}
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
}
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
await page.mouse.wheel(0, -20_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const previousHeight = await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await scrollTimelineToOldestLoadedEdge(page);
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
}),
)
.toBeGreaterThan(previousHeight);
await scrollTimelineToOldestLoadedEdge(page);
}

View File

@@ -0,0 +1,73 @@
import { test } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
chooseQuestionOption,
expectCurrentQuestion,
expectQuestionHidden,
expectQuestionOptionSelected,
fillQuestionAnswer,
openQuestion,
submitQuestionAnswers,
waitForQuestionPrompt,
} from "./helpers/questions";
const TOTAL_QUESTIONS = 3;
const SURFACE_QUESTION = "Which surface should this apply to?";
const ROLLOUT_QUESTION = "Which rollout should we use?";
const SUCCESS_QUESTION = "What success criteria should we use?";
test.describe("Question prompt pagination", () => {
test("shows one question at a time with numbered navigation", async ({ page }) => {
test.setTimeout(180_000);
const session = await seedMockAgentWorkspace({
repoPrefix: "question-pagination-",
title: "Question pagination e2e",
initialPrompt: "Emit synthetic questions.",
});
try {
await openAgentRoute(page, session);
await waitForQuestionPrompt(page, 120_000);
await expectCurrentQuestion(page, {
index: 1,
total: TOTAL_QUESTIONS,
question: SURFACE_QUESTION,
});
await expectQuestionHidden(page, ROLLOUT_QUESTION);
await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App");
await expectCurrentQuestion(page, {
index: 2,
total: TOTAL_QUESTIONS,
question: ROLLOUT_QUESTION,
});
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
await expectCurrentQuestion(page, {
index: 1,
total: TOTAL_QUESTIONS,
question: SURFACE_QUESTION,
});
await expectQuestionOptionSelected(page, "App");
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
await chooseQuestionOption(page, "Behind feature flag");
await expectCurrentQuestion(page, {
index: 3,
total: TOTAL_QUESTIONS,
question: SUCCESS_QUESTION,
});
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Only one prompt is visible at a time.",
});
await submitQuestionAnswers(page);
} finally {
await session.cleanup();
}
});
});

View File

@@ -6,12 +6,14 @@ import { getServerId } from "./helpers/server-id";
import {
expectSettingsHeader,
openSettingsHost,
openHostSection,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
@@ -19,9 +21,7 @@ import {
} from "./helpers/settings";
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
test("connections section shows the seeded connection endpoint", async ({ page }) => {
const serverId = getServerId();
const port = getE2EDaemonPort();
@@ -29,11 +29,44 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openSettingsHost(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectSettingsHeader(page, "Connections");
await expectHostConnectionsCard(page, port);
});
test("agents section shows the inject MCP toggle", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "agents");
await expectSettingsHeader(page, "Agents");
await expectHostInjectMcpCard(page);
await expectHostActionCards(page);
});
test("providers section shows the providers card", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Host");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
@@ -42,13 +75,14 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getServerId();
@@ -56,19 +90,20 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
const serverId = getServerId();
@@ -77,9 +112,10 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page);
await expectHostActionCards(page, serverId);
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {

View File

@@ -25,6 +25,7 @@ import {
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
expectAppearanceContent,
} from "./helpers/settings";
test.describe("Settings sidebar navigation", () => {
@@ -43,9 +44,13 @@ test.describe("Settings sidebar navigation", () => {
await openSettingsSection(page, "general");
await expectSettingsHeader(page, "General");
await expectGeneralContent(page);
await openSettingsSection(page, "appearance");
await expectSettingsHeader(page, "Appearance");
await expectAppearanceContent(page);
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
});
@@ -132,7 +137,9 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsBackButton(page);
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
page,
}) => {
await gotoAppShell(page);
await openCompactSettings(page);

View File

@@ -0,0 +1,119 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
interface TerminalSize {
rows: number | null;
cols: number | null;
}
// The xterm/client view resizes immediately on split, so it is not enough to
// prove the bug. It is the misleading symptom.
async function readXtermSize(page: Page): Promise<TerminalSize> {
return page.evaluate(() => {
const term = (window as Window & { __paseoTerminal?: { rows?: number; cols?: number } })
.__paseoTerminal;
return {
rows: typeof term?.rows === "number" ? term.rows : null,
cols: typeof term?.cols === "number" ? term.cols : null,
};
});
}
// The PTY's own view of its size, reported by an `stty size` loop running in the
// shell. This needs no focus or click, so it observes whether the daemon-side PTY
// actually received the resize frame after the split.
async function readLatestPtySize(page: Page): Promise<TerminalSize | null> {
const text = await getTerminalBufferText(page);
const matches = [...text.matchAll(/PTYSIZE (\d+) (\d+)/g)];
const last = matches.at(-1);
if (!last) {
return null;
}
return { rows: Number(last[1]), cols: Number(last[2]) };
}
function hasPtySizeReport(text: string): boolean {
return /PTYSIZE \d+ \d+/.test(text);
}
async function readXtermRows(page: Page): Promise<number | null> {
return (await readXtermSize(page)).rows;
}
async function ptyRowsMatchXtermRows(page: Page): Promise<boolean> {
const xterm = await readXtermSize(page);
const pty = await readLatestPtySize(page);
return pty?.rows === xterm.rows;
}
async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness): Promise<void> {
await page.setViewportSize({ width: 1280, height: 900 });
await withTerminalInApp(page, harness, { name: "split-resize" }, async () => {
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
// Continuously echo the PTY's own size. `stty size` prints "rows cols" and
// reads the controlling tty, so this keeps reporting the real PTY size
// without the test ever clicking the terminal back into focus.
await terminal.pressSequentially(
'while true; do echo "PTYSIZE $(stty size)"; sleep 0.3; done\n',
{ delay: 0 },
);
await waitForTerminalContent(page, hasPtySizeReport, 10_000);
const beforeXterm = await readXtermSize(page);
const beforePty = await readLatestPtySize(page);
expect(beforePty, "the PTY should report its size before splitting").not.toBeNull();
expect(beforeXterm.rows, "xterm should report its row count before splitting").not.toBeNull();
expect(beforePty?.rows, "while focused, the PTY size should already match the xterm size").toBe(
beforeXterm.rows,
);
// Split the pane downward. This focuses the new empty pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await page.getByRole("button", { name: "Split pane down" }).first().click();
// The local xterm renderer shrinks immediately on split - the part of the
// behaviour that already works and that makes the bug look like nothing changed.
await expect
.poll(() => readXtermRows(page), {
message: "xterm should shrink after splitting the pane down",
timeout: 8_000,
})
.toBeLessThan(beforeXterm.rows ?? Number.POSITIVE_INFINITY);
// The PTY must follow the shrunken terminal, even though focus moved to the
// new pane. Today it stays stuck at the pre-split size until the terminal is
// clicked back into focus. This poll fails without the fix.
await expect
.poll(() => ptyRowsMatchXtermRows(page), {
message: "the PTY rows should match the resized terminal after split-down",
timeout: 8_000,
})
.toBe(true);
});
}
test.describe("Terminal split resize", () => {
test.describe.configure({ timeout: 120_000 });
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-split-resize-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("splitting the pane down resizes the PTY even though focus moves to the new pane", async ({
page,
}) => {
test.setTimeout(90_000);
await verifySplitDownResizesPty(page, harness);
});
});

View File

@@ -5,8 +5,10 @@ import { injectDesktopBridge } from "./helpers/desktop-updates";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
interface EditorOpenRecord {
command: string;
args: string[];
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
function requireE2EEnv(name: string): string {
@@ -43,8 +45,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void>
async function expectEditorOpened(input: {
recordPath: string;
command: string;
workspacePath: string;
editorId: string;
path: string;
afterCount: number;
}): Promise<void> {
await expect
@@ -53,10 +55,7 @@ async function expectEditorOpened(input: {
const records = await readEditorOpenRecords(input.recordPath);
return records
.slice(input.afterCount)
.some(
(record) =>
record.command === input.command && record.args.includes(input.workspacePath),
);
.some((record) => record.editorId === input.editorId && record.path === input.path);
},
{ timeout: 30_000 },
)
@@ -73,7 +72,14 @@ test.describe("Workspace open in editor", () => {
const serverId = requireE2EEnv("E2E_SERVER_ID");
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
await rm(recordPath, { force: true });
await injectDesktopBridge(page, { serverId });
await injectDesktopBridge(page, {
serverId,
editorTargets: [
{ id: "cursor", label: "Cursor", kind: "editor" },
{ id: "vscode", label: "VS Code", kind: "editor" },
],
editorRecordPath: recordPath,
});
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
await workspace.navigateTo();
@@ -81,8 +87,8 @@ test.describe("Workspace open in editor", () => {
await chooseEditorTarget(page, "vscode");
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: 0,
});
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
@@ -94,8 +100,8 @@ test.describe("Workspace open in editor", () => {
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: recordsAfterSelection,
});
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
@@ -105,8 +111,8 @@ test.describe("Workspace open in editor", () => {
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
editorId: "vscode",
path: workspace.repoPath,
afterCount: recordsAfterReturnOpen,
});
});

View File

@@ -0,0 +1,80 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
startWorkspaceScriptFromMenu,
waitForWorkspaceSetupProgress,
} from "./helpers/workspace-setup";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
test("scripts menu resizes when a service row grows after launch", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("script-menu-resize-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo setup complete'"],
},
scripts: {
web: {
type: "service",
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `script-menu-resize-${Date.now()}`,
});
await completed;
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await openWorkspaceScriptsMenu(page);
const menu = page.getByTestId("workspace-scripts-menu");
const before = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
height: rect.height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
};
});
await startWorkspaceScriptFromMenu(page, "web");
await expect(menu).toContainText("localhost:", { timeout: 15_000 });
const after = await menu.evaluate((element) => {
const rect = element.getBoundingClientRect();
const firstChild = element.firstElementChild;
const childRect = firstChild?.getBoundingClientRect();
return {
height: rect.height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
childHeight: childRect?.height ?? 0,
};
});
expect(after.height).toBeGreaterThan(before.height);
expect(after.scrollHeight).toBeLessThanOrEqual(after.clientHeight + 1);
expect(after.childHeight).toBeGreaterThan(before.height);
} finally {
await client.close();
await repo.cleanup();
}
});

View File

@@ -74,23 +74,6 @@ platform :ios do
build_number = valid_build.version
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
# Ensure an editable App Store version exists for this marketing version.
# deliver requires a "Prepare for Submission" version on App Store Connect
# and won't auto-create one when skip_metadata + skip_screenshots are set.
app.ensure_version!(app_version, platform: Spaceship::ConnectAPI::Platform::IOS)
# Apple requires a non-empty "What's New" on every new App Store version
# before it can be submitted for review. With skip_metadata: true, deliver
# never sets this, so we populate it directly per-locale.
edit_version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
release_notes = "Bug fixes and improvements."
edit_version.get_app_store_version_localizations.each do |localization|
if localization.whats_new.to_s.strip.empty?
UI.message("Setting whatsNew on #{localization.locale}")
localization.update(attributes: { whatsNew: release_notes })
end
end
deliver(
api_key: api_key,
app_identifier: APP_IDENTIFIER,
@@ -98,10 +81,13 @@ platform :ios do
build_number: build_number.to_s,
submit_for_review: true,
automatic_release: true,
reject_if_possible: true,
release_notes: {
"en-US" => "Bug fixes and improvements.",
},
force: true,
skip_binary_upload: true,
skip_screenshots: true,
skip_metadata: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,

View File

@@ -1,10 +1,10 @@
{
"name": "@getpaseo/app",
"version": "0.1.87",
"version": "0.1.90-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
"start": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run start:expo\"",
"start": "npm run start:expo",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
@@ -26,6 +26,7 @@
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},

View File

@@ -17,14 +17,18 @@
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
/* These styles make the body full-height */
/* Keep the app shell fixed to the viewport. */
html,
body {
width: 100%;
height: 100%;
}
/* These styles disable body scrolling if you are using <ScrollView> */
body {
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
}
/* These styles smooth text rendering in the app shell. */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -38,8 +42,12 @@
/* These styles make the root element full-height */
#root {
display: flex;
width: 100%;
height: 100%;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
</style>
<style>

View File

@@ -0,0 +1,172 @@
import { execFileSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010";
const serverId = requiredEnv("PASEO_PROFILE_SERVER_ID");
const workspaceId = requiredEnv("PASEO_PROFILE_WORKSPACE_ID");
const agentId = requiredEnv("PASEO_PROFILE_AGENT_ID");
const switchCount = Number(process.env.PASEO_PROFILE_SWITCH_COUNT ?? 6);
const switchWaitMs = Number(process.env.PASEO_PROFILE_SWITCH_WAIT_MS ?? 250);
const idleWaitMs = Number(process.env.PASEO_PROFILE_IDLE_WAIT_MS ?? 0);
const dumpCommits = process.env.PASEO_PROFILE_DUMP_COMMITS === "1";
function requiredEnv(name) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function createTerminal() {
const output = execFileSync(
"npm",
[
"run",
"cli",
"--",
"terminal",
"create",
"--cwd",
workspaceId,
"--name",
"Tab Switch Profile",
"--json",
],
{ cwd: repoRoot, encoding: "utf8" },
);
const match = output.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error(`Could not parse terminal create output: ${output}`);
}
return JSON.parse(match[0]).id;
}
function killTerminal(terminalId) {
execFileSync("npm", ["run", "cli", "--", "terminal", "kill", terminalId], {
cwd: repoRoot,
encoding: "utf8",
});
}
const createdTerminalId = process.env.PASEO_PROFILE_TERMINAL_ID ? null : createTerminal();
const terminalId = process.env.PASEO_PROFILE_TERMINAL_ID ?? createdTerminalId;
const workspaceSegment = `b64_${Buffer.from(workspaceId, "utf8").toString("base64url")}`;
const workspaceUrl = `${baseUrl}/h/${serverId}/workspace/${workspaceSegment}`;
function tabTestId(kind, id) {
return `workspace-tab-${kind}_${id}`;
}
function summarize(samples) {
const byId = new Map();
for (const sample of samples) {
const current = byId.get(sample.id) ?? {
renders: 0,
actualDuration: 0,
baseDuration: 0,
};
current.renders += 1;
current.actualDuration += sample.actualDuration;
current.baseDuration += sample.baseDuration;
byId.set(sample.id, current);
}
return [...byId.entries()]
.map(([id, value]) => Object.assign({ id }, value))
.sort((left, right) => right.actualDuration - left.actualDuration);
}
function summarizeCommits(samples) {
const byCommit = new Map();
for (const sample of samples) {
const key = String(sample.commitTime);
const current = byCommit.get(key) ?? {
commitTime: sample.commitTime,
totalActualDuration: 0,
samples: [],
};
current.totalActualDuration += sample.actualDuration;
current.samples.push({
id: sample.id,
actualDuration: sample.actualDuration,
baseDuration: sample.baseDuration,
});
byCommit.set(key, current);
}
return [...byCommit.values()].sort((left, right) => left.commitTime - right.commitTime);
}
async function openIntent(page, value) {
const url = `${workspaceUrl}?renderProfile=1&open=${encodeURIComponent(value)}`;
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.getByTestId("workspace-tabs-row").waitFor({ timeout: 60_000 });
}
async function clickTab(page, testId) {
await page.getByTestId(testId).click();
await page.waitForTimeout(switchWaitMs);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
try {
await openIntent(page, `agent:${agentId}`);
await openIntent(page, `terminal:${terminalId}`);
const agentTab = tabTestId("agent", agentId);
const terminalTab = tabTestId("terminal", terminalId);
await page.getByTestId(agentTab).waitFor({ timeout: 60_000 });
await page.getByTestId(terminalTab).waitFor({ timeout: 60_000 });
await clickTab(page, agentTab);
await clickTab(page, terminalTab);
await page.waitForTimeout(500);
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
if (idleWaitMs > 0) {
await page.waitForTimeout(idleWaitMs);
}
for (let index = 0; index < switchCount; index += 1) {
await clickTab(page, agentTab);
await clickTab(page, terminalTab);
}
const samples = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__ ?? []);
const reasons = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {});
console.log(
JSON.stringify(
{
appUrl: baseUrl,
serverId,
workspaceId,
agentId,
terminalId,
switchCount,
switchWaitMs,
idleWaitMs,
samples: samples.length,
summary: summarize(samples),
reasons,
commits: dumpCommits ? summarizeCommits(samples) : undefined,
},
null,
2,
),
);
} finally {
await browser.close();
if (createdTerminalId) {
killTerminal(createdTerminalId);
}
}
}
await main();

View File

@@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
@@ -77,6 +78,7 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -214,6 +216,18 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
"supportsStreaming",
"supportsSessionPersistence",
"supportsDynamicModes",
"supportsMcpServers",
"supportsReasoningStream",
"supportsToolInvocations",
"supportsRewindConversation",
"supportsRewindFiles",
"supportsRewindBoth",
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
@@ -745,7 +759,68 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
);
export const AgentStreamView = memo(AgentStreamViewComponent);
function agentCapabilityFlagsEqual(
left: AgentCapabilityFlags | undefined,
right: AgentCapabilityFlags | undefined,
): boolean {
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
}
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
if (left.id !== right.id) reasons.push("agent.id");
if (left.status !== right.status) reasons.push("agent.status");
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
reasons.push("agent.capabilities");
}
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
return reasons;
}
function bottomAnchorRouteRequestsEqual(
left: BottomAnchorRouteRequest | null | undefined,
right: BottomAnchorRouteRequest | null | undefined,
): boolean {
return (
left?.agentId === right?.agentId &&
left?.reason === right?.reason &&
left?.requestKey === right?.requestKey
);
}
function agentStreamViewPropsEqual(
left: AgentStreamViewProps,
right: AgentStreamViewProps,
): boolean {
const reasons: string[] = [];
if (left.agentId !== right.agentId) reasons.push("agentId");
if (left.serverId !== right.serverId) reasons.push("serverId");
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
) {
reasons.push("routeBottomAnchorRequest");
}
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
return reasons.length === 0;
}
export const AgentStreamView = memo(AgentStreamViewComponent, agentStreamViewPropsEqual);
AgentStreamView.displayName = "AgentStreamView";
interface ToolCallSlotProps extends Omit<

View File

@@ -73,6 +73,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
@@ -625,6 +626,27 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
}
}, [settingsLoading, settings.theme]);
// Apply font / size / syntax appearance settings on mount and when they change.
// Sibling to the theme effect above; order is irrelevant because both patch all
// six registered theme keys, so the active key is always current.
useEffect(() => {
if (settingsLoading) return;
applyAppearance({
uiFontFamily: settings.uiFontFamily,
monoFontFamily: settings.monoFontFamily,
uiFontSize: settings.uiFontSize,
codeFontSize: settings.codeFontSize,
syntaxTheme: settings.syntaxTheme,
});
}, [
settingsLoading,
settings.uiFontFamily,
settings.monoFontFamily,
settings.uiFontSize,
settings.codeFontSize,
settings.syntaxTheme,
]);
return (
<VoiceProvider>
<DesktopWindowControlsSync enabled={!settingsLoading} />
@@ -867,7 +889,8 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="settings/hosts/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>
);
}

View File

@@ -1,16 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -0,0 +1,19 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeHostSectionSlug } from "@/utils/host-routes";
export default function SettingsHostSectionRoute() {
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
const section = normalizeHostSectionSlug(rawSection) ?? "connections";
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -0,0 +1,13 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildSettingsHostSectionRoute, buildSettingsRoute } from "@/utils/host-routes";
export default function SettingsHostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
if (!serverId) {
return <Redirect href={buildSettingsRoute()} />;
}
return <Redirect href={buildSettingsHostSectionRoute(serverId, "connections")} />;
}

View File

@@ -9,9 +9,11 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useStableEvent } from "@/hooks/use-stable-event";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { useFileLink } from "./use-file-link";
@@ -19,10 +21,16 @@ import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
}
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
export function AssistantMarkdownLink({
source,
style,
monoSurface,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
@@ -50,11 +58,21 @@ export function AssistantMarkdownLink({ source, style, children }: AssistantMark
);
if (isNative) {
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
// inside the paragraph's native UITextView, and a plain <Text> nested there
// is not hoisted into a UITextViewChild, so its text is silently dropped
// (the link disappears). The span composes correctly and stays selectable;
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
return (
<FileLinkHoverTooltip filePath={tooltipPath}>
<Text accessibilityRole="link" onPress={onPress} style={style}>
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
{children}
</Text>
</MarkdownTextSpan>
</FileLinkHoverTooltip>
);
}
@@ -72,7 +90,9 @@ export function AssistantMarkdownLink({ source, style, children }: AssistantMark
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text style={hoveredTextStyle}>{children}</Text>
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={hoveredTextStyle}>
{children}
</Text>
</Pressable>
</a>
);
@@ -100,7 +120,7 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink source={source} style={style}>
<AssistantMarkdownLink source={source} style={style} monoSurface>
{children}
</AssistantMarkdownLink>
);

View File

@@ -324,7 +324,7 @@ export function AgentList({
const handleAgentLongPress = useCallback(
(agent: AggregatedAgent) => {
const isRunning = agent.status === "running" || agent.status === "initializing";
const isRunning = agent.status === "running";
if (isRunning) {
setActionAgent(agent);
return;

View File

@@ -0,0 +1,66 @@
import { Fragment, type ReactNode } from "react";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
interface AppearanceStyleBoundaryProps {
appearanceKey?: string;
children: ReactNode;
}
function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) {
return <Fragment key={appearanceKey}>{children}</Fragment>;
}
const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBoundaryProps> => ({
appearanceKey: [
theme.fontFamily.ui,
theme.fontFamily.mono,
theme.fontSize.xs,
theme.fontSize.sm,
theme.fontSize.base,
theme.fontSize.lg,
theme.fontSize.xl,
theme.fontSize["2xl"],
theme.fontSize["3xl"],
theme.fontSize["4xl"],
theme.fontSize.code,
theme.lineHeight.diff,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.colors.mutedForeground,
theme.colors.surface1,
theme.colors.surface2,
theme.colors.border,
theme.colors.accentBright,
theme.colors.syntax.keyword,
theme.colors.syntax.comment,
theme.colors.syntax.string,
theme.colors.syntax.number,
theme.colors.syntax.literal,
theme.colors.syntax.function,
theme.colors.syntax.definition,
theme.colors.syntax.class,
theme.colors.syntax.type,
theme.colors.syntax.tag,
theme.colors.syntax.attribute,
theme.colors.syntax.property,
theme.colors.syntax.variable,
theme.colors.syntax.operator,
theme.colors.syntax.punctuation,
theme.colors.syntax.regexp,
theme.colors.syntax.escape,
theme.colors.syntax.meta,
theme.colors.syntax.heading,
theme.colors.syntax.link,
].join("\u0000"),
});
const ThemedAppearanceStyleBoundary = withUnistyles(AppearanceStyleBoundaryBase);
export function AppearanceStyleBoundary({ children }: { children: ReactNode }) {
return (
<ThemedAppearanceStyleBoundary uniProps={appearanceStyleBoundaryMapping}>
{children}
</ThemedAppearanceStyleBoundary>
);
}

View File

@@ -7,13 +7,19 @@ interface ContextWindowMeterProps {
maxTokens: number;
usedTokens: number;
totalCostUsd?: number | null;
showPercentage?: boolean;
}
const SVG_SIZE = 16;
const COMPACT_SVG_SIZE = 14;
const CENTER = SVG_SIZE / 2;
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
const RADIUS = 7;
const COMPACT_RADIUS = 6;
const STROKE_WIDTH = 2.25;
const COMPACT_STROKE_WIDTH = 2;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
function isValidMaxTokens(value: number): boolean {
return Number.isFinite(value) && value > 0;
@@ -72,6 +78,7 @@ export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
@@ -82,47 +89,56 @@ export function ContextWindowMeter({
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
const center = showPercentage ? COMPACT_CENTER : CENTER;
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={styles.container}
style={containerStyle}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
>
<Svg
width={SVG_SIZE}
height={SVG_SIZE}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.track}
strokeWidth={STROKE_WIDTH}
strokeWidth={strokeWidth}
/>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.progress}
strokeWidth={STROKE_WIDTH}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</Svg>
{showPercentage ? (
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
) : null}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -149,9 +165,22 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
svg: {
transform: [{ rotate: "-90deg" }],
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tooltipContent: {
gap: theme.spacing[1],
},

View File

@@ -2,13 +2,13 @@ import React from "react";
import { View, Text, ScrollView as RNScrollView } from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { diffLinePrefix } from "@/utils/diff-highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { isWeb } from "@/constants/platform";
const ScrollView = isWeb ? RNScrollView : GHScrollView;
@@ -165,7 +165,7 @@ export function DiffViewer({
}
const lines = (
<View style={linesContainerStyle}>
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
@@ -226,7 +226,7 @@ const styles = StyleSheet.create((theme) => {
paddingVertical: theme.spacing[1],
},
lineText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
...(isWeb

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, type ReactElement } from "react";
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -43,7 +43,7 @@ interface SortableItemProps<T> {
useDragHandle: boolean;
}
function SortableItem<T>({
function SortableItemInner<T>({
id,
item,
index,
@@ -118,6 +118,8 @@ function SortableItem<T>({
);
}
const SortableItem = memo(SortableItemInner) as typeof SortableItemInner;
export function DraggableList<T>({
data,
keyExtractor,

View File

@@ -14,7 +14,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
@@ -1157,7 +1156,7 @@ const styles = StyleSheet.create((theme) => ({
},
codeText: {
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
flexShrink: 0,
},

View File

@@ -1,17 +1,27 @@
import React, { useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import Markdown, {
type ASTNode,
MarkdownIt,
type RenderRules,
} from "react-native-markdown-display";
import {
ActivityIndicator,
Image as RNImage,
Linking,
ScrollView as RNScrollView,
Text,
type TextProps,
type TextStyle,
View,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -19,9 +29,11 @@ import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
@@ -46,6 +58,8 @@ interface FilePreviewBodyProps {
imagePreviewUri: string | null;
}
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -116,6 +130,295 @@ function clampLineSelection(input: {
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: TextStyle;
monoSurface?: boolean;
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
children: ReactNode;
}
function MarkdownInheritedText({
inheritedStyles,
textStyle,
style: overrideStyle,
monoSurface,
onPress,
accessibilityRole,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return (
<MarkdownTextSpan
monoSurface={monoSurface}
onPress={onPress}
accessibilityRole={accessibilityRole}
style={style}
>
{children}
</MarkdownTextSpan>
);
}
interface MarkdownListItemContentProps {
contentStyle: ViewStyle;
children: ReactNode;
}
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
const EMPTY_TEXT_STYLE: TextStyle = {};
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
return <View style={style}>{children}</View>;
}
interface MarkdownListViewProps {
baseStyle: ViewStyle;
spacing: { marginTop: number; marginBottom: number };
children: ReactNode;
}
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
return <View style={style}>{children}</View>;
}
interface FilePreviewMarkdownLinkProps {
href: string;
inheritedStyles: TextStyle;
linkStyle: TextStyle;
onLinkPress?: (url: string) => boolean;
children: ReactNode;
}
function FilePreviewMarkdownLink({
href,
inheritedStyles,
linkStyle,
onLinkPress,
children,
}: FilePreviewMarkdownLinkProps) {
const handlePress = useCallback(() => {
if (!href) return;
if (onLinkPress?.(href) === false) return;
void Linking.openURL(href);
}, [href, onLinkPress]);
return (
<MarkdownInheritedText
inheritedStyles={inheritedStyles}
textStyle={linkStyle}
accessibilityRole="link"
onPress={handlePress}
>
{children}
</MarkdownInheritedText>
);
}
function getMarkdownLinkHref(node: ASTNode): string {
const href = node.attributes?.href;
return typeof href === "string" ? href : "";
}
function createFilePreviewMarkdownRules(): RenderRules {
return {
text: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.text}
>
{node.content}
</MarkdownInheritedText>
),
textgroup: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.textgroup}
>
{children}
</MarkdownInheritedText>
),
strong: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.strong}
>
{children}
</MarkdownInheritedText>
),
em: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
{children}
</MarkdownInheritedText>
),
s: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
{children}
</MarkdownInheritedText>
),
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<HighlightedCodeBlock
key={node.key}
code={node.content}
language={null}
inheritedStyles={inheritedStyles}
textStyle={styles.code_block}
/>
),
fence: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<HighlightedCodeBlock
key={node.key}
code={node.content}
language={node.sourceInfo}
inheritedStyles={inheritedStyles}
textStyle={styles.fence}
/>
),
code_inline: (
node: ASTNode,
_children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
monoSurface
>
{node.content ?? ""}
</MarkdownInheritedText>
),
bullet_list: (
node: ASTNode,
children: ReactNode[],
parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownListView
key={node.key}
baseStyle={styles.bullet_list}
spacing={getMarkdownListSpacing(node, parent)}
>
{children}
</MarkdownListView>
),
ordered_list: (
node: ASTNode,
children: ReactNode[],
parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownListView
key={node.key}
baseStyle={styles.ordered_list}
spacing={getMarkdownListSpacing(node, parent)}
>
{children}
</MarkdownListView>
),
list_item: (
node: ASTNode,
children: ReactNode[],
parent: ASTNode[],
styles: MarkdownStyles,
) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
</View>
);
},
paragraph: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
) => (
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
{children}
</MarkdownParagraphView>
),
link: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
onLinkPress?: (url: string) => boolean,
) => (
<FilePreviewMarkdownLink
key={node.key}
href={getMarkdownLinkHref(node)}
inheritedStyles={EMPTY_TEXT_STYLE}
linkStyle={styles.link}
onLinkPress={onLinkPress}
>
{children}
</FilePreviewMarkdownLink>
),
};
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
@@ -172,14 +475,14 @@ const codeLineStyles = StyleSheet.create((theme) => ({
},
gutterText: {
color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
flex: 1,
@@ -198,6 +501,7 @@ function FilePreviewBody({
const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []);
const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
@@ -280,9 +584,11 @@ function FilePreviewBody({
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
<AppearanceStyleBoundary>
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</AppearanceStyleBoundary>
</RNScrollView>
{scrollbar.overlay}
</View>
@@ -296,7 +602,7 @@ function FilePreviewBody({
lineNumber: index + 1,
}));
const codeLines = (
<View>
<View dataSet={CODE_SURFACE_DATASET}>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}

View File

@@ -8,6 +8,7 @@ import type { HighlightToken } from "@getpaseo/highlight";
import { isNative, isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
interface HighlightedCodeBlockProps {
@@ -75,6 +76,7 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
return (
<View
style={containerStyle}
dataSet={CODE_SURFACE_DATASET}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>

View File

@@ -1,8 +1,8 @@
import React from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { isWeb } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
@@ -56,7 +56,7 @@ const GutteredLine = React.memo(function GutteredLine({
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
if (startLine === undefined) {
return (
<View>
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map((line) => (
<ContentLine key={line.key} line={line} />
))}
@@ -67,7 +67,7 @@ export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
const lastLineNumber = startLine + lines.length - 1;
const digits = Math.max(2, String(lastLineNumber).length);
return (
<View>
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map((line, index) => (
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
))}
@@ -81,7 +81,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: CODE_LINE_HEIGHT,
},
gutterText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: CODE_LINE_HEIGHT,
color: theme.colors.foregroundMuted,
@@ -90,7 +90,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
lineText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: CODE_LINE_HEIGHT,

View File

@@ -1,11 +1,7 @@
import { SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image, type ImageSourcePropType } from "react-native";
import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@getpaseo/protocol/messages";
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
interface EditorAppIconProps {
editorId: EditorTargetId;
@@ -14,7 +10,7 @@ interface EditorAppIconProps {
}
/* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
@@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
};
/* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
}
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
if (!hasBundledEditorAppIcon(editorId)) {
const source = EDITOR_APP_IMAGES[editorId];
if (!source) {
return <SquareTerminal size={size} color={color} />;
}
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
return <Image source={source} style={imageStyle} resizeMode="contain" />;
}

View File

@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
import { FolderPlus, Home, MessagesSquare, Search, Settings, X } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -34,6 +34,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -47,6 +48,8 @@ import {
type SidebarProjectEntry,
useSidebarWorkspacesList,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
@@ -89,6 +92,7 @@ interface SidebarSharedProps {
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
groupMode: SidebarGroupMode;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
@@ -192,6 +196,10 @@ export const LeftSidebar = memo(function LeftSidebar({
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects, isInitialLoad });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
);
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
@@ -268,6 +276,7 @@ export const LeftSidebar = memo(function LeftSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -417,6 +426,21 @@ function AddProjectTooltipContent({
);
}
function HeaderIconTooltipContent({
label,
shortcutKeys,
}: {
label: string;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
);
}
function SidebarFooter({
theme,
activeServerId,
@@ -518,6 +542,7 @@ function MobileSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -698,13 +723,16 @@ function MobileSidebar({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<Pressable
style={styles.mobileCloseButton}
onPress={closeToAgent}
@@ -733,6 +761,7 @@ function MobileSidebar({
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -777,6 +806,7 @@ function DesktopSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -860,14 +890,17 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -877,6 +910,7 @@ function DesktopSidebar({
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -911,6 +945,61 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton,
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
],
[],
);
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open command center"
testID="sidebar-command-center-search"
style={searchButtonStyle}
onPress={handleSearchPress}
>
{({ hovered, pressed }) => (
<Search
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarGroupingSelector serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
@@ -932,6 +1021,39 @@ const staticStyles = RNStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
sidebarHeaderRow: {
position: "relative",
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2] + theme.spacing[3],
paddingRight: theme.spacing[4],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workspacesHeaderIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
workspacesHeaderIconButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
sidebarContent: {
flex: 1,
minHeight: 0,

View File

@@ -1,17 +1,33 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
// Android's <Text selectable> enables per-text-node selection natively. Each
// sibling Text is its own selection scope — drag can't span across siblings
// (that requires a single UITextView ancestor and is iOS-only).
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
// (that requires a single UITextView ancestor and is iOS-only). onPress works
// natively here, so links routed through this span stay tappable on Android.
export function MarkdownTextSpan({
style,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
return (
<Text selectable style={style}>
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
{children}
</Text>
);

View File

@@ -1,19 +1,39 @@
import { useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import type { StyleProp, TextProps, TextStyle, ViewStyle } from "react-native";
import { UITextView } from "react-native-uitextview";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
// Links route through this span too (see assistant-file-links/link.tsx). A
// plain <Text> nested in the paragraph UITextView is dropped, so the link
// must be a UITextView span to be visible. onPress is forwarded best-effort:
// react-native-uitextview nulls onPress on the root native view, so reliable
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
// invisible link.
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
// Inline span backed by UITextView so iOS gets native word-selection handles.
// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the
// library's TextAncestorContext hoists these into UITextViewChild nodes so
// selection drags can cross sibling spans (e.g. plain text → **bold** → code).
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
export function MarkdownTextSpan({
style,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
return (
<UITextView uiTextView selectable style={style}>
<UITextView
uiTextView
selectable
style={style}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
{children}
</UITextView>
);

View File

@@ -1,9 +1,23 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
// Web links use the <a>/Pressable path in link.tsx, not this span, so these
// are accepted for prop-shape parity with the native variants and forwarded
// harmlessly.
onPress?: TextProps["onPress"];
accessibilityRole?: TextProps["accessibilityRole"];
}
// react-native-web renders Text as <span>/<div> with `user-select: text`
@@ -11,8 +25,23 @@ interface MarkdownTextSpanProps {
// react-native-uitextview: its transitive import of codegenNativeComponent
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web
// target in dev mode.
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
return <Text style={style}>{children}</Text>;
export function MarkdownTextSpan({
style,
monoSurface,
children,
onPress,
accessibilityRole,
}: MarkdownTextSpanProps) {
return (
<Text
dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined}
style={style}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
{children}
</Text>
);
}
interface MarkdownParagraphViewProps {

View File

@@ -11,6 +11,7 @@ import {
type TextStyle,
} from "react-native";
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import * as React from "react";
import {
useState,
@@ -62,7 +63,7 @@ import Animated, {
} from "react-native-reanimated";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -1496,16 +1497,18 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress,
}: MemoizedMarkdownBlockProps) {
return (
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
<AppearanceStyleBoundary>
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</AppearanceStyleBoundary>
);
});
@@ -1513,6 +1516,7 @@ interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
}
@@ -1520,13 +1524,18 @@ function MarkdownInheritedText({
inheritedStyles,
textStyle,
style: overrideStyle,
monoSurface,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
return (
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
{children}
</MarkdownTextSpan>
);
}
interface MarkdownListItemContentProps {
@@ -1613,6 +1622,67 @@ export const AssistantMessage = memo(function AssistantMessage({
{children}
</MarkdownInheritedText>
),
// strong/em/s have no custom rule in react-native-markdown-display's
// defaults beyond wrapping children in a plain RN <Text>. On iOS the
// paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx),
// and a plain <Text> nested inside one is not hoisted into a
// UITextViewChild, so its content renders invisibly. Route these inline
// marks through MarkdownTextSpan (same path as text/textgroup) so the
// styled content composes and stays visible + selectable on iOS.
strong: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.strong}
>
{children}
</MarkdownInheritedText>
),
em: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.em}
>
{children}
</MarkdownInheritedText>
),
s: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownStyles,
inheritedStyles: TextStyle = {},
) => (
<MarkdownInheritedText
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.s}
>
{children}
</MarkdownInheritedText>
),
// hardbreak/softbreak fall back to react-native-markdown-display's
// default, a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
// Android keep the same "\n" they rendered before.
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
code_block: (
node: ASTNode,
_children: ReactNode[],
@@ -1696,6 +1766,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
monoSurface
>
{content}
</MarkdownInheritedText>
@@ -1860,13 +1931,13 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[2],
},
headerLabel: {
fontFamily: Fonts.sans,
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
text: {
fontFamily: Fonts.sans,
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.base,
lineHeight: 22,
color: theme.colors.foreground,
@@ -1976,7 +2047,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
metadataText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.code,
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
lineHeight: 16,
},
}));
@@ -2072,7 +2143,7 @@ export const ActivityLog = memo(function ActivityLog({
</View>
</View>
{isExpanded && metadata && (
<View style={activityLogStylesheet.metadataContainer}>
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
<Text style={activityLogStylesheet.metadataText}>
{JSON.stringify(metadata, null, 2)}
</Text>
@@ -2108,7 +2179,7 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
text: {
fontFamily: Fonts.sans,
fontFamily: theme.fontFamily.ui,
fontSize: 13,
color: theme.colors.foregroundMuted,
},

View File

@@ -17,7 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
@@ -52,7 +52,12 @@ function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
<Text
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
>
{model.id}
</Text>
{model.description ? (
@@ -89,7 +94,12 @@ function CustomModelRow({
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
<Text
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
>
{model.id}
</Text>
<View style={sheetStyles.modelRowFiller} />
@@ -307,7 +317,7 @@ function DiagnosticSubSheet({
body = (
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
<ScrollView horizontal showsHorizontalScrollIndicator>
<Text style={sheetStyles.codeText} selectable>
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
{diagnostic}
</Text>
</ScrollView>
@@ -607,7 +617,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
},
monoHint: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
flexShrink: 0,
@@ -716,7 +726,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
},
codeText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,

View File

@@ -9,7 +9,7 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
import { Check, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
@@ -82,9 +82,18 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
return (
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-selected={isSelected}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
@@ -102,8 +111,73 @@ function QuestionOptionRow({
);
}
interface QuestionNavButtonProps {
index: number;
total: number;
isActive: boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
function QuestionNavButton({
index,
total,
isActive,
isResponding,
onSelect,
}: QuestionNavButtonProps) {
const { theme } = useUnistyles();
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
const handlePress = useCallback(() => {
onSelect(index);
}, [index, onSelect]);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
return [
styles.questionNavButton,
{
backgroundColor:
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
},
pressed && styles.optionItemPressed,
];
},
[
isActive,
theme.colors.border,
theme.colors.foregroundMuted,
theme.colors.surface1,
theme.colors.surface2,
],
);
const textStyle = useMemo(
() => [
styles.questionNavText,
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
],
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
testID={`question-form-question-nav-${index + 1}`}
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
>
<Text style={textStyle}>{index + 1}</Text>
</Pressable>
);
}
interface QuestionOtherInputProps {
qIndex: number;
accessibilityLabel: string;
value: string;
placeholder: string;
isResponding: boolean;
@@ -113,6 +187,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
accessibilityLabel,
value,
placeholder,
isResponding,
@@ -149,6 +224,7 @@ function QuestionOtherInput({
<TextInput
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -163,15 +239,19 @@ function QuestionOtherInput({
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const questions = parseQuestionFormQuestions(permission.request.input);
const questions = useMemo(
() => parseQuestionFormQuestions(permission.request.input),
[permission.request.input],
);
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const toggleOption = useCallback(
(qIndex: number, optIndex: number, multiSelect: boolean) => {
const current = selections[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
@@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
} else {
next.add(optIndex);
}
} else if (next.has(optIndex)) {
next.clear();
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
next.clear();
next.add(optIndex);
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
setSelections((prev) => ({ ...prev, [qIndex]: next }));
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, questions, selections],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
@@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}, []);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
? Math.min(activeQuestionIndex, questions.length - 1)
: 0;
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
@@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
});
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
const handleSelectQuestion = useCallback((index: number) => {
setActiveQuestionIndex(index);
}, []);
const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
@@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const submitDisabled = !allAnswered || isResponding;
const submitButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
opacity: submitDisabled ? 0.5 : 1,
},
pressed && !submitDisabled ? styles.optionItemPressed : null,
],
[
submitDisabled,
theme.colors.surface2,
theme.colors.surface1,
theme.colors.border,
theme.colors.borderAccent,
],
[submitDisabled, theme.colors.accent],
);
const containerStyle = useMemo(
@@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const questionNavStyle = useMemo(
() => [styles.questionNav, isMobile && styles.questionNavMobile],
[isMobile],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
[isMobile],
@@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const submitActionTextColor = allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted;
const submitActionTextColor = theme.colors.accentForeground;
const submitActionTextStyle = useMemo(
() => [styles.actionText, { color: submitActionTextColor }],
[submitActionTextColor],
@@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
const dismissLabel = resolveDismissLabel(questions);
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
return (
<View style={containerStyle}>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
const showTextInput = questionShowsTextInput(q);
return (
<View key={q.question} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={questionTextStyle}>{q.question}</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
{q.options.length > 0 ? (
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
placeholder={getQuestionInputPlaceholder(q)}
<View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
onSelect={handleSelectQuestion}
/>
) : null}
</View>
);
})}
);
})}
</View>
</View>
{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={resolvedActiveQuestionIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={activeQuestion.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
) : null}
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder(activeQuestion)}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
) : null}
</View>
) : null}
<View style={actionsContainerStyle}>
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
<Pressable
style={dismissButtonStyle}
onPress={handleDeny}
disabled={isResponding}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
@@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
)}
</Pressable>
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
<Pressable
style={submitButtonStyle}
onPress={handleSubmit}
disabled={submitDisabled}
accessibilityRole="button"
accessibilityLabel="Submit"
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} />
@@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: {
gap: theme.spacing[2],
},
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
flex: 1,
},
questionText: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
lineHeight: 22,
},
optionsWrap: {
gap: theme.spacing[1],
},
questionNav: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
},
questionNavMobile: {
paddingRight: theme.spacing[1],
},
questionNavButton: {
minWidth: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
borderWidth: theme.borderWidth[1],
},
questionNavText: {
fontSize: theme.fontSize.xs,
fontWeight: "700",
},
optionItem: {
flexDirection: "row",
alignItems: "center",

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
describe("computeResizeHandleSizes", () => {
it("clamps right-edge drags to the adjacent pane minimum", () => {
const sizes = computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 1,
deltaRatio: 0.5,
});
expect(sizes[0]).toBe(0.25);
expect(sizes[1]).toBe(0.65);
expect(sizes[2]).toBeCloseTo(0.1, 10);
});
it("clamps left-edge drags to the adjacent pane minimum", () => {
const sizes = computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 1,
deltaRatio: -0.5,
});
expect(sizes[0]).toBe(0.25);
expect(sizes[1]).toBe(0.1);
expect(sizes[2]).toBeCloseTo(0.65, 10);
});
it("moves adjacent pane sizes without clamping", () => {
const sizes = computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 1,
deltaRatio: 0.05,
});
expect(sizes[0]).toBe(0.25);
expect(sizes[1]).toBe(0.55);
expect(sizes[2]).toBeCloseTo(0.2, 10);
});
it("splits tiny adjacent pairs evenly when the configured minimum cannot fit", () => {
expect(
computeResizeHandleSizes({
sizes: [0.45, 0.05, 0.05, 0.45],
index: 1,
deltaRatio: 0.05,
}),
).toEqual([0.45, 0.05, 0.05, 0.45]);
});
it("leaves sizes unchanged when the adjacent pair is invalid", () => {
expect(
computeResizeHandleSizes({
sizes: [0.25, 0.5, 0.25],
index: 3,
deltaRatio: 0.25,
}),
).toEqual([0.25, 0.5, 0.25]);
expect(
computeResizeHandleSizes({
sizes: [0.25, 0, 0, 0.75],
index: 1,
deltaRatio: 0.25,
}),
).toEqual([0.25, 0, 0, 0.75]);
});
});

View File

@@ -0,0 +1,36 @@
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
interface ComputeResizeHandleSizesInput {
sizes: number[];
index: number;
deltaRatio: number;
minSize?: number;
}
export function computeResizeHandleSizes({
sizes,
index,
deltaRatio,
minSize = MIN_SPLIT_SIZE,
}: ComputeResizeHandleSizesInput): number[] {
const nextSizes = sizes.slice();
const leftSize = sizes[index];
const rightSize = sizes[index + 1];
if (leftSize === undefined || rightSize === undefined) {
return nextSizes;
}
const pairSize = leftSize + rightSize;
if (pairSize <= 0) {
return nextSizes;
}
const adjacentMinSize = Math.min(minSize, pairSize / 2);
const nextLeftSize = Math.min(
pairSize - adjacentMinSize,
Math.max(adjacentMinSize, leftSize + deltaRatio),
);
nextSizes[index] = nextLeftSize;
nextSizes[index + 1] = pairSize - nextLeftSize;
return nextSizes;
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
export interface ResizeHandleProps {
direction: "horizontal" | "vertical";
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
interface PointerState {
containerSize: number;
pointerStart: number;
leftSize: number;
rightSize: number;
}
function resetWindowHorizontalScroll() {
// Clamp any browser scroll introduced while dragging past the viewport edge.
if (window.scrollX === 0) {
return;
}
window.scrollTo(0, window.scrollY);
}
export function ResizeHandle({
@@ -25,7 +32,8 @@ export function ResizeHandle({
onResizeSplit,
}: ResizeHandleProps) {
const { theme } = useUnistyles();
const pointerStateRef = useRef<PointerState | null>(null);
const pointerStatesRef = useRef(new Map<number, PointerState>());
const cursorBeforeDragRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [active, setActive] = useState(false);
const [dragging, setDragging] = useState(false);
@@ -34,7 +42,11 @@ export function ResizeHandle({
const handlePointerDown = useCallback(
(event: RNPointerEvent) => {
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
if (!hitAreaElement) {
return;
}
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
}
@@ -45,51 +57,83 @@ export function ResizeHandle({
return;
}
const pointerId = event.nativeEvent.pointerId;
if (pointerStatesRef.current.has(pointerId)) {
return;
}
setDragging(true);
pointerStateRef.current = {
pointerStatesRef.current.set(pointerId, {
containerSize,
pointerStart:
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
});
const previousCursor = document.body.style.cursor;
if (pointerStatesRef.current.size === 1) {
cursorBeforeDragRef.current = document.body.style.cursor;
}
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
document.body.style.cursor = nextCursor;
event.preventDefault();
event.stopPropagation();
const pointerCaptureElement = hitAreaElement;
pointerCaptureElement.setPointerCapture?.(pointerId);
resetWindowHorizontalScroll();
function cleanup() {
pointerStateRef.current = null;
setDragging(false);
document.body.style.cursor = previousCursor;
pointerStatesRef.current.delete(pointerId);
setDragging(pointerStatesRef.current.size > 0);
if (pointerStatesRef.current.size === 0) {
document.body.style.cursor = cursorBeforeDragRef.current ?? "";
cursorBeforeDragRef.current = null;
}
if (pointerCaptureElement.hasPointerCapture?.(pointerId)) {
pointerCaptureElement.releasePointerCapture(pointerId);
}
resetWindowHorizontalScroll();
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
}
function handlePointerMove(moveEvent: PointerEvent) {
const pointerState = pointerStateRef.current;
if (moveEvent.pointerId !== pointerId) {
return;
}
const pointerState = pointerStatesRef.current.get(pointerId);
if (!pointerState) {
return;
}
moveEvent.preventDefault();
resetWindowHorizontalScroll();
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
const deltaRatio =
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
const nextSizes = sizes.slice();
nextSizes[index] = pointerState.leftSize + deltaRatio;
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
onResizeSplit(groupId, nextSizes);
onResizeSplit(
groupId,
computeResizeHandleSizes({
sizes,
index,
deltaRatio,
}),
);
}
function handlePointerUp() {
function handlePointerUp(upEvent: PointerEvent) {
if (upEvent.pointerId !== pointerId) {
return;
}
cleanup();
}
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp, { once: true });
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
},
[direction, groupId, index, onResizeSplit, sizes],
);
@@ -130,6 +174,7 @@ export function ResizeHandle({
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
touchAction: "none",
} as object,
],
[direction],

View File

@@ -47,7 +47,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "canonical",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}

View File

@@ -90,6 +90,7 @@ function workspace(input: {
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
import { useCallback } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
{ value: "project", label: "Project" },
{ value: "status", label: "Status" },
];
export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const handleSelect = useCallback(
(mode: SidebarGroupMode) => {
if (!serverId) return;
setGroupMode(serverId, mode);
},
[serverId, setGroupMode],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
],
[],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Sidebar grouping"
testID="sidebar-grouping-selector"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<GroupModeMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
onSelect={handleSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function GroupModeMenuItem({
item,
isSelected,
onSelect,
}: {
item: { value: SidebarGroupMode; label: string };
isSelected: boolean;
onSelect: (mode: SidebarGroupMode) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`sidebar-grouping-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
{item.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
menuHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
menuHeaderLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -0,0 +1,941 @@
import { memo, useCallback, useMemo, useState } from "react";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
type StatusGroup,
} from "@/hooks/sidebar-status-view-model";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleAlert,
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
SquareTerminal,
Globe,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useSidebarWorkspaceEntry } from "../sidebar-workspace-list";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import { confirmDialog } from "@/utils/confirm-dialog";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
}
export function SidebarStatusWorkspaceList({
workspaces,
projectNamesByKey,
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
}: StatusWorkspaceListProps) {
const groups = useMemo(
() => buildStatusGroups(workspaces, projectNamesByKey),
[workspaces, projectNamesByKey],
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const statusShortcutIndex = useMemo(
() =>
showShortcutBadges
? buildStatusShortcutIndex(
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
)
: new Map<string, number>(),
[collapsedStatusGroupKeys, groups, showShortcutBadges],
);
return (
<View style={styles.container}>
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
</ScrollView>
)}
</View>
);
}
function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
}) {
return (
<>
{groups.map((group) => (
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View style={styles.statusWorkspaceListContainer}>
{group.rows.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
))}
</View>
) : null}
</View>
))}
</>
);
}
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
const [isHovered, setIsHovered] = useState(false);
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleStatusGroupCollapsed,
);
const handlePress = useCallback(() => {
toggleStatusGroupCollapsed(group.bucket);
}, [group.bucket, toggleStatusGroupCollapsed]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.statusGroupRow,
isHovered && styles.statusGroupRowHovered,
pressed && styles.statusGroupRowPressed,
],
[isHovered],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
<Pressable
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`${group.label} status group`}
accessibilityState={accessibilityState}
style={rowStyle}
onPress={handlePress}
testID={`sidebar-status-group-${group.bucket}`}
>
<View style={styles.statusGroupRowLeft}>
<View style={styles.statusGroupLeadingVisualSlot}>
<StatusGroupLeadingVisual
bucket={group.bucket}
collapsed={collapsed}
showChevron={isHovered}
/>
</View>
<View style={styles.statusGroupTitleGroup}>
<Text style={styles.statusGroupTitle} numberOfLines={1}>
{group.label}
</Text>
</View>
</View>
</Pressable>
</View>
);
}
function StatusGroupLeadingVisual({
bucket,
collapsed,
showChevron,
}: {
bucket: StatusGroup["bucket"];
collapsed: boolean;
showChevron: boolean;
}) {
if (!showChevron) {
return <StatusGroupIcon bucket={bucket} />;
}
if (collapsed) {
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
}
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert size={14} uniProps={amberColorMapping} />;
case "failed":
return <ThemedCircleX size={14} uniProps={redColorMapping} />;
case "attention":
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
case "running":
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
case "done":
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
}
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
serverId,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
serverId: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
const handlePress = useCallback(() => {
if (!serverId) return;
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
if (!hydratedWorkspace) return null;
return (
<StatusWorkspaceRowWithMenu
workspace={hydratedWorkspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={handlePress}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) return;
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
if (!confirmed) return;
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to archive worktree");
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) return;
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) return;
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error("Host is not connected");
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) {
throw new Error(payload.error?.message ?? "Failed to rename branch");
}
return { targetCwd };
},
onSuccess: async ({ targetCwd }) => {
await invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: workspace.serverId,
cwd: targetCwd,
});
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(slugify(value));
},
[renameMutation],
);
const validateRenameSlug = useCallback((value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? "Invalid branch name";
}, []);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
if (isWorktree) {
void archiveWorktreeAfterConfirmation();
} else {
void hideWorkspaceAfterConfirmation();
}
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = archiveStatus;
} else if (isArchivingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={computedArchiveStatus}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={workspace.projectKind === "git" ? handleOpenRename : undefined}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.name}
placeholder="branch-name"
submitLabel="Rename"
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<>
{showScriptsIcon ? (
<View accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
) : null}
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
},
list: {
flex: 1,
},
listContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
statusGroupBlock: {
marginBottom: theme.spacing[1],
},
statusWorkspaceListContainer: {},
statusGroupRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
userSelect: "none",
},
statusGroupRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
statusGroupRowPressed: {
backgroundColor: theme.colors.surface2,
},
statusGroupRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
statusGroupLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
statusGroupTitleGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
statusGroupTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));

View File

@@ -0,0 +1,511 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import {
ActivityIndicator,
Pressable,
Text,
View,
type GestureResponderEvent,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleAlert,
ExternalLink,
Folder,
FolderGit2,
GitPullRequest,
Monitor,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
export function SidebarWorkspaceRowFrame({
workspace,
isDragging = false,
children,
}: {
workspace: SidebarWorkspaceEntry;
isDragging?: boolean;
children: (input: {
isHovered: boolean;
hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void };
}) => ReactNode;
}) {
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const hoverHandlers = useMemo(
() => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }),
[handlePointerEnter, handlePointerLeave],
);
return (
<WorkspaceHoverCard workspace={workspace} prHint={workspace.prHint} isDragging={isDragging}>
{children({ isHovered, hoverHandlers })}
</WorkspaceHoverCard>
);
}
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
workspace,
subtitle,
isHovered,
isLoading,
isCreating = false,
shortcutNumber = null,
showShortcutBadge = false,
children,
}: {
workspace: SidebarWorkspaceEntry;
subtitle?: string | null;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
shortcutNumber?: number | null;
showShortcutBadge?: boolean;
children?: ReactNode;
}) {
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
],
[isHovered, isCreating],
);
return (
<View style={styles.workspaceRowContent}>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isLoading}
/>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadgeOverlay} pointerEvents="none">
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
</View>
) : null}
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
</View>
) : null}
</View>
);
});
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
loading = false,
}: {
bucket: SidebarWorkspaceEntry["statusBucket"];
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
offset,
}: {
dotColorStyle: ViewStyle;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, offset, size],
);
return <View style={overlayStyle} />;
}
function PrBadge({ hint }: { hint: PrHint }) {
const [isHovered, setIsHovered] = useState(false);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
const textStyle = useMemo(
() => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text),
[isHovered],
);
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const pressableStyle = useMemo(
() => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed],
[isHovered],
);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
#{hint.number}
</Text>
</Pressable>
);
}
function ChecksBadge({ checks }: { checks: PrHint["checks"] }) {
if (!checks || checks.length === 0) return null;
const failed = checks.filter((check) => check.status === "failure").length;
if (failed === 0) return null;
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
</View>
);
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
switch (bucket) {
case "needs_input":
return styles.statusDotNeedsInput;
case "failed":
return styles.statusDotFailed;
case "running":
return styles.statusDotRunning;
case "attention":
return styles.statusDotAttention;
case "done":
return null;
}
}
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
badgePressed: {
opacity: 0.82,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
},
textHovered: {
color: theme.colors.foreground,
},
}));
const checksBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.palette.red[500],
},
}));
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
rowRight: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
flexShrink: 0,
},
shortcutBadge: {
minWidth: 18,
height: 18,
paddingHorizontal: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
flexShrink: 0,
},
shortcutBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
hidden: { opacity: 0 },
trailingActionSlot: {
position: "relative",
minWidth: 18,
minHeight: 20,
flexShrink: 0,
alignItems: "flex-end",
justifyContent: "flex-start",
},
trailingActionOverlay: {
position: "absolute",
top: 0,
right: 0,
},
}));
export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
return (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{number}</Text>
</View>
);
}
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionBase({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
if (!children) return null;
return <View style={visible ? undefined : sidebarWorkspaceRowStyles.hidden}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionOverlay({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
if (!visible || !children) return null;
return <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>;
}
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
},
workspaceRowMain: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[2],
width: "100%",
},
workspaceRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
shortcutBadgeOverlay: {
position: "absolute",
top: 1,
right: 0,
},
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,
height: 16,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
statusDotOverlay: {
position: "absolute",
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
workspaceBranchTextHovered: {
opacity: 1,
},
workspaceSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 14,
marginLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadgeRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,
},
statusDotFailed: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.surface0,
},
statusDotRunning: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.surface0,
},
statusDotAttention: {
backgroundColor: theme.colors.palette.green[500],
borderColor: theme.colors.surface0,
},
}));

View File

@@ -67,6 +67,7 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -78,7 +79,6 @@ interface SplitContainerProps {
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
closingTabIds: Set<string>;
onNavigateTab: (tabId: string) => void;
@@ -215,14 +215,16 @@ const MountedTabSlot = memo(function MountedTabSlot({
}, [onFocusPane, paneId]);
return (
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
</RenderProfile>
);
});
@@ -355,7 +357,6 @@ export function SplitContainer({
isWorkspaceFocused,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -553,63 +554,64 @@ export function SplitContainer({
);
return (
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
<RenderProfile id="SplitContainer">
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
</RenderProfile>
);
}
@@ -711,7 +713,6 @@ function SplitNodeView({
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -764,7 +765,6 @@ function SplitNodeView({
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
@@ -810,7 +810,6 @@ function SplitNodeView({
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
@@ -862,7 +861,6 @@ function SplitPaneView({
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -995,66 +993,67 @@ function SplitPaneView({
);
return (
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
</View>
</RenderProfile>
);
}

View File

@@ -45,12 +45,15 @@ interface TerminalEmulatorProps {
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -80,6 +83,8 @@ type BridgeInboundMessage =
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
@@ -89,9 +94,10 @@ type BridgeInboundMessage =
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string }
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
@@ -105,7 +111,7 @@ type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number }
| { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
| {
type: "terminalKey";
streamKey: string;
@@ -159,6 +165,8 @@ function createMountMessage(input: {
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
@@ -168,6 +176,8 @@ function createMountMessage(input: {
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
fontFamily: input.fontFamily,
fontSize: input.fontSize,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
@@ -183,11 +193,14 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -216,6 +229,8 @@ export default function TerminalEmulator({
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
});
@@ -224,11 +239,14 @@ export default function TerminalEmulator({
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -241,6 +259,7 @@ export default function TerminalEmulator({
});
callbacksRef.current = {
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -389,6 +408,11 @@ export default function TerminalEmulator({
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
}, [fontFamily, fontSize, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
@@ -401,14 +425,14 @@ export default function TerminalEmulator({
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey });
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey });
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
@@ -495,7 +519,11 @@ export default function TerminalEmulator({
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols });
callbacksRef.current.onResize?.({
rows: message.rows,
cols: message.cols,
shouldClaim: message.shouldClaim !== false,
});
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
@@ -592,6 +620,7 @@ export default function TerminalEmulator({
return;
}
webViewRef.current?.requestFocus();
callbacksRef.current.onFocus?.();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);

View File

@@ -134,12 +134,15 @@ interface TerminalEmulatorProps {
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -214,11 +217,14 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -234,8 +240,12 @@ export default function TerminalEmulator({
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const fontFamilyRef = useRef(fontFamily);
const fontSizeRef = useRef(fontSize);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize;
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
@@ -484,6 +494,8 @@ export default function TerminalEmulator({
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current,
fontSize: fontSizeRef.current,
});
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
@@ -523,11 +535,15 @@ export default function TerminalEmulator({
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
runtimeRef.current?.setFont({ fontFamily, fontSize });
}, [fontFamily, fontSize]);
useEffect(() => {
if (focusRequestToken <= 0) {
return () => {};
}
runtimeRef.current?.resize({ force: true });
runtimeRef.current?.resize({ force: true, shouldClaim: true });
return focusWithRetries({
focus: () => {
runtimeRef.current?.focus();
@@ -547,7 +563,7 @@ export default function TerminalEmulator({
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true });
runtimeRef.current?.resize({ force: true, shouldClaim: true });
}, [resizeRequestToken]);
useEffect(() => {
@@ -742,8 +758,9 @@ export default function TerminalEmulator({
}, []);
const handleRootPointerDown = useCallback(() => {
onFocus?.();
runtimeRef.current?.focus();
}, []);
}, [onFocus]);
const handleRootContextMenu = useCallback(
(event: ReactMouseEvent) => {

View File

@@ -173,6 +173,10 @@ export function TerminalPane({
const { theme } = useUnistyles();
const { settings } = useAppSettings();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const terminalFontFamily = useMemo(() => {
const trimmed = settings.monoFontFamily.trim();
return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
@@ -214,6 +218,7 @@ export function TerminalPane({
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const lastPaneFocusResizeKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => {
@@ -252,7 +257,7 @@ export function TerminalPane({
);
useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
if (isMobile || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
@@ -263,15 +268,36 @@ export function TerminalPane({
}
lastAutoFocusKeyRef.current = nextFocusKey;
if (!isWorkspaceFocused) {
return;
}
requestTerminalFocus();
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
if (!isPaneFocused || !terminalId) {
lastPaneFocusResizeKeyRef.current = null;
return;
}
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
const focusResizeKey = `${scopeKey}:${terminalId}`;
if (lastPaneFocusResizeKeyRef.current === focusResizeKey) {
return;
}
lastPaneFocusResizeKeyRef.current = focusResizeKey;
if (!isWorkspaceFocused) {
return;
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
const handleTerminalFocus = useCallback(() => {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [requestTerminalReflow]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -308,25 +334,6 @@ export function TerminalPane({
[pulseKeyboardRefits],
);
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
// Focus transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to a terminal.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs),
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
useEffect(() => {
if (!client || !isConnected || !isWorkspaceFocused) {
return;
@@ -375,7 +382,7 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
getPreferredSize: () => lastSentTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
@@ -605,33 +612,35 @@ export function TerminalPane({
],
);
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) {
return;
}
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
});
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
return;
}
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
);
const handleTerminalKey = useCallback(
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
@@ -758,12 +767,15 @@ export function TerminalPane({
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData}
onFocus={handleTerminalFocus}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}

View File

@@ -8,13 +8,14 @@ import {
} from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
import { HighlightedLines } from "./highlighted-content";
import { DiffViewer } from "./diff-viewer";
@@ -179,7 +180,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
<Text style={styles.shellPrompt}>$ </Text>
{normalizedCommand}
@@ -224,7 +225,7 @@ function WorktreeSetupDetailSection({
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
@@ -383,7 +384,7 @@ function SubAgentDetailSection({
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
@@ -466,7 +467,7 @@ function ScrollableTextSection({
{keyedLines ? (
<HighlightedLines lines={keyedLines} startLine={startLine} />
) : (
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{content}
</Text>
)}
@@ -498,7 +499,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{result ? `${url}\n\n${result}` : url}
</Text>
</ScrollView>
@@ -542,7 +543,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.content}
</Text>
</ScrollView>
@@ -553,7 +554,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.filePaths && detail.filePaths.length > 0) {
out.push(
<View key="search-files" style={styles.section}>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.filePaths.join("\n")}
</Text>
</View>,
@@ -562,7 +563,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.webResults && detail.webResults.length > 0) {
out.push(
<View key="search-web-results" style={styles.section}>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
</Text>
</View>,
@@ -571,7 +572,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.annotations && detail.annotations.length > 0) {
out.push(
<View key="search-annotations" style={styles.section}>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{detail.annotations.join("\n\n")}
</Text>
</View>,
@@ -638,7 +639,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
{value}
</Text>
</ScrollView>
@@ -738,7 +739,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE}>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE} dataSet={CODE_SURFACE_DATASET}>
{errorText}
</Text>
</ScrollView>
@@ -756,7 +757,15 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
);
}
export function ToolCallDetailsContent({
export function ToolCallDetailsContent({ ...props }: ToolCallDetailsContentProps) {
return (
<AppearanceStyleBoundary>
<ToolCallDetailsContentInner {...props} />
</AppearanceStyleBoundary>
);
}
function ToolCallDetailsContentInner({
detail,
errorText,
maxHeight,
@@ -823,7 +832,7 @@ const styles = StyleSheet.create((theme) => {
padding: theme.spacing[3],
},
plainText: {
fontFamily: Fonts.sans,
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
@@ -876,7 +885,7 @@ const styles = StyleSheet.create((theme) => {
padding: insets.padding,
},
scrollText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
@@ -891,7 +900,7 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
},
subAgentSessionText: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
@@ -907,13 +916,13 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
},
subAgentActionTool: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
},
subAgentActionSummary: {
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,

View File

@@ -59,6 +59,7 @@ import {
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
const IS_WEB = isWeb;
@@ -1022,7 +1023,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={props.handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
keyboardBlurBehavior="none"
>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
@@ -1482,6 +1483,7 @@ export function Combobox({
);
useWebKeyboardListener(isOpen, handleDesktopKey);
useDismissKeyboardOnOpen(isOpen, isMobile);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),

View File

@@ -3,6 +3,7 @@ import {
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
@@ -30,6 +31,8 @@ import { Check, CheckCircle } from "lucide-react-native";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -178,10 +181,19 @@ function renderDropdownSurface(input: {
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
surfaceNativeID: string;
onExited: () => void;
}): ReactElement {
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
input;
const {
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited,
} = input;
const body = scrollable ? (
<FloatingScrollView
@@ -199,6 +211,7 @@ function renderDropdownSurface(input: {
return (
<FloatingSurface
collapsable={false}
nativeID={surfaceNativeID}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
@@ -232,6 +245,7 @@ export function DropdownMenu({
defaultOpen,
onOpenChange,
});
useDismissKeyboardOnOpen(isOpen);
const flushPendingSelect = useCallback(() => {
const pendingSelect = pendingSelectRef.current;
@@ -350,16 +364,58 @@ function getTransformOrigin(placement: Placement, alignment: Alignment): string
return `${vertical} ${horizontal}`;
}
const CONTENT_ENTERING_DURATION_MS = 150;
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(150);
}).duration(CONTENT_ENTERING_DURATION_MS);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
function useReleaseFixedMenuHeight({
contentSize,
enabled,
surfaceNativeID,
}: {
contentSize: Size | null;
enabled: boolean;
surfaceNativeID: string;
}): void {
useEffect(() => {
if (!enabled) return undefined;
// Reanimated web entering animations leave the measured menu surface with
// an inline height snapshot. Once the menu is open, height must return to
// content-sized so rows can grow in place (for example service script URLs).
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID]);
}
export function DropdownMenuContent({
children,
side = "bottom",
@@ -389,6 +445,7 @@ export function DropdownMenuContent({
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const surfaceNativeID = useId();
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
@@ -429,6 +486,12 @@ export function DropdownMenuContent({
}
}, [flushPendingSelect, modalVisible, open]);
useReleaseFixedMenuHeight({
contentSize,
enabled: modalVisible,
surfaceNativeID,
});
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
@@ -575,6 +638,7 @@ export function DropdownMenuContent({
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}

View File

@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { Keyboard } from "react-native";
export function useDismissKeyboardOnOpen(isOpen: boolean, enabled = true) {
useEffect(() => {
if (!enabled || !isOpen) return;
Keyboard.dismiss();
const frame = requestAnimationFrame(() => Keyboard.dismiss());
const timer = setTimeout(() => Keyboard.dismiss(), 150);
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
};
}, [enabled, isOpen]);
}

View File

@@ -10,6 +10,7 @@ const { theme } = vi.hoisted(() => ({
colors: {
surface3: "#333",
accent: "#0a84ff",
accentForeground: "#fff",
palette: { white: "#fff" },
},
},

View File

@@ -53,6 +53,11 @@ export function Switch({
}));
const thumbAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[theme.colors.palette.white, theme.colors.accentForeground],
),
transform: [{ translateX: progress.value * thumbTravel }],
}));
@@ -116,7 +121,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
thumb: {
backgroundColor: theme.colors.palette.white,
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,

View File

@@ -10,6 +10,7 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
@@ -20,18 +21,22 @@ function workspaceDescriptor(input: {
id: string;
name?: string;
projectId?: string;
projectDisplayName?: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: "Project 1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: "/repo/main",
workspaceDirectory: `/repo/main/${input.id}`,
projectKind: "git",
workspaceKind: "worktree",
name: input.name ?? input.id,
status: "done",
status: input.status ?? "done",
archivingAt: null,
statusEnteredAt: input.statusEnteredAt ?? null,
diffStat: null,
scripts: [],
};
@@ -56,6 +61,9 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
useSidebarViewStore.setState({
groupModeByServerId: {},
});
act(() => {
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
@@ -95,6 +103,72 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
]);
});
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("srv", "status");
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
[
"ws-done",
workspaceDescriptor({
id: "ws-done",
name: "Done",
projectId: "project-1",
projectDisplayName: "Project 1",
status: "done",
statusEnteredAt: new Date("2026-01-01T00:00:00.000Z"),
}),
],
[
"ws-running-old",
workspaceDescriptor({
id: "ws-running-old",
name: "Running old",
projectId: "project-2",
projectDisplayName: "Project 2",
status: "running",
statusEnteredAt: new Date("2026-02-01T00:00:00.000Z"),
}),
],
[
"ws-needs-input",
workspaceDescriptor({
id: "ws-needs-input",
name: "Needs input",
projectId: "project-1",
projectDisplayName: "Project 1",
status: "needs_input",
statusEnteredAt: new Date("2026-01-15T00:00:00.000Z"),
}),
],
[
"ws-running-new",
workspaceDescriptor({
id: "ws-running-new",
name: "Running new",
projectId: "project-2",
projectDisplayName: "Project 2",
status: "running",
statusEnteredAt: new Date("2026-03-01T00:00:00.000Z"),
}),
],
]),
);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "srv", workspaceId: "ws-needs-input" },
{ serverId: "srv", workspaceId: "ws-running-new" },
{ serverId: "srv", workspaceId: "ws-running-old" },
{ serverId: "srv", workspaceId: "ws-done" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);

View File

@@ -1,8 +1,16 @@
import { useEffect, useMemo } from "react";
import {
useHydratedWorkspaceEntries,
useProjectNamesMap,
} from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import {
buildSidebarShortcutModel,
buildStatusSidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
@@ -12,21 +20,42 @@ export function WorkspaceShortcutTargetsSubscriber({
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
const statusWorkspaces = useHydratedWorkspaceEntries(enabled ? serverId : null);
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets,
);
const shortcutModel = useMemo(
() =>
buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
}),
[collapsedProjectKeys, projects],
);
const shortcutModel = useMemo(() => {
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
projectNamesByKey,
collapsedStatusGroupKeys,
});
}
return buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
});
}, [
collapsedProjectKeys,
collapsedStatusGroupKeys,
groupMode,
projectNamesByKey,
projects,
statusWorkspaces,
]);
useEffect(() => {
if (!enabled || !serverId) {

View File

@@ -97,6 +97,7 @@ interface ControlledAgentControlsProps {
/** Extra elements rendered inline with the agent controls (desktop only). */
desktopExtras?: ReactNode;
modelSelectorServerId?: string | null;
isCompactLayout?: boolean;
}
export interface DraftAgentControlsProps {
@@ -124,12 +125,14 @@ export interface DraftAgentControlsProps {
isRetryingModelProvider?: boolean;
disabled?: boolean;
modelSelectorServerId?: string | null;
isCompactLayout?: boolean;
}
interface AgentControlsProps {
agentId: string;
serverId: string;
onDropdownClose?: () => void;
isCompactLayout?: boolean;
}
function findOptionLabel(
@@ -409,9 +412,11 @@ function ControlledAgentControls({
isRetryingModelProvider = false,
desktopExtras,
modelSelectorServerId = null,
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
@@ -1345,6 +1350,7 @@ export const AgentControls = memo(function AgentControls({
agentId,
serverId,
onDropdownClose,
isCompactLayout,
}: AgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
@@ -1518,8 +1524,15 @@ export const AgentControls = memo(function AgentControls({
);
const modeChip = useMemo(
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
[serverId, agentId],
() => (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="toolbar"
isCompactLayout={isCompactLayout}
/>
),
[serverId, agentId, isCompactLayout],
);
if (!agent) {
@@ -1548,6 +1561,7 @@ export const AgentControls = memo(function AgentControls({
disabled={!client}
desktopExtras={modeChip}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
);
});
@@ -1577,9 +1591,11 @@ export function DraftAgentControls({
isRetryingModelProvider = false,
disabled = false,
modelSelectorServerId = null,
isCompactLayout,
}: DraftAgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const mappedThinkingOptions = useMemo<AgentControlOption[]>(() => {
return toThinkingControlOptions(thinkingOptions);
@@ -1625,9 +1641,18 @@ export function DraftAgentControls({
selectedMode={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
isCompactLayout={isCompactLayout}
/>
),
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
[
selectedProvider,
providerDefinitions,
modeOptions,
selectedMode,
onSelectMode,
disabled,
isCompactLayout,
],
);
if (!isCompact) {
@@ -1661,6 +1686,7 @@ export function DraftAgentControls({
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
desktopExtras={draftModeChip}
isCompactLayout={isCompactLayout}
/>
) : null}
</View>
@@ -1688,6 +1714,7 @@ export function DraftAgentControls({
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
modelSelectorServerId={modelSelectorServerId}
isCompactLayout={isCompactLayout}
/>
);
}

View File

@@ -235,14 +235,17 @@ interface AgentModeControlProps {
serverId: string;
agentId: string;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export const AgentModeControl = memo(function AgentModeControl({
serverId,
agentId,
placement,
isCompactLayout,
}: AgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
@@ -303,6 +306,7 @@ export interface DraftAgentModeControlProps {
onSelectMode: (modeId: string) => void;
disabled?: boolean;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export function DraftAgentModeControl({
@@ -313,8 +317,10 @@ export function DraftAgentModeControl({
onSelectMode,
disabled,
placement,
isCompactLayout,
}: DraftAgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
if (!selectedProvider || modeOptions.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (

View File

@@ -4,6 +4,7 @@ import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
@@ -36,7 +37,11 @@ import {
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import {
COMPACT_FORM_FACTOR_WIDTH,
MAX_CONTENT_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
@@ -380,7 +385,11 @@ export function WorkspaceDraftAgentTab({
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompact = useIsCompactFormFactor();
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd: composerState.workingDir,
@@ -401,14 +410,14 @@ export function WorkspaceDraftAgentTab({
};
openFileExplorerForCheckout({
checkout,
isCompact,
isCompact: isCompactFormFactor,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
});
},
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
);
const {
@@ -625,10 +634,14 @@ export function WorkspaceDraftAgentTab({
);
const composerFooter = useMemo(
() =>
isCompact ? (
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
isCompactComposerLayout ? (
<DraftAgentModeControl
placement="footer"
{...composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
) : undefined,
[isCompact, composerAgentControls],
[isCompactComposerLayout, composerAgentControls],
);
return (
@@ -662,7 +675,7 @@ export function WorkspaceDraftAgentTab({
)}
</View>
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
@@ -692,6 +705,7 @@ export function WorkspaceDraftAgentTab({
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</View>

View File

@@ -140,6 +140,10 @@ function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean {
return isWeb && !isMobile;
}
function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean {
return override ?? formFactor;
}
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
@@ -190,6 +194,7 @@ function renderContextWindowMeter(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
): ReactElement | null {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
return null;
@@ -199,6 +204,7 @@ function renderContextWindowMeter(
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
/>
);
}
@@ -206,13 +212,13 @@ function renderContextWindowMeter(
function resolveContextWindowPlacement(
meter: ReactElement | null,
isMobile: boolean,
): { beforeVoiceContent: ReactNode; footerRight: ReactNode } {
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
if (isMobile) {
return { beforeVoiceContent: null, footerRight: meter };
return { beforeVoiceContent: null, footerInlineContent: meter };
}
return {
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerRight: null,
footerInlineContent: null,
};
}
@@ -221,14 +227,22 @@ interface RenderLeftContentArgs {
agentId: string;
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { agentControls, agentId, serverId, focusInput } = args;
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} />;
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
}
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
return (
<AgentControls
agentId={agentId}
serverId={serverId}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
);
}
interface RenderAttachmentTrayArgs {
@@ -238,13 +252,18 @@ interface RenderAttachmentTrayArgs {
handleRemoveAttachment: (index: number) => void;
}
function renderComposerFooter(footer: ReactNode, footerRight: ReactNode): ReactElement | null {
if (!footer && !footerRight) return null;
function renderComposerFooter(
footer: ReactNode,
footerInlineContent: ReactNode,
): ReactElement | null {
if (!footer && !footerInlineContent) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>
<View style={styles.footerLeft}>{footer}</View>
<View style={styles.footerRight}>{footerRight}</View>
<View style={styles.footerLeft}>
{footer}
{footerInlineContent}
</View>
</View>
</View>
);
@@ -667,6 +686,8 @@ interface ComposerProps {
footer?: ReactNode;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
isCompactLayout?: boolean;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -862,6 +883,7 @@ export function Composer({
inputWrapperStyle,
footer,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
@@ -892,9 +914,11 @@ export function Composer({
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
const userInput = value;
const setUserInput = onChangeText;
const {
@@ -1430,7 +1454,7 @@ export function Composer({
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
isCompact={isMobile}
isCompact={isCompactLayout}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
@@ -1448,7 +1472,7 @@ export function Composer({
hasSendableContent,
isAgentRunning,
isConnected,
isMobile,
isCompactLayout,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,
@@ -1468,12 +1492,13 @@ export function Composer({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd],
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
);
const { beforeVoiceContent, footerRight } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isMobile),
[contextWindowMeter, isMobile],
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
[contextWindowMeter, isCompactLayout],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -1540,8 +1565,8 @@ export function Composer({
);
const leftContent = useMemo(
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, agentControls],
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
[agentId, focusInput, serverId, agentControls, isCompactLayout],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1718,7 +1743,7 @@ export function Composer({
</View>
</View>
</View>
{renderComposerFooter(footer, footerRight)}
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
);
}
@@ -1760,6 +1785,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: -theme.spacing[3],
},
alignItems: "center",
paddingBottom: {
xs: 0,
md: theme.spacing[2],
},
},
footerContent: {
width: "100%",
@@ -1782,6 +1811,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
// so its icon aligns to the composer border before the footer inset is applied.
marginLeft: {
@@ -1789,17 +1819,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: 0,
},
},
footerRight: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
// On mobile, cancel the trailing glyph's internal inset (28px box around a 16px
// ring) so its right edge aligns to the composer border before the footer inset.
marginRight: {
xs: -6,
md: 0,
},
},
messageInputContainer: {
position: "relative",
width: "100%",

View File

@@ -2,6 +2,8 @@ import {
View,
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
useWindowDimensions,
NativeSyntheticEvent,
@@ -46,6 +48,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
@@ -138,6 +142,8 @@ const MIN_INPUT_HEIGHT_DESKTOP = 46;
const DEFAULT_MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_VIEWPORT_RATIO = 0.5;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
const ATTACHMENT_SHEET_HEADER: SheetHeader = { title: "Add attachment" };
const ATTACHMENT_SHEET_SNAP_POINTS = ["28%", "45%"];
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -198,6 +204,55 @@ function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) {
);
}
function AttachmentSheetItem({
item,
onSelect,
}: {
item: AttachmentMenuItem;
onSelect: (item: AttachmentMenuItem) => void;
}) {
const handlePress = useCallback(() => {
onSelect(item);
}, [item, onSelect]);
const pressableStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.attachmentSheetItem,
pressed && styles.attachmentSheetItemPressed,
item.disabled && styles.buttonDisabled,
],
[item.disabled],
);
return (
<Pressable
testID={`message-input-attachment-menu-item-${item.id}`}
accessibilityRole="button"
disabled={item.disabled}
onPress={handlePress}
style={pressableStyle}
>
{item.icon ? <View style={styles.attachmentSheetItemIcon}>{item.icon}</View> : null}
<Text style={styles.attachmentSheetItemText}>{item.label}</Text>
</Pressable>
);
}
function AttachmentSheetList({
items,
onSelect,
}: {
items: AttachmentMenuItem[];
onSelect: (item: AttachmentMenuItem) => void;
}) {
return (
<View style={styles.attachmentSheetList}>
{items.map((item) => (
<AttachmentSheetItem key={item.id} item={item} onSelect={onSelect} />
))}
</View>
);
}
function AttachmentDropdown({
isConnected,
disabled,
@@ -211,12 +266,73 @@ function AttachmentDropdown({
renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement;
attachmentMenuItems: AttachmentMenuItem[];
}) {
const isCompact = useIsCompactFormFactor();
const [isSheetOpen, setIsSheetOpen] = useState(false);
useDismissKeyboardOnOpen(isSheetOpen, isCompact);
const isButtonDisabled = !isConnected || disabled;
const handleOpenSheet = useCallback(() => {
if (isButtonDisabled) return;
setIsSheetOpen(true);
}, [isButtonDisabled]);
const handleCloseSheet = useCallback(() => {
setIsSheetOpen(false);
}, []);
const handleSheetItemSelect = useCallback((item: AttachmentMenuItem) => {
if (item.disabled) return;
setIsSheetOpen(false);
if (Platform.OS === "ios") {
setTimeout(item.onSelect, 250);
return;
}
item.onSelect();
}, []);
const mobileAttachButtonStyle = useCallback(
(state: { pressed: boolean; hovered?: boolean }) => {
if (typeof attachButtonStyle === "function") {
return attachButtonStyle({ ...state, hovered: Boolean(state.hovered), open: isSheetOpen });
}
return attachButtonStyle;
},
[attachButtonStyle, isSheetOpen],
);
const renderMobileAttachButtonIcon = useCallback(
({ hovered }: { hovered?: boolean }) => renderAttachButtonIcon({ hovered }),
[renderAttachButtonIcon],
);
if (isCompact) {
return (
<>
<Pressable
disabled={isButtonDisabled}
accessibilityLabel="Add attachment"
accessibilityRole="button"
testID="message-input-attach-button"
onPress={handleOpenSheet}
style={mobileAttachButtonStyle}
>
{renderMobileAttachButtonIcon}
</Pressable>
<AdaptiveModalSheet
header={ATTACHMENT_SHEET_HEADER}
visible={isSheetOpen}
onClose={handleCloseSheet}
snapPoints={ATTACHMENT_SHEET_SNAP_POINTS}
testID="message-input-attachment-menu"
>
<AttachmentSheetList items={attachmentMenuItems} onSelect={handleSheetItemSelect} />
</AdaptiveModalSheet>
</>
);
}
return (
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
disabled={!isConnected || disabled}
disabled={isButtonDisabled}
accessibilityLabel="Add attachment"
accessibilityRole="button"
testID="message-input-attach-button"
@@ -1950,6 +2066,31 @@ const styles = StyleSheet.create((theme: Theme) => ({
buttonDisabled: {
opacity: 0.5,
},
attachmentSheetList: {
gap: theme.spacing[1],
},
attachmentSheetItem: {
minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
},
attachmentSheetItemPressed: {
backgroundColor: theme.colors.surface2,
},
attachmentSheetItemIcon: {
width: 20,
alignItems: "center",
justifyContent: "center",
},
attachmentSheetItemText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
overlayContainer: {
position: "absolute",
display: "flex",

View File

@@ -13,6 +13,7 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Desktop app constants for macOS traffic light buttons
// These buttons (close/minimize/maximize) overlay the top-left corner

View File

@@ -3,8 +3,6 @@
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from "react-native";
const tintColorLight = "#0a7ea4";
const tintColorDark = "#fff";
@@ -26,28 +24,3 @@ export const Colors = {
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: "system-ui",
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: "ui-serif",
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: "ui-rounded",
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: "ui-monospace",
},
default: {
sans: "normal",
serif: "serif",
rounded: "normal",
mono: "monospace",
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});

View File

@@ -19,6 +19,7 @@ function workspace(input: {
name: "main",
status: "running",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};

View File

@@ -18,6 +18,7 @@ const baseWorkspace: WorkspaceDescriptor = {
name: "feature",
status: "done",
archivingAt: "2026-04-30T00:00:00.000Z",
statusEnteredAt: null,
diffStat: null,
scripts: [],
};

View File

@@ -17,10 +17,10 @@ const CATALOG_DATA = [
title: "Agoragentic",
description:
"Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
version: "1.3.0",
version: "1.3.2",
iconId: "agoragentic-acp",
installLink: "https://agoragentic.com",
command: ["npx", "-y", "agoragentic-mcp@1.3.0", "--acp"],
command: ["npx", "-y", "agoragentic-mcp@1.3.2", "--acp"],
},
{
id: "amp-acp",
@@ -36,10 +36,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.26.0",
version: "0.28.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.26.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.28.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -57,29 +57,29 @@ const CATALOG_DATA = [
id: "claude-acp",
title: "Claude Agent",
description: "ACP wrapper for Anthropic's Claude",
version: "0.32.0",
version: "0.40.0",
iconId: "claude-acp",
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.32.0"],
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.40.0"],
},
{
id: "cline",
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "2.18.0",
version: "3.0.15",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@2.18.0", "--acp"],
command: ["npx", "-y", "cline@3.0.15", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.95.0",
version: "2.102.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.95.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.102.0", "--acp"],
},
{
id: "codex-acp",
@@ -130,10 +130,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.7",
version: "0.1.12",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.7"],
command: ["npx", "-y", "deepagents-acp@0.1.12"],
},
{
id: "deepseek-tui",
@@ -157,29 +157,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.0.66",
version: "0.0.77",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.0.66", "acp"],
command: ["npx", "-y", "dimcode@0.0.77", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.3.36",
version: "0.3.44",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.3.36", "--acp"],
command: ["npx", "-y", "dirac-cli@0.3.44", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.119.0",
version: "0.139.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.119.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.139.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -189,38 +189,38 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.0",
version: "0.7.15",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "fast-agent-acp@latest", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.7.15", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.41.1",
version: "0.45.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.45.0", "--acp"],
},
{
id: "github-copilot-cli",
title: "GitHub Copilot",
description: "GitHub's AI pair programmer",
version: "1.0.41",
version: "1.0.59",
iconId: "github-copilot-cli",
installLink: "https://github.com/features/copilot/cli/",
command: ["npx", "-y", "@github/copilot@1.0.41", "--acp"],
command: ["npx", "-y", "@github/copilot@1.0.59", "--acp"],
},
{
id: "glm-acp-agent",
title: "GLM Agent",
description:
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.1.2",
version: "1.1.4",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
command: ["npx", "-y", "glm-acp-agent@1.1.2"],
command: ["npx", "-y", "glm-acp-agent@1.1.4"],
},
{
id: "goose",
@@ -294,7 +294,7 @@ const CATALOG_DATA = [
version: "0.1.44",
iconId: "minion-code",
installLink: "https://github.com/femto/minion-code",
command: ["uvx", "minion-code@latest", "acp"],
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
},
{
id: "mistral-vibe",
@@ -309,10 +309,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.4",
version: "1.1.14",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.4", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.14", "acp"],
},
{
id: "opencode",
@@ -327,10 +327,10 @@ const CATALOG_DATA = [
id: "pi-acp",
title: "pi ACP",
description: "ACP adapter for pi coding agent",
version: "0.0.26",
version: "0.0.27",
iconId: "pi-acp",
installLink: "https://github.com/svkozak/pi-acp",
command: ["npx", "-y", "pi-acp@0.0.26"],
command: ["npx", "-y", "pi-acp@0.0.27"],
},
{
id: "poolside",
@@ -345,19 +345,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "0.2.6",
version: "1.0.13",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@0.2.6", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.13", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.15.6",
version: "0.17.1",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.15.6", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.17.1", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -15,6 +15,7 @@ import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import { resolveAppVersion } from "@/utils/app-version";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
type DesktopDaemonSettings = DesktopSettings["daemon"];
@@ -133,7 +134,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={styles.logOutput} selectable>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
@@ -163,7 +164,7 @@ function DaemonCliStatusModal({
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>

View File

@@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import { confirmDialog } from "@/utils/confirm-dialog";
import {
shouldUseDesktopDaemon,
type SkillOp,
type SkillsStatus,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
@@ -21,21 +17,6 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const UNINSTALL_MESSAGE =
"Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.";
const OP_KIND_ORDER: Record<SkillOp["kind"], number> = { add: 0, update: 1, delete: 2 };
const OP_KIND_LABEL: Record<SkillOp["kind"], string> = {
add: "Add skill",
update: "Update skill",
delete: "Delete skill",
};
function formatUpdateMessage(ops: readonly SkillOp[]): string {
const sorted = [...ops].sort((a, b) => {
const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind];
return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name);
});
return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n");
}
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
@@ -75,15 +56,8 @@ export function IntegrationsSection() {
const handleUpdateSkills = useCallback(async () => {
if (isSkillsWorking) return;
const ops = skillsStatus?.ops ?? [];
const confirmed = await confirmDialog({
title: "Update Paseo skills?",
message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.",
confirmLabel: "Update",
});
if (!confirmed) return;
await updateSkills();
}, [isSkillsWorking, skillsStatus, updateSkills]);
}, [isSkillsWorking, updateSkills]);
const handleUninstallSkills = useCallback(async () => {
if (isSkillsWorking) return;

View File

@@ -51,6 +51,24 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: "editor" | "file-manager";
}
export interface DesktopEditorOpenTargetInput {
editorId: string;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface DesktopEditorBridge {
listTargets?: () => Promise<DesktopEditorTargetDescriptor[]>;
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
}
export interface DesktopWebUtilsBridge {
getPathForFile?: (file: File) => string;
}
@@ -111,6 +129,7 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
editor?: DesktopEditorBridge;
webUtils?: DesktopWebUtilsBridge;
menu?: DesktopMenuBridge;
browser?: DesktopBrowserBridge;

View File

@@ -43,6 +43,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;

View File

@@ -57,8 +57,8 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "@/components/diff-scroll";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import {
buildSplitDiffRows,
@@ -955,7 +955,7 @@ function DiffFileBody({
if (layout === "split") {
const rows = buildSplitDiffRows(file);
return (
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE}>
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<SplitDiffColumn
rows={rows}
side="left"
@@ -979,7 +979,7 @@ function DiffFileBody({
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
@@ -1006,7 +1006,7 @@ function DiffFileBody({
const textViewportWidth =
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
return (
<View style={DIFF_CONTENT_ROW_STYLE}>
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
@@ -2557,7 +2557,7 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[2],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
@@ -2572,7 +2572,7 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[3],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foreground,
userSelect: "text",
},

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
import {
buildGitHubBlobUrl,
buildGitHubBranchTreeUrl,
parseGitHubRepoFromRemote,
} from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
@@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => {
).toBeNull();
});
});
describe("buildGitHubBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a line range anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
});

View File

@@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: {
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
return `https://github.com/${repo}/tree/${encodedBranch}`;
}
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildGitHubBlobUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeGitHubBlobPath(input.path);
if (!repo || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += `#L${input.lineStart}`;
if (input.lineEnd && input.lineEnd > input.lineStart) {
url += `-L${input.lineEnd}`;
}
}
return url;
}

View File

@@ -366,45 +366,62 @@ describe("git-actions-policy", () => {
);
});
it("respects the ship preference when choosing between PR merge and local merge", () => {
const prPreferredActions = buildGitActions(
it("promotes ready PR merge over update-from-base", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
const localMergePreferredActions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "merge",
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
}),
);
expect(prPreferredActions.primary).toMatchObject({
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
expect(localMergePreferredActions.primary).toMatchObject({
});
it("promotes local merge over update-from-base", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 3,
}),
);
expect(actions.primary).toMatchObject({
id: "merge-branch",
label: "Merge locally",
});
expect(
localMergePreferredActions.secondary.some((action) => action.id === "merge-pr-squash"),
).toBe(true);
});
it("promotes ready PR merge over local merge", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
expect(actions.secondary.some((action) => action.id === "merge-branch")).toBe(true);
});
it("keeps the merge-pr actions in the feature branch menu", () => {
@@ -648,7 +665,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.primary).toMatchObject({ id: "merge-branch", label: "Merge locally" });
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
false,
);
@@ -675,6 +692,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.secondary).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -748,7 +766,7 @@ describe("git-actions-policy", () => {
}),
);
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
expect(actions.primary).toMatchObject({ id: "merge-branch", label: "Merge locally" });
expect(
actions.secondary.some((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),

View File

@@ -336,22 +336,22 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (canMergePr(input) && input.shipDefault === "pr") {
if (canMergePr(input)) {
return getDefaultDirectPullRequestMergeActionId(input);
}
if (canEnablePrAutoMerge(input) && input.shipDefault === "pr") {
if (canEnablePrAutoMerge(input)) {
return getDefaultEnablePullRequestAutoMergeActionId(input);
}
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
return "merge-branch";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
if (hasEnabledPrAutoMerge(input)) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return "merge-branch";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
return null;
@@ -557,6 +557,16 @@ function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
);
}
function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean {
return (
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestUrl !== null &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
);
}
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";

View File

@@ -0,0 +1,213 @@
import { describe, expect, it } from "vitest";
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
import {
buildStatusGroups,
buildStatusShortcutIndex,
STATUS_BUCKET_LABELS,
STATUS_BUCKET_ORDER,
type StatusGroup,
} from "./sidebar-status-view-model";
function ws(
input: Partial<SidebarWorkspaceEntry> & { workspaceKey: string },
): SidebarWorkspaceEntry {
return {
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? "main",
statusBucket: input.statusBucket ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
workspaceKey: input.workspaceKey,
};
}
function d(iso: string): Date {
return new Date(iso);
}
const emptyProjectNames = new Map<string, string>();
describe("buildStatusGroups", () => {
it("groups workspaces by status bucket in fixed order", () => {
const workspaces = [
ws({ workspaceKey: "srv:done-ws", statusBucket: "done", name: "done-ws" }),
ws({
workspaceKey: "srv:needs-input-ws",
statusBucket: "needs_input",
name: "needs-input-ws",
}),
ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]);
expect(groups[0]?.label).toBe("Needs input");
expect(groups[1]?.label).toBe("Working");
expect(groups[2]?.label).toBe("Done");
});
it("omits empty buckets", () => {
const workspaces = [
ws({ workspaceKey: "srv:a", statusBucket: "done" }),
ws({ workspaceKey: "srv:b", statusBucket: "running" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]);
});
it("sorts by statusEnteredAt desc within a bucket", () => {
const workspaces = [
ws({
workspaceKey: "srv:old",
statusBucket: "done",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:new",
statusBucket: "done",
statusEnteredAt: d("2026-06-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:mid",
statusBucket: "done",
statusEnteredAt: d("2026-03-01T00:00:00Z"),
}),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]);
});
it("sorts null timestamps last within a bucket", () => {
const workspaces = [
ws({ workspaceKey: "srv:null-a", statusBucket: "done", statusEnteredAt: null }),
ws({
workspaceKey: "srv:ts",
statusBucket: "done",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([
"srv:ts",
"srv:null-a",
"srv:null-b",
]);
});
it("tie-breaks by project name, then workspace name, then workspaceKey", () => {
const projectNames = new Map<string, string>([
["proj-b", "Beta"],
["proj-a", "Alpha"],
]);
const workspaces = [
ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }),
ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
];
const groups = buildStatusGroups(workspaces, projectNames);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]);
});
it("returns empty array for no workspaces", () => {
const groups = buildStatusGroups([], emptyProjectNames);
expect(groups).toEqual([]);
});
it("uses hydrated workspace entries with real status, not structural placeholders", () => {
const workspaces = [
ws({
workspaceKey: "srv:ni",
statusBucket: "needs_input",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:fail",
statusBucket: "failed",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:att",
statusBucket: "attention",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:run",
statusBucket: "running",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER);
expect(groups.map((g) => g.label)).toEqual(
STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]),
);
// Each group has exactly one row with the matching bucket
for (const group of groups) {
expect(group.rows).toHaveLength(1);
expect(group.rows[0]?.statusBucket).toBe(group.bucket);
}
});
});
describe("buildStatusShortcutIndex", () => {
it("assigns sequential numbers in status visual order", () => {
const groups: StatusGroup[] = [
{ bucket: "needs_input", label: "Needs input", rows: [ws({ workspaceKey: "srv:ni" })] },
{
bucket: "running",
label: "Working",
rows: [ws({ workspaceKey: "srv:run" }), ws({ workspaceKey: "srv:run2" })],
},
{ bucket: "done", label: "Done", rows: [ws({ workspaceKey: "srv:dn" })] },
];
const index = buildStatusShortcutIndex(groups);
expect(index.get("srv:ni")).toBe(1);
expect(index.get("srv:run")).toBe(2);
expect(index.get("srv:run2")).toBe(3);
expect(index.get("srv:dn")).toBe(4);
});
it("stops at 9 shortcuts", () => {
const rows = Array.from({ length: 12 }, (_, i) => ws({ workspaceKey: `srv:ws${i}` }));
const groups: StatusGroup[] = [{ bucket: "done", label: "Done", rows }];
const index = buildStatusShortcutIndex(groups);
expect(index.size).toBe(9);
expect(index.has("srv:ws8")).toBe(true);
expect(index.has("srv:ws9")).toBe(false);
});
it("returns empty map for empty groups", () => {
const index = buildStatusShortcutIndex([]);
expect(index.size).toBe(0);
});
});

View File

@@ -0,0 +1,94 @@
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
"needs_input",
"failed",
"attention",
"running",
"done",
] as const;
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
needs_input: "Needs input",
failed: "Failed",
attention: "Ready to review",
running: "Working",
done: "Done",
};
export interface StatusGroup {
bucket: StatusBucket;
label: string;
rows: SidebarWorkspaceEntry[];
}
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
projectNamesByKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
for (const ws of workspaces) {
const bucket: StatusBucket = ws.statusBucket;
let rows = bucketRows.get(bucket);
if (!rows) {
rows = [];
bucketRows.set(bucket, rows);
}
rows.push(ws);
}
const groups: StatusGroup[] = [];
for (const bucket of STATUS_BUCKET_ORDER) {
const rows = bucketRows.get(bucket);
if (!rows || rows.length === 0) continue;
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey));
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
}
return groups;
}
function compareStatusRows(
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
projectNamesByKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;
const bTime = b.statusEnteredAt?.getTime() ?? null;
if (aTime !== null && bTime !== null) {
if (aTime !== bTime) return bTime - aTime;
} else if (aTime !== null) {
return -1;
} else if (bTime !== null) {
return 1;
}
const aProject = projectNamesByKey.get(a.projectKey) ?? "";
const bProject = projectNamesByKey.get(b.projectKey) ?? "";
const projectCmp = aProject.localeCompare(bProject);
if (projectCmp !== 0) return projectCmp;
const nameCmp = a.name.localeCompare(b.name);
if (nameCmp !== 0) return nameCmp;
return a.workspaceKey.localeCompare(b.workspaceKey);
}
export function buildStatusShortcutIndex(groups: StatusGroup[]): Map<string, number> {
const index = new Map<string, number>();
let shortcutNumber = 1;
for (const group of groups) {
for (const row of group.rows) {
if (shortcutNumber > 9) return index;
index.set(row.workspaceKey, shortcutNumber);
shortcutNumber += 1;
}
}
return index;
}

View File

@@ -17,6 +17,7 @@ export interface SidebarWorkspaceEntry {
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
prHint: PrHint | null;
@@ -50,6 +51,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKind: "checkout",
name: input.workspaceId,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,

View File

@@ -34,7 +34,7 @@ afterEach(() => {
});
describe("ensureAgentIsInitialized", () => {
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => {
it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
@@ -56,12 +56,12 @@ describe("ensureAgentIsInitialized", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded canonical tail when no authoritative cursor is available", () => {
it("requests a bounded projected tail when no authoritative cursor is available", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -75,7 +75,7 @@ describe("ensureAgentIsInitialized", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
@@ -107,7 +107,7 @@ describe("ensureAgentIsInitialized", () => {
});
describe("refreshAgent", () => {
it("fetches a bounded canonical tail after refreshing the agent", async () => {
it("fetches a bounded projected tail after refreshing the agent", async () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -121,7 +121,7 @@ describe("refreshAgent", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
projection: "projected",
});
});
});

View File

@@ -420,7 +420,7 @@ describe("deriveAgentScreenViewState", () => {
expect(transitions.join(" -> ")).not.toContain("loading -> idle");
});
it("keeps optimistic running status while authoritative agent is initializing", () => {
it("uses authoritative initializing status instead of optimistic running status", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
@@ -431,8 +431,8 @@ describe("deriveAgentScreenViewState", () => {
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expect(ready.source).toBe("optimistic");
expect(ready.agent.status).toBe("running");
expect(ready.source).toBe("authoritative");
expect(ready.agent.status).toBe("initializing");
});
it("hands off to authoritative once agent reaches running", () => {

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