Compare commits

...

100 Commits

Author SHA1 Message Date
Mohamed Boudra
4c97ff8fa0 chore(release): cut 0.1.81 2026-05-24 13:13:01 +07:00
Mohamed Boudra
c0d2f20056 Update changelog for 0.1.81 2026-05-24 13:11:48 +07:00
Mohamed Boudra
2a3cfc684b Merge branch 'main' of github.com:getpaseo/paseo 2026-05-24 12:59:53 +07:00
Mohamed Boudra
68c893f643 fix(server): inject OpenCode MCP servers once 2026-05-24 12:58:30 +07:00
Mohamed Boudra
d43d30eeeb fix(server): log each provider fallback during metadata generation 2026-05-24 12:54:08 +07:00
Mohamed Boudra
79dcbdc1c1 Fix false unpushed commit warnings for worktree archive (#1158)
* fix(server): avoid false unpushed worktree commits

* fix(server): preserve local-only worktree push counts
2026-05-24 12:41:36 +08:00
paseo-ai[bot]
94bccf19ba fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 04:13:18 +00:00
Mohamed Boudra
83f205bd3d Preserve assistant message formatting on copy 2026-05-24 04:10:09 +00:00
Mohamed Boudra
846c9b9da3 Make mobile terminals load faster (#1147)
* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
2026-05-23 18:41:10 +08:00
paseo-ai[bot]
84f1dfc8cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-23 10:25:37 +00:00
Mohamed Boudra
33892f0698 fix(app): stop terminal flicker on resize 2026-05-23 17:22:24 +07:00
Mohamed Boudra
0962529d48 Make the web app installable (#1144)
* feat(app): make web app installable

* fix: restore CI for import and shortcut tests
2026-05-23 12:17:51 +08:00
Mohamed Boudra
008e4e846f fix(app): make slash command popover interactive on Android
The popover was positioned `bottom: 100%` of its parent inside the composer
and worked on iOS/web; Android's hit-test by parent bounds drops touches on
children that overflow their parent, so taps and scrolls went through to the
chat behind it. Move the popover into a `<Portal>` at the app root and apply
the same Reanimated keyboard SharedValue as the composer so it tracks the
keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas
(Android hit-test, Portal lifecycle/transforms, status-bar offset, the
two-measurement flash) and the canonical files.
2026-05-22 20:44:37 +07:00
Mohamed Boudra
789a559b31 fix(app): make sheet header search text readable in dark mode
The model picker search input rendered text in the light-theme
foreground color on a dark surface, leaving typed characters invisible.

@gorhom/bottom-sheet mounts header subtrees before the sheet is visible
and keeps them mounted across theme changes, so any color read from
StyleSheet.create((theme) => ...) at the caller goes stale. Move text
color and placeholder color into AdaptiveTextInput itself via
withUnistyles, with the leaf's color appended last so callers cannot
re-introduce a stale read. Drop the now-redundant placeholderTextColor
props at the header search call sites.

Also ban useUnistyles() in docs/unistyles.md. The hook subscribes to
every runtime change and forces a re-render each cycle; new code uses
withUnistyles or StyleSheet.create instead.
2026-05-22 18:23:11 +07:00
Mohamed Boudra
f5f1ae7fa9 fix(app): show one /exit row with /quit and /q as aliases 2026-05-22 18:22:19 +07:00
Mohamed Boudra
8e0ebfcaaa fix(server): hide empty sessions from the import list 2026-05-22 18:12:15 +07:00
Mohamed Boudra
b151dfcfd5 fix(app): show segmented control track under all segments 2026-05-22 18:05:35 +07:00
Mohamed Boudra
de7bf2fb01 fix(app): restore Alt+letter shortcuts on macOS
The key matcher only consulted event.code when a binding explicitly
opted in via codeFallback. macOS rewrites event.key whenever Option
is held (Option+T -> "†", Option+[ -> "“", Option+Shift+W -> "„",
etc.), so the comparison against combo.key always failed and every
Alt-bound letter / bracket shortcut silently stopped firing on Mac:
Cmd+Alt+T (cycle theme), Alt+Shift+[/], Alt+[/], Alt+Shift+W.

Fall back to event.code when combo.alt is set. Keep the existing
key-first behaviour for non-Alt bindings so Dvorak users still get
logical-character matching (Cmd+V on physical Period keeps pasting
instead of triggering Cmd+. for sidebar toggle).
2026-05-22 17:55:48 +07:00
Mohamed Boudra
5f11f602fb feat(app): add community links and sidebar home button 2026-05-22 17:40:42 +07:00
Mohamed Boudra
369c5a4498 fix(app): show shortcut chord badges in light mode
The Shortcut component computed its colors with a hex-only opacity
helper that received unistyles' CSS-var theme strings on web (e.g.
"var(--colors-foreground)"), fell through both regex branches, and
returned the rgba(255,255,255,...) fallback. Badges and chord text
rendered as near-white on white, so the keyboard shortcuts settings
page looked blank. Use the existing surface2 and foregroundMuted
tokens directly, which work in both themes without per-platform
color math.
2026-05-22 17:28:58 +07:00
Mohamed Boudra
c46ff2e045 feat(app): native terminal on WebView with xterm.js
Replace the Expo DOM-backed terminal on iOS and Android with a managed
WebView running xterm.js. Web and desktop continue to use the existing
DOM implementation.

Mobile mounted tabs switch from display:none to opacity:0 for the hidden
slot on purpose: terminals stay mounted under the LRU tab cache, and
keeping the WebView in the layer tree avoids cold-starting it every time
the user returns to a terminal tab.

EAS native builds rebuild the WebView bundle post-install so the
generated HTML stays in sync with the entry source.

This does not fix the WS 1006 / NSPOSIXErrorDomain Code=54 host-disconnect
symptom that prompted the investigation. After restoring the baseline,
that symptom could not be replicated; do not read this commit as its
root cause or fix.
2026-05-22 15:33:58 +07:00
Yurui Zhou
af10e64f82 feat(pi): bridge extension UI dialogs (#1134) 2026-05-22 00:03:42 +08:00
paseo-ai[bot]
db44a3e0e1 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 13:06:14 +00:00
Mohamed Boudra
25c4cee01e chore(release): cut 0.1.80 2026-05-21 20:02:05 +07:00
Mohamed Boudra
3ec4e2c536 docs(changelog): draft 0.1.80 entry 2026-05-21 20:00:36 +07:00
Mohamed Boudra
7dd9cbc506 fix(app): keep inline style marker web-only 2026-05-21 19:58:31 +07:00
paseo-ai[bot]
6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
Mohamed Boudra
a9f5b8ea4d chore(release): cut 0.1.79 2026-05-21 17:03:25 +07:00
Mohamed Boudra
1ce30bac05 docs(changelog): draft 0.1.79 entry 2026-05-21 16:51:15 +07:00
Mohamed Boudra
754f5a1f0b Tweak import session tile copy 2026-05-21 16:27:49 +07:00
Mohamed Boudra
698390bf1c feat(app): copy resume command for pi agents 2026-05-21 16:23:23 +07:00
Mohamed Boudra
63b24ec261 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 16:19:04 +07:00
Mohamed Boudra
ad060e2f86 feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.

Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.

To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.

Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
2026-05-21 16:15:28 +07:00
paseo-ai[bot]
1e595ad9cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 08:21:38 +00:00
Mohamed Boudra
9fd93f8308 Merge remote-tracking branch 'origin/main' into release-beta-0-1-79-beta-3 2026-05-21 15:17:35 +07:00
paseo-ai[bot]
fac81f568a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 05:51:50 +00:00
Mohamed Boudra
57de8c3807 chore(release): cut 0.1.79-beta.3 2026-05-21 12:50:43 +07:00
Mohamed Boudra
0310fd3c3c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 12:48:30 +07:00
Mohamed Boudra
a837d3e5d1 fix(app): keep React aligned with native renderer 2026-05-21 12:44:19 +07:00
paseo-ai[bot]
b5a78b7261 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 04:26:52 +00:00
Mohamed Boudra
e630e205db chore(release): cut 0.1.79-beta.2 2026-05-21 11:23:34 +07:00
Mohamed Boudra
8865f41d58 fix(app): unblock android APK build by aligning native deps
The 0.1.79-beta.1 APK build failed on EAS because react-native-unistyles
3.2.4 (bumped in #1103) references JHybridObject::CxxPart, which only
exists in react-native-nitro-modules 0.35.x. We were still on 0.33.8 so
the Android NDK compile died with "no member named CxxPart". Web and
desktop builds didn't catch it because the unistyles C++ layer is
Android-only.

Bump nitro to 0.35.5 to match unistyles' expected API. While re-resolving
the tree, react-native 0.81.6 also tightens its react peer to ^19.1.4,
so bump react/react-dom alongside so npm install stays clean.

Verified with a local production-apk gradle build using EAS's exact
gradleCommand — assembleRelease completes and the APK lands.
2026-05-21 11:19:52 +07:00
Mohamed Boudra
b94527fe1f fix(app): show empty state in import sheet when no sessions match 2026-05-21 11:19:52 +07:00
Mohamed Boudra
dee8f485de Recover stale daemon connections with liveness probes (#1124)
Use top-level WebSocket ping/pong for client liveness so session RPC timeouts stay scoped to the operation that timed out. Back off inactive connection probes while a host already has a healthy active connection.
2026-05-21 11:43:22 +08:00
Mohamed Boudra
28415b3542 docs(release): make betas silent, add release-beta/stable skills 2026-05-21 10:20:21 +07:00
Mohamed Boudra
3dd305082e docs(alternatives): add hero mockup to alternative comparison pages 2026-05-21 10:12:04 +07:00
Mohamed Boudra
cae3deb6e7 fix(website): SEO canonicals, host redirects, sitemap, app noindex
Addresses the high-volume issues from the Ahrefs audit:
- Worker entrypoint 301s every request not on https://paseo.sh
  (www subdomain and http variants), killing the duplicate-page
  errors and the HTTP→HTTPS internal-link notices.
- pageMeta() emits <link rel="canonical"> and og:url per route;
  every head() call now passes its canonical path.
- /blog stops rewriting itself to /blog?drafts=false; the search
  param only appears when explicitly true.
- Sitemap includes /blog and each post.
- Trim homepage title under SERP truncation, rewrite /agents meta
  description under 160 chars, pad short descriptions on
  /privacy, /download, /cloud, /changelog, /docs.
- app.paseo.sh gets <meta robots=noindex,nofollow> and a
  Disallow-all robots.txt so the SPA shell stops polluting the
  crawl with H1-missing / no-OG / low-word-count warnings.
2026-05-21 10:07:41 +07:00
Mohamed Boudra
ea36f0879f docs(website): rewrite agent pages for SEO and desktop coverage
Every agent landing page now mentions both mobile and desktop, leads with
"open source", and uses a search-friendly hero title. Renames Pi to "Pi Agent"
and disambiguates other common-word names in titles.
2026-05-20 22:31:28 +07:00
Mohamed Boudra
3bca1a72e8 Create agents in worktrees with auto-archive (#1120)
* Add daemon worktree auto-archive spawning

* Refactor create agent lifecycle dispatch

* Stabilize Claude autonomous turn test
2026-05-20 22:41:15 +08:00
paseo-ai[bot]
68d88f0928 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 14:39:26 +00:00
Mohamed Boudra
8a69d66f8e chore(release): cut 0.1.79-beta.1 2026-05-20 21:35:34 +07:00
Mohamed Boudra
c7872d968b docs(changelog): draft 0.1.79-beta.1 entry 2026-05-20 21:34:01 +07:00
Mohamed Boudra
8c4f5940d6 fix(server): launch Pi through the Windows-aware spawn helper (#1121) 2026-05-20 22:31:23 +08:00
paseo-ai[bot]
9b2b511abe fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 12:17:32 +00:00
Matteo Pietro Dazzi
e58ea31d6d chore: upgrade vitest (#1091)
* chore: upgrade vitest

* chore: sync lock
2026-05-20 12:14:13 +00:00
吴天一
c88f6fb2c2 docs: update built-in provider references (#1105) 2026-05-20 09:45:38 +00:00
a3a8527a1c Fix Dvorak paste shortcut handling
Fixes #1083
2026-05-20 17:34:49 +08:00
Mohamed Boudra
f1b3e25344 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-20 16:24:58 +07:00
paseo-ai[bot]
18a1bdcf72 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-20 08:57:53 +00:00
Mohamed Boudra
f933bf6b3a fix: stop leaking CSS rules from dynamic UI styles (#1084) (#1103)
* fix: stop leaking CSS rules from dynamic position styles

* Fix floating surfaces on web

* Stop diff controls growing Unistyles rules

* Use inline style escape hatch in diff pane

* Centralize dynamic style escape hatches

* Restore startup workspace navigation

* Upgrade Unistyles for web cleanup fix

* Update review style test for inline styles

* Use redirect for startup workspace restore

* Update startup redirect test
2026-05-20 16:55:00 +08:00
Mohamed Boudra
905a0985f9 Forward create agent env to provider launches (#1112) 2026-05-20 15:23:05 +08:00
Zexin Yuan
ca913728d9 Add publicUseTls option to NixOS module (#1106) 2026-05-20 07:20:13 +00:00
Mohamed Boudra
0d495a9625 Fix startup workspace restore (#1111) 2026-05-20 07:00:29 +00:00
Chris Banes
3786cf3569 Wait for Cursor ACP slash commands before listing. (#1099)
cursor-agent publishes commands asynchronously via available_commands_update, so listCommands() returned an empty cache when called right after session/new. Enable waitForInitialCommands only on CursorACPAgentClient with a 10s timeout; other ACP providers keep the immediate-return default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:47:10 +08:00
Mohamed Boudra
8a6bdb2d01 Run daemon dev diagnostics on workers 2026-05-20 09:36:09 +07:00
paseo-ai[bot]
555d10f046 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-19 16:02:09 +00:00
Mohamed Boudra
a1a5119bc5 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 22:57:37 +07:00
Mohamed Boudra
4cf985c254 Run Pi agents through the installed Pi CLI (#1097)
* Run Pi through its RPC interface

Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules.

Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path.

* Keep Pi provider code in one module

* Add Pi session imports

* Enable Paseo MCP tools in Pi agents

Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients.

Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names.

* Fix Pi import symlink tests on Windows

Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior.

* Use native realpath for import cwd matching

Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data.

* Fallback realpath for Windows import matching

Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners.

* Add daemon system prompt support for Pi

* Fix Windows realpath import matching

* Remove Pi RPC migration plan doc
2026-05-19 23:56:07 +08:00
Mohamed Boudra
0e1c590a3b Reduce workspace git refresh polling (#1102)
* Reduce workspace git refresh polling

* Keep workspace git snapshot reads passive

* Update workspace git refresh tests
2026-05-19 23:54:52 +08:00
Mohamed Boudra
b7a8567092 Restore the previous workspace on app start (#1101)
* Restore previous workspace on app start

* Guard workspace startup hydration

* Extract last workspace selection store
2026-05-19 23:53:57 +08:00
Mohamed Boudra
ad9b149bf7 Keep workspace editor target selection 2026-05-19 21:30:43 +07:00
Mohamed Boudra
306601a0c3 Add daemon-wide system prompts (#1100)
* Add daemon-wide system prompt setting

* Align settings textarea rendering

* Polish daemon settings layout

* Remove orchestrator prompt injection
2026-05-19 14:24:56 +00:00
Mohamed Boudra
da0e94fd18 Add DeepSeek TUI to the ACP catalog (#1096)
* Consolidate provider selection handling

* List DeepSeek TUI in docs and website
2026-05-19 22:20:09 +08:00
Mohamed Boudra
84fb7e9e06 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 20:49:46 +07:00
Mohamed Boudra
5dd6b030f2 Streamline model selector provider data 2026-05-19 20:01:56 +07:00
Mohamed Boudra
bc7798af28 Allow ACP providers without model lists 2026-05-19 20:01:56 +07:00
Mohamed Boudra
f238cbc20c Tighten DeepSeek TUI catalog copy 2026-05-19 20:01:56 +07:00
Mohamed Boudra
41c5eea678 Add DeepSeek TUI to ACP catalog 2026-05-19 20:01:56 +07:00
Mohamed Boudra
352e8fb6eb Show catalog provider icons (#1098) 2026-05-19 20:54:33 +08:00
Mohamed Boudra
8d65f8bc96 Suggest daemon upgrade on unknown RPC errors 2026-05-19 19:47:53 +07:00
Mohamed Boudra
2c561536e4 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-19 18:12:20 +07:00
Mohamed Boudra
3743df09e6 Add rename for workspaces, terminals, and agent tabs (#531)
* Add rename for workspaces, terminals, and agent tabs

Surfaces rename via the sidebar workspace kebab (git branch rename with
client-side slugify), the terminal tab context menu (stops OSC 2 auto-title
overrides), and the agent tab context menu (locks the title against the
metadata generator). A shared RenameModal wraps AdaptiveModalSheet and
replaces the inline rename on the host page.

Auto-title races are closed from both sides: AgentManager gains
setGeneratedTitleIfUnset with an atomic per-agent write queue, and
TerminalSession replaces lockedTitle with a titleMode discriminated union
so user rename flips to manual and disposes the OSC subscription.

New WebSocket messages are additive: rename_terminal_request/response and
checkout_rename_branch_request/response (branch rename uses the
CheckoutError family). A new @getpaseo/server/utils/branch-slug subpath
export shares slugify + validateBranchSlug between server and app.

* Tighten rename feature and restore lost rebase wiring

Unslop pass on the rename commit plus two rebase artifacts:

- session.ts: collapse handleRenameTerminalRequest to a respond helper
  with early returns; restore workspaceGitWatchTargets.set() in
  syncWorkspaceGitObserver (lost during rebase onto main, which broke
  onBranchChanged firing on branch rename).
- terminal.ts: remove DA1 query handler accidentally re-introduced by the
  rebase (main intentionally removed it); restore conditional
  onTitleChange registration under titleMode === "auto".
- rename-modal.tsx: drop unconfident optional-call on setNativeProps and
  the unknown-cast HTMLInputElement narrowing dance.
- sidebar-workspace-list.tsx: remove unused branch field from rename
  result; flatten validateRenameSlug into early returns.
- host-page.tsx: drop ?? "" fallback on a typed-string field.

* Fix typecheck after rebase: pass parsed config to getScriptConfigs

main refactored getScriptConfigs to take the parsed paseo.json config
instead of a repo path. spawnWorktreeScripts (added in the rename
feature) was still passing repoRoot. Read and parse the config first,
matching how spawnWorkspaceScript already does it.

* Resolve lint regressions and restore lost rebase fixes

Post-rebase cleanup: drop the await on syncWorkspaceGitObservers so
fetch_workspaces emits the response before any cold registration-
triggered git work fires (workspace.id is already on the descriptor —
no registry lookup needed). Restore the DA1 CSI handler that answers
\x1b[?62;4;22c on the daemon-side xterm so foreground apps like nvim
get a reply on stdin. Extract WorkspaceTabRenameModal/useWorkspaceTabRename
to drop WorkspaceScreenContent below the cyclomatic-complexity ceiling.
Switch session test internals to handleMessage so we exercise the public
dispatcher and avoid casting through any. Convert literal 'type' aliases
to interfaces and stop spawning callbacks inline so the codebase passes
the post-rebase oxlint rules.

* Fix typecheck after rebase: wire workspaceGitService and worker setTitle

Bootstrap was missing workspaceGitService when constructing
CreatePaseoWorktreeWorkflowDependencies after main's worktree workflow
refactor. Worker terminal manager needed setTitle on the session and
setTerminalTitle on the manager to satisfy the rename additions to
TerminalSession/TerminalManager interfaces.

* Format session.test.ts after rebase

* Remove stray auto-spawn of workspace scripts after bootstrap

The rebase brought in a spawnWorktreeScripts helper and a call inside
runWorktreeSetupInBackground that auto-started every configured workspace
script after worktree setup completed. main never auto-started scripts —
this regressed the workspace-setup-streaming Playwright test, which
expects the "web" script to be idle so the user can click Run.

Drops the helper, the call, and the workspaceGitService dep that only
existed to feed it.

* Fix checkout branch rename tests

* Fix sidebar checkout action store import

* Skip POSIX terminal tests on Windows

* Unslop the rename-entities feature

Six audit findings closed and ~450 net lines trimmed from the branch:

- Remove the duplicate "rename-branch" union member in
  GitMutationRefreshReason.
- Fold dispatchStashMessage back into handleSessionMessage; the split was a
  feature-first artifact of adding checkout_rename_branch_request, with no
  documented rationale.
- Drop the protected beforeGeneratedTitleIfUnsetWrite test seam from
  AgentStorage; rewrite the race test to exercise real Promise.all
  concurrency against the existing per-agent write queue.
- Reshape useWorkspaceTabRename so the hook returns state and handlers
  only; promote WorkspaceTabRenameModal to an exported component the
  consumer renders directly.
- Rename RenameModal to AdaptiveRenameModal so it reads as a generic
  primitive next to AdaptiveModalSheet, and update host-page,
  sidebar-workspace-list, and the workspace tab rename hook to import it
  by its new name.
- Trim duplicate matrix coverage and Zod self-tests across
  rename-modal.test.tsx, terminal.test.ts, session.test.ts,
  agent-storage.test.ts, messages.rename-entities.test.ts and the three
  rename e2e specs; introduce packages/app/e2e/helpers/rename.ts to share
  setup across the e2e specs without merging coverage.

Behavior of the rename feature is unchanged; targeted vitest, branch-wide
typecheck, and lint all green.

* Restore agentMetadataMocks dropped during rebase

5e6aeb2d removed the agentMetadataMocks hoisted definition and its
vi.mock wiring when it deleted the import describe block. The block
was kept (it belongs to main's import feature) but the mock support
was left behind.

* Fix terminal-manager tests using hardcoded /tmp on Windows

setTerminalTitle tests used cwd: "/tmp" which is not a valid directory
on Windows, causing node-pty error code 267 (ERROR_DIRECTORY). Use
realpathSync(tmpdir()) like the rest of the test file.

* Fix typecheck and lint after rebase onto main

AdaptiveModalSheet moved from a `title` string prop to a structured
`header: SheetHeader`; update AdaptiveRenameModal to memoize and pass
a SheetHeader. invalidateCheckoutGitQueriesForClient moved from
git/actions-store to git/query-keys. useWorkspaceTerminals now owns
the terminal query, so workspace-screen pulls queryKey from the hook
and re-declares queryClient via useQueryClient.

* Update agent metadata test mock to match setGeneratedTitleIfUnset rename

The branch renamed AgentManager.setTitle to setGeneratedTitleIfUnset
for the rename feature; the generateTitlePromptWithConfig helper still
mocked the old method, so eight prompt-byte tests crashed with
"setGeneratedTitleIfUnset is not a function" on both ubuntu and
windows server-tests jobs. Sister mocks in the same file were already
on the new name.

* Fix rename modal showing empty input by using controlled TextInput

AdaptiveTextInput (introduced on main by 29ce6653f) is intentionally
uncontrolled: it drops the `value` prop and seeds the native input
once via `initialValue`. AdaptiveRenameModal still passed `value=
draft` from the pre-rebase shape, so every rename modal opened with
an empty textbox and a disabled Save button. Three playwright e2e
specs (settings-host-page, sidebar-workspace-rename, workspace-agent-
tab-rename) failed for this reason.

Switch the rename modal to a plain controlled TextInput so the input
seeds with the current label and the slug transform (used by sidebar
workspace rename) reflects live in the textbox as the user types.
The unit test's old AdaptiveTextInput mock is replaced with a
react-native mock providing a controlled TextInput shim that captures
the same onChangeText/onSubmitEditing handlers.

* Revert "Fix rename modal showing empty input by using controlled TextInput"

This reverts commit 6283deae842b8a5fed63cb8bf06c78029f98386e.

* Seed rename modal input via AdaptiveTextInput's initialValue + resetKey

AdaptiveTextInput is intentionally uncontrolled — it drops `value` and
seeds the native input once with `initialValue`. The rename modal was
passing `value={draft}` from the pre-rebase shape, so every rename
modal opened with an empty textbox (three playwright specs failing).

Pass `initialValue={draft}` and bump `resetKey` only when the
transform rewrote what the user typed. That seeds the native input
with the current label on open, lets the user keep typing inside
text without cursor jumps (no remount when transform is a no-op),
and remounts the native input with the slug when the transform
diverges so live slugification keeps working in sidebar workspace
rename. Keeping AdaptiveTextInput preserves the BottomSheetTextInput
swap on mobile so the keyboard stays above the sheet — using a plain
TextInput would break that.

The unit test mock is updated to read `initialValue`/`resetKey` so it
mirrors production behavior instead of pretending the input is
controlled.

* Slugify branch rename at submit only, drop live transform

The rename modal's transform prop remounted the native input every
time the slug diverged from what the user typed, which lost focus
mid-edit on every uppercase letter or space in the sidebar workspace
rename. Live-rewriting what the user typed was also surprising — the
expectation is that you type a name, the daemon stores a slug.

Drop the transform prop from AdaptiveRenameModal entirely. Sidebar
workspace rename now slugifies once in handleSubmitRename before
calling renameBranch, and validateRenameSlug runs validateBranchSlug
against the slugified value so the user sees inline errors. The
playwright spec drops the live-slug assertion; it still verifies the
post-submit branch on disk and the rename request payload.

* Rename new rename RPCs to dotted convention

docs/rpc-namespacing.md says new RPCs use dotted names with the
direction as the final segment, and explicitly bans new flat snake_
case names. This PR introduced two flat ones; rename them in place
before the protocol ships:

- checkout_rename_branch_request → checkout.rename_branch.request
- checkout_rename_branch_response → checkout.rename_branch.response
- rename_terminal_request → terminal.rename.request
- rename_terminal_response → terminal.rename.response

Touched: the Zod literals in messages.ts, the discriminated union
entries, the session dispatcher and its tests, the daemon client
wrappers and their tests, the terminal session controller, and the
message-parsing rename-entities test. No callers exist outside the
server package — app and CLI go through the daemon-client wrappers,
which now emit the dotted names.

* Match rename modal Save button and input focus to app conventions

Save uses Button variant=default so it gets the same accent
background + white text as every other primary action in the app
(open-project, settings host save, pair-link confirm, etc).

The input previously fell back to the browser's blue focus outline
on web because the rename modal never set outlineStyle: none — every
other AdaptiveTextInput call site that wants accent feedback already
does this. Kill the browser outline, track focus state, and switch
borderColor to accent while focused.

* Theme the focus-visible outline color via AdaptiveTextInput

public/index.html paints a 2px :focus-visible outline on every web
element with a hard-coded #20744a (Paseo green). On the Claude theme,
the rename modal's input got that green ring instead of the theme's
brown accent — visible mismatch against every other accent-colored
control (primary buttons, etc).

Add an outlineColor entry to AdaptiveTextInput's stylesheet sourced
from theme.colors.accent. Unistyles' Babel plugin tracks the read and
updates the native ShadowTree on theme switch — no React re-render,
no useUnistyles() call (forbidden on this hot path per docs/unistyles.md).
The inline outline-color on the rendered DOM input overrides the
selector-level color from index.html; outline-width/style/offset
still come from the global rule.

Consumer style merges in after, so existing callers that pass
outlineColor (message-input, review/surface, question-form-card)
still win.

Also drop the half-baked isFocused/focusedBorderStyle local state
I added to rename-modal earlier — the AdaptiveTextInput fix removes
the need for it.

* Drop useUnistyles() from rename modal

The rename modal only read theme.colors.foregroundMuted to pass as
the TextInput's placeholderTextColor prop. The rest already went
through StyleSheet.create((theme) => ...). Per docs/unistyles.md the
hook is forbidden when an alternative exists — and this is exactly
the alternative the rest of the codebase uses (project-settings-screen,
add-host-modal, pair-link-modal, command-center): put the muted color
in a tiny StyleSheet entry and read .color off it for the prop.

* Default placeholderTextColor in AdaptiveTextInput

placeholderTextColor was being duplicated at every AdaptiveTextInput
call site (add-host-modal, command-center, pair-link-modal,
project-picker-modal, provider-diagnostic-sheet, combobox, the
sheet's own search inputs, etc.) — all passing the same
theme.colors.foregroundMuted. The shared input should own this.

Move it into AdaptiveTextInput as a default sourced from the same
StyleSheet.create(theme => ...) block as the accent outline. Consumers
still override via the prop if they need a different color. Strip
the redundant placeholderTextColor and local placeholderColor style
entry from rename-modal; other call sites can be cleaned up in a
follow-up.

* Update rename e2e specs to use dotted RPC type strings

Earlier commit (5d5624943) renamed the new rename RPCs to the dotted
convention but missed the two e2e specs that capture the WebSocket
frames by type. captureWsSessionFrames matched the old flat names, so
renameRequests / renameFrames stayed empty even though the rename
went through end to end — the sidebar updated and the branch was
renamed on disk. The toBeGreaterThan(0) assertion fired and the test
failed in playwright CI.
2026-05-19 18:05:45 +08:00
Mohamed Boudra
0a2307d199 Avoid duplicate Claude result text (#1095) 2026-05-19 17:43:26 +08:00
Mohamed Boudra
0f6641c8c0 Show resolved file paths in agent file-link tooltips (#1088)
* fix: show resolved file paths in agent file-link tooltips

Bare filenames in agent messages now resolve to their full workspace
path on hover. Also raises the daemon's directory-suggestion scan
depth so files in deeper package layouts are reachable.

* fix(app): keep file-link wrapper stable to avoid layout shift on resolve

* fix(app): resolve file links on hover instead of at render time

useQuery was firing the daemon RPC for every ambiguous file reference
the moment a message rendered, fanning out a wave of requests on chat
scroll. Switch to enabled: false + prefetchQuery on hover so RPCs are
driven by user intent. Sync-resolvable refs (directFile, external)
still seed via initialData and render with no RPC.

Also memo on source primitives rather than identity so identical-
content sources constructed inline upstream don't bust the memo every
render.

* Redesign assistant file link resolution

* Stabilize assistant file link handlers
2026-05-19 15:32:48 +08:00
Mohamed Boudra
fdecd75f94 Add Claude project-dir resolver matching SDK encoding
Claude's Agent SDK encodes a cwd into ~/.claude/projects/<dir> by
realpath + NFC normalizing the path, replacing every non-alphanumeric
character with "-", and (when the encoded result exceeds 200 chars)
appending a base-36 hash. paseo's existing sanitizer only replaces
five characters and skips realpath, so it misses the SDK's directory
whenever the cwd has a symlink (macOS /var → /private/var), spaces,
parens, NFD unicode, or exceeds the length cap.

This adds the resolver alongside the existing logic; wiring it into
agent.ts to replace resolveHistoryPath is the next step. Tests assert
parity by writing a session file at our computed path and asking the
SDK's getSessionInfo({ dir }) to find it — if both encoders agree,
the SDK finds it.
2026-05-19 13:42:24 +07:00
paseo-ai[bot]
b41cb72da0 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-19 06:00:14 +00:00
Yurui Zhou
cb96485035 feat(server): upgrade embedded Pi SDK (#1087)
* feat(server): upgrade embedded Pi SDK

* fix: sync package-lock.json with package.json

The Pi SDK upgrade commit (63e18a9d) regenerated package-lock.json in a
way that dropped packages/website's react@19.2.6, react-dom@19.2.6, and
scheduler@0.27.0 (website depends on react ^19.1.4, which resolves to
19.2.6). This broke `npm ci` in CI, failing all 14 checks at the install
step with EUSAGE "package.json and package-lock.json not in sync".

Regenerated with `npm install`.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:56:32 +08:00
ezra
ce822f989f Fix Codex Microsoft Store binary detection on Windows (#1020)
Fixes #1013
2026-05-19 05:12:55 +00:00
Bolun Zhang
e38d0e0fa9 feat(mcp): consolidate provider settings tools (#1011)
Closes #984
2026-05-19 12:56:03 +08:00
João Sousa Andrade
bc32b16b02 Reject relay re-handshake key changes (#1037)
Closes #366
Closes #368
2026-05-19 12:38:55 +08:00
Mohamed Boudra
b2cdbdaed8 Hide import pill once the draft is submitting 2026-05-19 10:26:13 +07:00
Mingyang Sun
9ef7230417 Add Kiro CLI to ACP provider catalog
Adds Kiro CLI as an opt-in ACP provider catalog entry, plus a generic ACP extension-notification sink so unknown notifications no longer fail JSON-RPC dispatch.
2026-05-19 03:23:57 +00:00
Zexin Yuan
383b380d8a fix(cli): query daemon for status and pairing offer over RPC
Closes #1081
2026-05-19 03:15:52 +00:00
Mohamed Boudra
339ca2fc83 Make zinc accent monochrome and respect accentForeground 2026-05-19 10:01:39 +07:00
xy-plus
8ff63a6d71 fix(server): keep Codex sub-agent running on transient child error state
Fixes #1071
2026-05-19 10:57:55 +08:00
Yurui Zhou
aaabadb04b fix: prevent macOS desktop unlock freeze after display sleep (#745)
* Restart the GPU process to recover from macOS compositor freezes

macOS display sleep can leave Chromium's GPU-process display link stuck
on a stale display, so the compositor stops producing frames and the
window looks frozen — unresponsive to clicks and keys — even though the
renderer stays alive.

setupDarwinCompositorWatchdog polls the renderer for frame production
and, on a sustained stall while the window is visible and unlocked,
restarts the GPU process so Chromium rebuilds the display link. This
replaces setupDarwinPaintRefresh, whose invalidate/resize nudges did
not address the dead display link.

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

* Give compositor watchdog a module home

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-19 10:41:55 +08:00
Mohamed Boudra
501dcf373b Hide voice mode while agents run 2026-05-19 08:18:56 +07:00
Samatar
5a1c7f266c fix(server): render non-ASCII filenames in git output
Fixes #436
2026-05-19 01:16:33 +00:00
Samatar
9bc6210823 fix(terminal): send SIGINT for hardware Ctrl+C on iPad
Fixes #1049
2026-05-19 09:02:48 +08:00
paseo-ai[bot]
8aa8f7a0cc fix: update lockfile signatures and Nix hash [skip ci] 2026-05-18 11:04:02 +00:00
320 changed files with 21323 additions and 9205 deletions

View File

@@ -0,0 +1,11 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
user-invocable: true
---
# Release beta
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.

View File

@@ -0,0 +1,11 @@
---
name: release-stable
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
user-invocable: true
---
# Release stable
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.

1
.claude/skills/release-beta Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/release-beta

View File

@@ -0,0 +1 @@
../../.agents/skills/release-stable

View File

@@ -1,5 +1,83 @@
# Changelog
## 0.1.81 - 2026-05-24
### Added
- **Paseo can now be installed as a web app from supported browsers** ([#1144](https://github.com/getpaseo/paseo/pull/1144))
- **Pi extension dialogs now appear as Paseo permission prompts** ([#1134](https://github.com/getpaseo/paseo/pull/1134) by [@yuruiz](https://github.com/yuruiz))
- Added community links and a home button to the sidebar
### Improved
- **Mobile terminals load faster and restore existing output more smoothly** ([#1147](https://github.com/getpaseo/paseo/pull/1147))
- Copying assistant messages preserves formatting
- Agent metadata fallback failures now log each provider attempt for easier debugging
### Fixed
- Android: slash command suggestions stay interactive when opened from the composer
- macOS: Alt+letter shortcuts work again
- Terminal panes no longer flicker during resize
- OpenCode MCP servers are injected once instead of being connected twice
- Import session no longer shows empty sessions
- Worktree archive status no longer reports false unpushed commits ([#1158](https://github.com/getpaseo/paseo/pull/1158))
- The `/exit`, `/quit`, and `/q` slash command aliases now show as one row
- Shortcut chord badges are readable in light mode
- Segmented controls show their track under every segment
- Sheet header search text is readable in dark mode
## 0.1.80 - 2026-05-21
### Fixed
- Opening dropdown menus no longer crashes on mobile
## 0.1.79 - 2026-05-21
### Added
- **Pi has been revamped with first-class support**
- Runs through your installed Pi CLI, so your Pi extensions and configuration carry over
- Pi agents can call Paseo tools when you have the Pi MCP extension installed
- Import a Pi session you started in the terminal
- Copy Pi's resume command from any agent to continue the session in your terminal
- Windows: Pi sessions match correctly across symlinked and junctioned workspace paths
- **New home screen with quick tiles for adding a project, importing a session, setting up providers, and pairing a device**
- **Create an agent directly into a fresh worktree that auto-archives when the run finishes**
- **Set a custom system prompt that applies to every agent you start**
- **Rename workspaces, terminals, and agent tabs** ([#531](https://github.com/getpaseo/paseo/pull/531))
- **DeepSeek TUI in the ACP provider catalog** ([#1096](https://github.com/getpaseo/paseo/pull/1096))
- **Kiro CLI in the ACP provider catalog** (by [@huhusmang](https://github.com/huhusmang))
- Catalog providers show their icons in the model picker ([#1098](https://github.com/getpaseo/paseo/pull/1098))
- Custom environment variables passed when creating an agent now reach the agent process ([#1112](https://github.com/getpaseo/paseo/pull/1112))
- NixOS module supports the public TLS option for self-hosted relays ([#1106](https://github.com/getpaseo/paseo/pull/1106) by [@yzx9](https://github.com/yzx9))
### Improved
- **Stale host connections recover automatically without a manual refresh**
- Paseo opens to the workspace you were on last time you used it ([#1101](https://github.com/getpaseo/paseo/pull/1101))
- Workspaces remember which editor you opened them in
- Outdated daemons now suggest an upgrade when they receive a command they don't understand
- Voice mode is hidden while an agent is running
- Agent file-link tooltips show the full resolved file path ([#1088](https://github.com/getpaseo/paseo/pull/1088))
- Workspace git status refreshes less aggressively in the background ([#1102](https://github.com/getpaseo/paseo/pull/1102))
### Fixed
- macOS desktop no longer freezes after the display wakes from sleep ([#745](https://github.com/getpaseo/paseo/pull/745))
- Windows: Codex picks up the Microsoft Store install correctly ([#1020](https://github.com/getpaseo/paseo/pull/1020) by [@32r4](https://github.com/32r4))
- Workspace selection survives a daemon restart ([#1111](https://github.com/getpaseo/paseo/pull/1111))
- Cursor agents wait for slash commands to load before listing them ([#1099](https://github.com/getpaseo/paseo/pull/1099) by [@chrisbanes](https://github.com/chrisbanes))
- Codex sub-agents keep running through transient child process errors (by [@xy-plus](https://github.com/xy-plus))
- iPad terminals send Ctrl+C correctly from a hardware keyboard (by [@samatar26](https://github.com/samatar26))
- Git filenames with non-ASCII characters render correctly (by [@samatar26](https://github.com/samatar26))
- Paste shortcuts work on Dvorak keyboard layouts (by [@qin-nz](https://github.com/qin-nz))
- Claude file links resolve correctly for projects whose paths need SDK encoding
- Duplicate Claude result text no longer appears in chat ([#1095](https://github.com/getpaseo/paseo/pull/1095))
- Dynamic UI styles no longer leak CSS rules across the page ([#1103](https://github.com/getpaseo/paseo/pull/1103))
- Relay handshakes reject sessions that try to change encryption keys mid-flight ([#1037](https://github.com/getpaseo/paseo/pull/1037) by [@joaosa](https://github.com/joaosa))
## 0.1.78 - 2026-05-18
### Improved
@@ -680,7 +758,7 @@
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support
- Pi (pi.dev) agent provider — connect Pi as a new agent type with thinking levels and tool call support
- Copilot agent provider re-enabled after ACP compatibility fixes
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode

View File

@@ -2,7 +2,7 @@
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
**Supported agents:** Claude Code, Codex, and OpenCode.
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
## Repository map
@@ -32,6 +32,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [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 |

View File

@@ -19,7 +19,7 @@
</a>
</p>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
@@ -34,7 +34,7 @@
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
@@ -49,7 +49,9 @@ You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [GitHub Copilot](https://github.com/features/copilot/cli/)
- [OpenCode](https://github.com/anomalyco/opencode)
- [Pi](https://pi.dev)
### Desktop app (recommended)

View File

@@ -27,6 +27,8 @@ Both look the same in storage. This is an accepted limitation — see [Limitatio
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
1. Snapshot the current session into the registry

View File

@@ -38,6 +38,10 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
## Screenshots
```bash

View File

@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
│ (Node.js) │
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Claude │ │ Codex │ │ OpenCode
│ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ │
└───────────┘ └────────┘ └──────────┘
┌────────────┼────────────┬────────────┬────────────
│ │ │ │ │
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
│ SDK │ │ Server │ │ ACP │ │ │ │
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────
```
## Components at a glance
@@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -215,16 +217,17 @@ initializing → idle ⇄ running
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
| Provider | Wraps | Session format |
| ------------------ | ------------------------------------ | -------------------------------------------------- |
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
| Copilot | GitHub Copilot via ACP | Provider-managed |
| OpenCode | OpenCode server / CLI | Provider-managed |
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
| Cursor | ACP wrapper (`acp-agent`) | Provider-managed |
| Generic ACP | ACP wrapper | Provider-managed |
| Pi-direct | Direct Anthropic API call | Stateless |
| Pi | Local Pi RPC process | Provider-managed |
| Mock load test | In-process fake | In-memory |
All providers:

View File

@@ -339,6 +339,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:

View File

@@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`.
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
auth: { password: string } // bcrypt hash, optional

View File

@@ -43,6 +43,22 @@ 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.
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
source that drives frame production — stuck on a stale display. The compositor
then stops producing frames and the window looks frozen: unresponsive to clicks
and keys even though the renderer and every process stay alive. It self-recovers
after a few minutes, which is too long for a foreground app.
`setupDarwinCompositorWatchdog`
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
this. It polls the renderer for frame production every couple of seconds and,
after a sustained stall while the window is visible and unlocked, restarts the
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.
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -171,6 +187,20 @@ Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## App web deploys
`packages/app` exports a single-page Expo web app and deploys the `dist/`
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
from `packages/app/public/index.html`. Keep the install icons in `public/` so
Cloudflare serves them from stable root URLs after `expo export`.
Do not add service-worker caching casually. Paseo is a live control surface for
agents, and an aggressive service worker can strand installed users on stale web
code. If offline behavior becomes a product requirement, add it deliberately
with an update strategy and test the installed-app upgrade path.
## Expo troubleshooting
```bash

173
docs/floating-panels.md Normal file
View File

@@ -0,0 +1,173 @@
# Floating Panels
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
float above an anchor element on iOS, Android, and web. This doc captures the
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | -------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Inline slash-command autocomplete above a focused TextInput |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete coexists with a
focused TextInput. There is no shared "floating panel" primitive yet — when a
fifth use case shows up we can revisit; until then prefer copying the closest
file and trimming. The four gotchas below are what each of them re-learned.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders
correctly (with `overflow: visible`, the default) but **does not receive touch
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
rect first, then iterates children. A touch in the overflowing region never
reaches the parent, let alone the child. iOS and web do not share this rule —
iOS hit-test descends into overflowing children, web uses standard CSS pointer
events. This is the bug that put autocomplete on this path: the popover was
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point (`PortalHost name="root"` in
`app/_layout.tsx`) whose bounds cover the screen. Same window, same IME,
hit-test works because the new parent is full-screen. This is the right
default when you must keep IME attachment.
Choose Modal vs Portal by whether you need the underlying input to keep its
keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
quietly relying on:
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
component's natural ancestor chain. When the user navigates away, your
component may stay mounted (offscreen, in a tab) — the popover stays with it.
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`. See
`composer.tsx:1604` and `autocomplete-popover.tsx`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is at the
app root — it does not get that transform unless you apply it yourself.
The fix for transforms is Gotcha 3.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
Do not try to track the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
to the popover wrapper.
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed. See `autocomplete-popover.tsx`
`openShift` / `shift` / `animatedTransformStyle`.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
## Gotcha 4 — Status-bar offset for Portal-based positioning on Android
On Android, `measureInWindow` returns coords **below** the status bar (the
status bar is system-owned space). A Portal overlay positioned at `top: 0`
inside the `PortalProvider` may start at the **top of the window** (above the
status bar) depending on the wrapper chain. The result: position the popover
at the measured `rect.y` and it sits `StatusBar.currentHeight` higher than the
anchor on Android.
Mirror what tooltip does: shift the measured rect down by the status bar height
on Android only.
```ts
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
```
See `tooltip.tsx` and `autocomplete-popover.tsx`. iOS and web do not need this.
## Gotcha 5 — The two-measurement flash
If your popover needs `top` (or `left`) computed from both:
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
- the popover's own size (`contentSize` from `onLayout`),
then a naïve implementation will flash through three positions on every open:
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
for either measurement. Wrapper has no `width`, so the inner content lays
out at its natural (often narrow) intrinsic width.
2. **Frame 2**`anchorRect` lands. Wrapper now has `width: anchorRect.width`.
But the stale `onLayout` from frame 1 has already set `contentSize` to the
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
at the wrong spot.
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
updates. Position snaps to the right place.
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
both:
- **Do not mount the floating content until `anchorRect` is set.** Return
`null` until then. This prevents the bad-width onLayout from happening at
all.
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
the final width but `opacity: 0`.** The first visible paint is at the
correct position. This is the combobox pattern —
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
`top: -9999` as the placeholder; the layout work still happens at -9999 and
any subsequent state-flash is visible when you flip back.
The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Recipe for a new anchored panel
Before you write a new one, ask:
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
4. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded** — autocomplete's inner detail card escapes the 220-cap
container, which is why bottom-anchored looked clean but rendered
wrong. Verify before you commit.
5. **Are you on Android with Portal?** Add the status-bar offset (Gotcha 4).
Then copy the closest canonical file and trim.

View File

@@ -15,7 +15,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).

View File

@@ -16,7 +16,7 @@ Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
```
## Baseline
@@ -44,6 +44,6 @@ One live reasoning-dedup matrix run returned no reasoning content; an immediate
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`

View File

@@ -71,7 +71,7 @@ Anyone who builds software:
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP

View File

@@ -14,7 +14,19 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
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

@@ -7,7 +7,7 @@ All workspaces share one version and release together.
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
## Standard release (patch)
@@ -51,10 +51,11 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
Use the beta path when you need to:
- smoke a build yourself before promoting it to everyone
- test a build manually in a Linux or Windows VM
- send a build to a user who is hitting a specific problem
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
@@ -264,18 +265,15 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
## Changelog policy
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
- Do not create duplicate entries for each beta on the same version line.
- `CHANGELOG.md` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
## Changelog ownership
@@ -359,7 +357,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
## Pre-release sanity check
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
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.
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
@@ -375,15 +373,19 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
## Changelog scope
The changelog always covers **stable-to-HEAD**:
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command

View File

@@ -11,7 +11,7 @@ The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Operation: `set_auto_merge`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.

View File

@@ -102,14 +102,14 @@ When a test is labeled end-to-end, it calls the real service. No environment var
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
| Suffix | What it is | Where it runs |
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
| Suffix | What it is | Where it runs |
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
@@ -127,7 +127,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
- Never re-run a suite another agent already reported green.
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
## Agent authentication in tests

View File

@@ -4,15 +4,17 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Forbidden
## STOP — `useUnistyles()` Is Banned
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
Use these alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
@@ -46,31 +48,9 @@ const ThemedBlur = withUnistyles(BlurView);
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
### 4. There is no "last resort"
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
There is no escape hatch. If none of (1)(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
## How Updates Propagate
@@ -80,6 +60,34 @@ The important detail: the automatic native path tracks `props.style`. It does no
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Dynamic Pixel Styles On Web
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
## Inline Style Escape Hatch
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
```tsx
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const styles = StyleSheet.create({
thumb: {
position: "absolute",
},
});
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
```
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.

View File

@@ -124,6 +124,17 @@ in
default = true;
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
};
publicUseTls = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
description = ''
Whether the public (client-facing) relay endpoint uses TLS.
When `null` (default), the daemon falls back to `relay.useTls`.
Override when the internal path is plain `ws://` behind a
TLS-terminating reverse proxy.
'';
};
};
inheritUserEnvironment = lib.mkOption {
@@ -253,6 +264,8 @@ in
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) {
PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false";
} // cfg.environment;
serviceConfig = {

View File

@@ -1 +1 @@
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=
sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=

5204
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -181,7 +181,7 @@ test.describe("Client slash commands", () => {
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
await selectClientSlashCommand(page, "/qu", "/quit");
await selectClientSlashCommand(page, "/qu", "/exit");
await expectWorkspaceTabHidden(page, agent.id);
await expectAgentArchivedInSessions(page, title);
},

View File

@@ -188,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let fakeToolBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,8 +209,8 @@ interface OfferPayload {
relay: { endpoint: string };
}
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
async function createFakeToolBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
@@ -284,6 +284,27 @@ forwardToRealGh();
`,
);
await chmod(ghPath, 0o755);
const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
if (recordPath) {
fs.appendFileSync(recordPath, JSON.stringify({
command: path.basename(process.argv[1]),
args: process.argv.slice(2),
cwd: process.cwd(),
at: Date.now()
}) + "\\n");
}
`;
for (const editorCommand of ["cursor", "code"]) {
const editorPath = path.join(binDir, editorCommand);
await writeFile(editorPath, fakeEditorSource);
await chmod(editorPath, 0o755);
}
return binDir;
}
@@ -599,7 +620,8 @@ interface DaemonSpawnArgs {
relayPort: number;
metroPort: number;
paseoHome: string;
fakeGhBinDir: string;
fakeToolBinDir: string;
editorRecordPath: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
@@ -613,8 +635,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
@@ -678,9 +701,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
if (fakeToolBinDir) {
await rm(fakeToolBinDir, { recursive: true, force: true });
fakeToolBinDir = null;
}
}
@@ -694,7 +717,8 @@ export default async function globalSetup() {
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
fakeGhBinDir = await createFakeGhBin();
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
fakeToolBinDir = await createFakeToolBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -712,7 +736,8 @@ export default async function globalSetup() {
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
fakeToolBinDir,
editorRecordPath,
dictation,
buffer: daemonLineBuffer,
});
@@ -742,6 +767,7 @@ export default async function globalSetup() {
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
process.env.E2E_METRO_PORT = String(metroPort);
process.env.E2E_PASEO_HOME = paseoHome;
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
console.log(
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
);

View File

@@ -0,0 +1,40 @@
import { type Page } from "@playwright/test";
/**
* Listens for outbound WebSocket "session" frames of a given inner message type
* and accumulates them. The returned array is populated in-place as frames arrive.
*/
export function captureWsSessionFrames<T extends Record<string, unknown>>(
page: Page,
messageType: string,
extract: (inner: Record<string, unknown>) => T,
): T[] {
const captured: T[] = [];
page.on("websocket", (ws) => {
ws.on("framesent", (frame) => {
const raw = frame.payload;
const text = typeof raw === "string" ? raw : raw.toString("utf8");
try {
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
if (outer.type === "session" && outer.message?.type === messageType) {
captured.push(extract(outer.message));
}
} catch {
// Ignore non-JSON and binary frames.
}
});
});
return captured;
}
export function renameModalInput(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-input`);
}
export function renameModalSubmit(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-submit`);
}
export function renameModalError(page: Page, testIdPrefix: string) {
return page.getByTestId(`${testIdPrefix}-error`);
}

View File

@@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise<void> {
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0);
}
export async function clickEditHostLabel(page: Page): Promise<void> {
@@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise<void> {
}
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
}
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {

View File

@@ -0,0 +1,138 @@
import { execSync } from "node:child_process";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
import { captureWsSessionFrames } from "./helpers/rename";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
function workspaceRenameModalTestId(workspaceId: string, suffix: string): string {
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
}
async function openProjectViaDaemon(
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
cwd: string,
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
const result = await client.openProject(cwd);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return {
id: String(result.workspace.id),
name: result.workspace.name,
workspaceDirectory: result.workspace.workspaceDirectory,
};
}
async function openRenameModal(page: Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
await expect(input).toBeVisible({ timeout: 10_000 });
return input;
}
test.describe("Sidebar workspace rename", () => {
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
page,
}) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-rename-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
expect(workspace.name).toBe("main");
const renameRequests = captureWsSessionFrames(
page,
"checkout.rename_branch.request",
(inner) => ({
branch: String(inner.branch ?? ""),
cwd: String(inner.cwd ?? ""),
}),
);
await gotoAppShell(page);
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
timeout: 30_000,
});
const input = await openRenameModal(page, workspace.id);
await expect(input).toHaveValue("main");
await input.fill("Feature Rename 2");
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
"feature-rename-2",
{ timeout: 15_000 },
);
expect(renameRequests.length).toBeGreaterThan(0);
expect(renameRequests.at(-1)).toEqual({
branch: "feature-rename-2",
cwd: workspace.workspaceDirectory,
});
const currentBranchOnDisk = execSync("git branch --show-current", {
cwd: repo.path,
stdio: "pipe",
})
.toString()
.trim();
expect(currentBranchOnDisk).toBe("feature-rename-2");
} finally {
await client.close();
await repo.cleanup();
}
});
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
const input = await openRenameModal(page, workspace.id);
await expect(input).toHaveValue("main");
await input.fill("taken");
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
await expect(errorNode).toBeVisible({ timeout: 15_000 });
await expect(errorNode).toContainText(/already exists|branch/i);
await expect(input).toBeVisible();
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { test, expect, type Page } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectArchiveTabDaemonClient,
createIdleAgent,
expectWorkspaceTabVisible,
} from "./helpers/archive-tab";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
test.describe("Workspace agent tab rename", () => {
test("right-click rename sends update_agent_request and updates the tab label", async ({
page,
}) => {
test.setTimeout(120_000);
const client = await connectArchiveTabDaemonClient();
const repo = await createTempGitRepo("workspace-agent-rename-");
try {
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
const agent = await createIdleAgent(client, {
cwd: repo.path,
title: initialTitle,
});
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
agentId: String(inner.agentId ?? ""),
name: String(inner.name ?? ""),
requestId: String(inner.requestId ?? ""),
}));
await openAgentInWorkspace(page, agent);
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
await expect(tab).toContainText(initialTitle, { timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
await expect(input).toHaveValue(initialTitle);
const renamed = "My Renamed Agent";
await input.fill(renamed);
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText(renamed, { timeout: 15_000 });
expect(updateFrames.length).toBeGreaterThan(0);
const lastFrame = updateFrames.at(-1)!;
expect(lastFrame.agentId).toBe(agent.id);
expect(lastFrame.name).toBe(renamed);
expect(lastFrame.requestId.length).toBeGreaterThan(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,113 @@
import { readFile, rm } from "node:fs/promises";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { injectDesktopBridge } from "./helpers/desktop-updates";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
interface EditorOpenRecord {
command: string;
args: string[];
}
function requireE2EEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is not set.`);
}
return value;
}
async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenRecord[]> {
try {
const contents = await readFile(recordPath, "utf8");
return contents
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as EditorOpenRecord);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-open-in-editor-caret").click();
await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible();
await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click();
}
async function expectEditorOpened(input: {
recordPath: string;
command: string;
workspacePath: string;
afterCount: number;
}): Promise<void> {
await expect
.poll(
async () => {
const records = await readEditorOpenRecords(input.recordPath);
return records
.slice(input.afterCount)
.some(
(record) =>
record.command === input.command && record.args.includes(input.workspacePath),
);
},
{ timeout: 30_000 },
)
.toBe(true);
}
test.describe("Workspace open in editor", () => {
test("keeps the selected editor target after leaving and returning to the workspace", async ({
page,
withWorkspace,
}) => {
test.setTimeout(90_000);
const serverId = requireE2EEnv("E2E_SERVER_ID");
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
await rm(recordPath, { force: true });
await injectDesktopBridge(page, { serverId });
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
await workspace.navigateTo();
await chooseEditorTarget(page, "vscode");
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
afterCount: 0,
});
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
await openSettings(page);
await clickSettingsBackToWorkspace(page);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
afterCount: recordsAfterSelection,
});
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
await gotoAppShell(page);
await workspace.navigateTo();
await page.getByTestId("workspace-open-in-editor-primary").click();
await expectEditorOpened({
recordPath,
command: "code",
workspacePath: workspace.repoPath,
afterCount: recordsAfterReturnOpen,
});
});
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
test.describe("Workspace terminal tab rename", () => {
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
page,
}) => {
test.setTimeout(60_000);
const client = await connectTerminalClient();
const repo = await createTempGitRepo("workspace-terminal-rename-");
try {
const seeded = await client.openProject(repo.path);
if (!seeded.workspace) {
throw new Error(seeded.error ?? "Failed to seed workspace");
}
const workspaceId = seeded.workspace.id;
const created = await client.createTerminal(repo.path);
if (!created.terminal) {
throw new Error(created.error ?? "Failed to create terminal");
}
const terminalId = created.terminal.id;
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
terminalId: String(inner.terminalId ?? ""),
title: String(inner.title ?? ""),
requestId: String(inner.requestId ?? ""),
}));
await navigateToTerminal(page, { workspaceId, terminalId });
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
await input.fill("My Renamed Terminal");
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
expect(renameFrames.length).toBeGreaterThan(0);
const lastFrame = renameFrames.at(-1)!;
expect(lastFrame.terminalId).toBe(terminalId);
expect(lastFrame.title).toBe("My Renamed Terminal");
expect(lastFrame.requestId.length).toBeGreaterThan(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -1,13 +1,14 @@
{
"name": "@getpaseo/app",
"version": "0.1.78",
"version": "0.1.81",
"private": true,
"main": "index.ts",
"scripts": {
"start": "cross-env APP_VARIANT=development expo start",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
@@ -41,15 +42,15 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
@@ -75,6 +76,7 @@
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -85,12 +87,12 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "0.33.8",
"react-native-nitro-modules": "0.35.5",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1",
@@ -102,11 +104,13 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^3.2.4",
"@xterm/headless": "^6.0.0",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
@@ -115,7 +119,7 @@
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
"vitest": "^4.1.6",
"wrangler": "^4.75.0",
"ws": "^8.20.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -3,10 +3,17 @@
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="robots" content="noindex,nofollow" />
<meta name="theme-color" content="#181B1A" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Paseo" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<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">

View File

@@ -0,0 +1,27 @@
{
"id": "/",
"name": "Paseo",
"short_name": "Paseo",
"description": "Monitor and control local AI coding agents from anywhere.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#181B1A",
"background_color": "#181B1A",
"categories": ["developer", "productivity", "utilities"],
"icons": [
{
"src": "/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,89 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
async function resolveTsPath(basePath) {
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js"),
path.join(basePath, "index.jsx"),
];
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// try next candidate
}
}
return basePath;
}
const aliasPlugin = {
name: "paseo-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
}));
build.onResolve({ filter: /^@server\// }, async (args) => ({
path: await resolveTsPath(
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
),
}));
},
};
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
loader: {
".css": "text",
},
plugins: [aliasPlugin],
logLevel: "info",
});
const js = result.outputFiles[0]?.text;
if (!js) {
throw new Error("terminal webview bundle produced no JavaScript");
}
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</head>
<body>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
// Do not edit by hand.
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);

View File

@@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => {
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
});
it("redirects to the host root when the persisted workspace targets a different server", () => {
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-2",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-2");
expect(route).toBeNull();
});
it("resolves the persisted workspace when another host is first online", () => {
const selection = resolveStartupWorkspaceSelection({
...baseInput,
anyOnlineHostServerId: "server-2",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
});
it("redirects to the host root when no persisted workspace exists", () => {

View File

@@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection(
if (!input.isWorkspaceSelectionLoaded) {
return null;
}
if (
!input.anyOnlineHostServerId ||
!input.workspaceSelection ||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
) {
if (!input.workspaceSelection) {
return null;
}
return input.workspaceSelection;

View File

@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostRuntimeBootstrapState } from "./_layout";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
const { redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
@@ -18,11 +18,11 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
return {
navigateToWorkspaceMock: vi.fn(),
redirectMock: vi.fn(),
state: hoistedState,
};
@@ -50,8 +50,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
useActiveWorkspaceSelection: () => state.workspaceSelection,
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
useLastWorkspaceSelection: () => state.workspaceSelection,
}));
describe("Index route startup navigation", () => {
@@ -68,8 +68,8 @@ describe("Index route startup navigation", () => {
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
navigateToWorkspaceMock.mockReset();
redirectMock.mockReset();
container = document.createElement("div");
@@ -98,26 +98,33 @@ describe("Index route startup navigation", () => {
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
});
it("navigates to the host root when the persisted workspace targets a different server", async () => {
it("restores the persisted workspace even when the first online host is different", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("navigates to the host root when no persisted workspace exists", async () => {

View File

@@ -7,10 +7,11 @@ import {
resolveStartupWorkspaceSelection,
} from "@/app/host-runtime-bootstrap";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
const isDesktop = shouldUseDesktopDaemon();
@@ -18,34 +19,33 @@ export default function Index() {
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const workspaceSelection = useActiveWorkspaceSelection();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
pathname,
anyOnlineHostServerId,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
React.useEffect(() => {
if (!startupWorkspaceSelection) {
return;
}
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
currentPathname: pathname,
});
}, [pathname, startupWorkspaceSelection]);
if (startupWorkspaceSelection) {
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
return (
<Redirect
href={buildHostWorkspaceRoute(
startupWorkspaceSelection.serverId,
startupWorkspaceSelection.workspaceId,
)}
/>
);
}
if (redirectRoute) {

View File

@@ -27,6 +27,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
deepagents:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
"deepseek-tui":
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
dimcode:
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
dirac:

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<rect width="32" height="32" fill="currentColor" opacity="0.18"/>
<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="'Noto Serif SC', serif" font-weight="700" font-size="20" fill="currentColor"></text>
<rect x="0" y="29" width="32" height="3" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B

View File

@@ -1,6 +1,5 @@
export {
AssistantInlineCodePathLink,
AssistantInlinePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
@@ -9,5 +8,9 @@ export {
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
export {
AssistantFileLinkResolverProvider,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkResolver } from "./use-resolver";
export { useAssistantFileLinkActions } from "./use-file-link";

View File

@@ -1,11 +1,4 @@
import {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type MouseEvent,
} from "react";
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Pressable,
Text,
@@ -16,128 +9,66 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
interface AssistantInlinePathLinkProps {
content: string;
parsed: InlinePathTarget;
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string;
style: StyleProp<TextStyle>;
}
export function AssistantInlinePathLink({
content,
parsed,
onPress,
workspaceRoot,
style,
}: AssistantInlinePathLinkProps) {
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(parsed, "side");
},
[onPress, parsed],
);
if (!isNative) {
return (
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
<a
href={parsed.path}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
</a>
</FileLinkHoverTooltip>
);
}
return (
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
);
}
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
export function AssistantMarkdownLink({
source,
style,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownLinkProps) {
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const href = source.href;
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(source, "side");
},
[onPress, source],
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
[target, workspaceRoot],
);
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
const handleHoverIn = useCallback(() => {
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onAuxPress();
});
const handleHoverIn = useStableEvent(() => {
setHovered(true);
handlePrefetch();
}, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
onHoverIn();
});
const handleHoverOut = useStableEvent(() => setHovered(false));
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const tooltipFilePath = useMemo(
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
[source.href, workspaceRoot],
);
if (isNative) {
return (
<Text accessibilityRole="link" onPress={handlePress} style={style}>
{children}
</Text>
<FileLinkHoverTooltip filePath={tooltipPath}>
<Text accessibilityRole="link" onPress={onPress} style={style}>
{children}
</Text>
</FileLinkHoverTooltip>
);
}
const anchor = (
<a
href={href}
href={source.href}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
onPress={handlePress}
onFocus={handlePrefetch}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
</a>
);
if (tooltipFilePath) {
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
}
return anchor;
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
}
interface AssistantMarkdownCodeLinkProps {
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink
source={source}
style={style}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
<AssistantMarkdownLink source={source} style={style}>
{children}
</AssistantMarkdownLink>
);
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{content}
</AssistantMarkdownCodeLink>
);
}
function getMarkdownLinkTooltipFilePath(
href: string,
workspaceRoot: string | undefined,
): string | null {
const classification = classifyAssistantFileLink(href, { workspaceRoot });
if (classification?.kind !== "directFile") {
return null;
}
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
}
function formatInlinePathTargetForTooltip(
target: InlinePathTarget,
target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
return filePath;
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
>
{content}
</AssistantMarkdownCodeLink>
);
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow.
@@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
function FileLinkHoverTooltip({
filePath,
children,
}: {
filePath: string | null;
children: ReactNode;
}) {
if (!isWeb) {
return children;
}
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
<TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger>
<TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
{filePath ? (
<TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
</Text>
</View>
</View>
</View>
</TooltipContent>
</TooltipContent>
) : null}
</Tooltip>
);
}

View File

@@ -0,0 +1,87 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
type MutableRefObject,
type ReactNode,
} from "react";
import React from "react";
import type { ToastApi } from "@/components/toast-host";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
getDirectorySuggestions: GetDirectorySuggestions;
}
export interface AssistantFileLinkResolverConfig {
client?: AssistantFileLinkDaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig {
children: ReactNode;
}
export interface AssistantFileLinkResolverContextValue {
configRef: MutableRefObject<AssistantFileLinkResolverConfig>;
getDirectorySuggestions: GetDirectorySuggestions;
}
const AssistantFileLinkResolverContext =
createContext<AssistantFileLinkResolverContextValue | null>(null);
export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
children,
}: AssistantFileLinkResolverProviderProps) {
const configRef = useRef<AssistantFileLinkResolverConfig>({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
});
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
const activeClient = configRef.current.client;
if (!activeClient) {
return { entries: [], error: null };
}
const result = await activeClient.getDirectorySuggestions(input);
return { entries: result.entries, error: result.error };
}, []);
const value = useMemo<AssistantFileLinkResolverContextValue>(
() => ({ configRef, getDirectorySuggestions }),
[getDirectorySuggestions],
);
return (
<AssistantFileLinkResolverContext.Provider value={value}>
{children}
</AssistantFileLinkResolverContext.Provider>
);
}
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
const context = useContext(AssistantFileLinkResolverContext);
if (!context) {
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
}
return context;
}
export type { AssistantFileLinkContext };

View File

@@ -1,16 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistantFileLinkResolver,
classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken,
UnresolvedFileLinkError,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
type GetDirectorySuggestions,
} from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
};
@@ -20,554 +20,201 @@ function resolvedSuggestions(
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
getDirectorySuggestions: GetDirectorySuggestions;
searches: Array<{
query: string;
cwd: string;
matchMode: "suffix";
limit: number;
}>;
} {
const searches: Array<{
query: string;
cwd: string;
matchMode: "suffix";
limit: number;
}> = [];
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(
async (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => {
searches.push({
query: input.query,
cwd: input.cwd,
matchMode: input.matchMode,
limit: input.limit,
});
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
},
);
return { getDirectorySuggestions, searches };
}
interface DirectorySearch {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
describe("classifyForResolution", () => {
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
class FakeWorkspaceFiles {
readonly searches: DirectorySearch[] = [];
readonly openedFiles: OpenedFile[] = [];
readonly unresolvedTokens: string[] = [];
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
},
});
}
});
private getDirectorySuggestions = async (
search: DirectorySearch,
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
it("preserves line ranges on direct workspace files", () => {
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
private openWorkspaceFile = (
target: InlinePathTarget,
disposition: OpenFileDisposition,
): void => {
this.openedFiles.push({ target, disposition });
};
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
},
});
});
private onUnresolvedFileCandidate = (token: string): void => {
this.unresolvedTokens.push(token);
};
}
describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
const result = classifyForResolution(
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
CONTEXT,
);
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await Promise.all([
resolver.prefetch({ context: CONTEXT, source }),
resolver.prefetch({ context: CONTEXT, source }),
]);
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "file.ts",
token: "file.ts:12",
target: {
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
},
"main",
});
});
it("keeps explicit external URLs external", () => {
const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: { kind: "external", url: "http://dumm.md" },
});
});
it("keeps auto-linkified normal domains external", () => {
const result = classifyForResolution(
{ href: "http://google.com", text: "google.com", markup: "linkify" },
CONTEXT,
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(result.opened).toBe(true);
expect(result).toEqual({
kind: "resolved",
value: { kind: "external", url: "http://google.com" },
});
});
it("click consumes an in-flight hover resolution", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const suggestions = vi.fn(() => deferred.promise);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
it("returns ignored for non-file-looking content", () => {
const result = classifyForResolution({ href: "" }, CONTEXT);
const prefetch = resolver.prefetch({ context: CONTEXT, source });
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
});
it("retries a click after hover prefetch fails to query suggestions", async () => {
const suggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(openResult.opened).toBe(true);
});
it("does not cache unresolved candidates", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(first).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
expect(second.opened).toBe(true);
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
});
it("keys cache entries by server, workspace, and token", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await resolver.open({ context: CONTEXT, source, disposition: "main" });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
disposition: "main",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
{
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
});
it("does not apply stale async results after the active context changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
let isCurrent = true;
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(() => deferred.promise),
openWorkspaceFile,
openExternalUrl: vi.fn(),
isCurrentContext: () => isCurrent,
});
const opened = resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
isCurrent = false;
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
const result = await opened;
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(result.opened).toBe(false);
expect(result.kind).toBe("file");
});
it("opens direct workspace file links without querying suggestions", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("preserves direct workspace file line ranges", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx:33-40" },
disposition: "main",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
"main",
);
});
it("opens basename line refs when the daemon returns that exact filename", async () => {
const workspaceFiles = new FakeWorkspaceFiles({
describe("fetchDaemonResolution", () => {
it("resolves daemon suggestions into workspace file targets", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "file.ts:12",
text: "file.ts:12",
sourceType: "inline-code",
const result = await fetchDaemonResolution({
ambiguousQuery: "file.ts",
token: "file.ts:12",
target: {
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
},
disposition: "main",
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(workspaceFiles.searches).toEqual([
expect(searches).toEqual([
{
query: "file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([
{
expect(result).toEqual({
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("throws a typed unresolved error when the daemon finds no match", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
fetchDaemonResolution({
ambiguousQuery: "src/file.ts",
token: "src/file.ts",
target: {
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
raw: "src/file.ts",
path: "/Users/test/project/src/file.ts",
lineStart: undefined,
lineEnd: undefined,
},
disposition: "main",
},
]);
expect(result.opened).toBe(true);
});
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
const workspaceFiles = new FakeWorkspaceFiles({});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "src/file.ts",
text: "src/file.ts",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "src/file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([]);
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "src/file.ts",
opened: false,
});
});
it("passes side open disposition to workspace file links", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "side",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"side",
);
});
it("keeps explicit external URLs external", async () => {
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md" },
disposition: "main",
});
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
expect(result).toEqual({
kind: "external",
url: "http://dumm.md",
opened: true,
});
});
it("keeps auto-linkified normal domains external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
expect(result).toEqual({
kind: "external",
url: "http://google.com",
opened: true,
});
});
it("keeps auto-linkified normal domain paths external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
expect(result).toEqual({
kind: "external",
url: "http://openai.com/path",
opened: true,
});
});
it("does not open unresolved linkified markdown filenames in the browser", async () => {
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const prefetchResult = await resolver.prefetch({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
disposition: "main",
});
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(openExternalUrl).not.toHaveBeenCalled();
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("keeps failed ambiguous resolution out of the browser", async () => {
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => {
throw new Error("daemon unavailable");
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
openWorkspaceFile: vi.fn(),
openExternalUrl,
onUnresolvedFileCandidate,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
});
it("throws a typed unresolved error when the daemon throws", async () => {
const getDirectorySuggestions = vi.fn<GetDirectorySuggestions>(async () => {
throw new Error("daemon unavailable");
});
await expect(
fetchDaemonResolution({
ambiguousQuery: "dumm.md",
token: "dumm.md",
target: {
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
});
});
describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
getAssistantFileLinkToken({

View File

@@ -4,12 +4,6 @@ import {
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource {
href: string;
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
sourceType?: "inline-code";
}
export interface AssistantFileLinkContext {
workspaceRoot?: string;
}
export interface DirectorySuggestionEntry {
path: string;
kind: "file" | "directory";
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
error: string | null;
}
export interface AssistantFileLinkResolverDependencies {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkPrefetchInput {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
disposition: OpenFileDisposition;
}
export type GetDirectorySuggestions = (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
export type ResolvedAssistantFileLink =
| { kind: "external"; url: string }
| { kind: "file"; target: InlinePathTarget }
| { kind: "ignored" };
export type AssistantFileLinkResolution =
| { kind: "resolved"; value: ResolvedAssistantFileLink }
| {
kind: "external";
url: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
kind: "needsLookup";
ambiguousQuery: string;
token: string;
}
| {
kind: "ignored";
target: InlinePathTarget;
};
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
opened: boolean;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
export interface FetchDaemonResolutionInput {
ambiguousQuery: string;
token: string;
classification: AssistantFileLinkClassification;
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}
export function createAssistantFileLinkResolver(
dependencies: AssistantFileLinkResolverDependencies,
): AssistantFileLinkResolver {
const cache = new Map<string, CachedAssistantFileLink>();
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
export class UnresolvedFileLinkError extends Error {
constructor(readonly token: string) {
super(`No file found for ${token}`);
this.name = "UnresolvedFileLinkError";
}
}
async function resolve(
input: AssistantFileLinkPrefetchInput,
): Promise<ResolvedAssistantFileLink> {
const parsed = parseInteraction(input);
if (!parsed) {
return { kind: "ignored" };
}
export async function fetchDaemonResolution({
ambiguousQuery,
token,
target,
workspaceRoot,
getDirectorySuggestions,
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
const trimmedRoot = workspaceRoot?.trim();
if (!trimmedRoot) {
throw new UnresolvedFileLinkError(token);
}
if (parsed.classification.kind === "external") {
return { kind: "external", url: parsed.classification.raw };
}
let suggestions: DirectorySuggestionResult;
try {
suggestions = await getDirectorySuggestions({
query: ambiguousQuery,
cwd: trimmedRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
} catch {
throw new UnresolvedFileLinkError(token);
}
if (
parsed.classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context: input.context,
source: input.source,
token: parsed.token,
target: parsed.classification.target,
})
) {
return { kind: "file", target: parsed.classification.target };
}
const key = getResolutionKey(input.context, parsed.token);
const cached = cache.get(key);
if (cached) {
return cached;
}
const active = inFlight.get(key);
if (active) {
return active;
}
const request = resolveAmbiguousCandidate({
context: input.context,
token: parsed.token,
target: parsed.classification.target,
getDirectorySuggestions: dependencies.getDirectorySuggestions,
})
.then((result) => {
if (result.kind === "file") {
cache.set(key, result);
}
inFlight.delete(key);
return result;
})
.catch((): CachedAssistantFileLink => {
inFlight.delete(key);
return { kind: "unresolvedFileCandidate", token: parsed.token };
});
inFlight.set(key, request);
return request;
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
throw new UnresolvedFileLinkError(token);
}
return {
prefetch(input) {
return resolve(input);
},
async open(input) {
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
...target,
path: joinWorkspacePath(trimmedRoot, match.path),
};
}
if (resolved.kind === "file") {
dependencies.openWorkspaceFile(resolved.target, input.disposition);
return { ...resolved, opened: true };
}
export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const token = getAssistantFileLinkToken(source).trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (resolved.kind === "external") {
await dependencies.openExternalUrl(resolved.url);
return { ...resolved, opened: true };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (classification.kind === "external") {
return { kind: "resolved", value: { kind: "external", url: classification.raw } };
}
if (
classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context,
source,
token,
target: classification.target,
})
) {
return { kind: "resolved", value: { kind: "file", target: classification.target } };
}
if (resolved.kind === "unresolvedFileCandidate") {
dependencies.onUnresolvedFileCandidate?.(resolved.token);
}
const workspaceRoot = context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "resolved", value: { kind: "ignored" } };
}
return { ...resolved, opened: false };
},
return {
kind: "needsLookup",
ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot),
token,
target: classification.target,
};
}
@@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri
return source.href;
}
function parseInteraction(
input: AssistantFileLinkPrefetchInput,
): ParsedAssistantFileLinkInteraction | null {
const token = getAssistantFileLinkToken(input.source).trim();
if (!token) {
return null;
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: input.context.workspaceRoot,
});
if (!classification) {
return null;
}
return { token, classification };
}
async function resolveAmbiguousCandidate(input: {
context: AssistantFileLinkContext;
token: string;
target: InlinePathTarget;
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
}): Promise<CachedAssistantFileLink> {
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
const suggestions = await input.getDirectorySuggestions({
query,
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
return {
kind: "file",
target: {
...input.target,
path: joinWorkspacePath(workspaceRoot, match.path),
},
};
}
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
export function getAmbiguousSuggestionQuery(
target: InlinePathTarget,
workspaceRoot: string,
): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
}
function shouldResolveDirectFileThroughSuggestions(input: {
export function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
token: string;
@@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean {
);
}
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
return source.markup === "linkify" || source.sourceInfo === "auto";
}
function canApplyResult(
context: AssistantFileLinkContext,
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
): boolean {
return isCurrentContext ? isCurrentContext(context) : true;
}
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return root ? `${root}/${child}` : child;
}
export type { AssistantFileLinkClassification };

View File

@@ -0,0 +1,329 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
import { useFileLink } from "./use-file-link";
import type { OpenFileDisposition } from "@/workspace/file-open";
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(async () => {}),
}));
const SOURCE = {
href: "http://dumm.md",
text: "dumm.md",
markup: "linkify",
};
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
interface TestClient {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
}
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createToast(): ToastApi {
return {
show: vi.fn<ToastApi["show"]>(),
copied: vi.fn<ToastApi["copied"]>(),
error: vi.fn<ToastApi["error"]>(),
};
}
function createWrapper(input: { client: TestClient; openedFiles: OpenedFile[]; toast?: ToastApi }) {
const queryClient = createQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
const openWorkspaceFile = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
input.openedFiles.push({ target, disposition });
},
[],
);
return (
<QueryClientProvider client={queryClient}>
<AssistantFileLinkResolverProvider
client={input.client}
serverId="server-1"
workspaceRoot="/Users/test/project"
onOpenWorkspaceFile={openWorkspaceFile}
toast={input.toast}
>
{children}
</AssistantFileLinkResolverProvider>
</QueryClientProvider>
);
};
}
describe("useFileLink", () => {
it("returns the same object across no-op parent rerenders", () => {
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
const queryClient = createQueryClient();
const Provider = AssistantFileLinkResolverProvider as React.ComponentType<
Omit<React.ComponentProps<typeof AssistantFileLinkResolverProvider>, "children"> & {
children?: ReactNode;
}
>;
function ChurningProviderWrapper({ children }: { children: ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
Provider,
{
client: { getDirectorySuggestions },
serverId: "server-1",
workspaceRoot: "/Users/test/project",
onOpenWorkspaceFile: () => {},
toast: createToast(),
},
children,
),
);
}
const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), {
wrapper: ChurningProviderWrapper,
});
const first = result.current;
rerender();
expect(result.current).toBe(first);
expect(result.current.onHoverIn).toBe(first.onHoverIn);
expect(result.current.onPress).toBe(first.onPress);
expect(result.current.onAuxPress).toBe(first.onAuxPress);
expect(result.current.open).toBe(first.open);
});
it("does not cache unresolved lookups forever", async () => {
const getDirectorySuggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const toast = createToast();
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
toast,
}),
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toEqual([
{
target: {
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
disposition: "main",
},
]);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
});
it("click retries after hover prefetch fails", async () => {
const getDirectorySuggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
});
act(() => {
result.current.onHoverIn();
});
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toHaveLength(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
});
it("dedupes two links pointing at the same source", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const getDirectorySuggestions = vi.fn(() => deferred.promise);
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(
() => ({
first: useFileLink(SOURCE),
second: useFileLink(SOURCE),
}),
{
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
},
);
act(() => {
result.current.first.onHoverIn();
result.current.second.onHoverIn();
});
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await waitFor(() => {
expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md");
expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md");
});
});
it("hover then click uses the prefetched result", async () => {
const getDirectorySuggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
});
act(() => {
result.current.onHoverIn();
});
await waitFor(() => {
expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md");
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toHaveLength(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
it("does not open a stale result after the workspace changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const getDirectorySuggestions = vi.fn(() => deferred.promise);
const openedFiles: OpenedFile[] = [];
const queryClient = createQueryClient();
function Wrapper({ children }: { children: ReactNode }) {
const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project");
const client = useMemo(() => ({ getDirectorySuggestions }), []);
const openWorkspaceFile = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
openedFiles.push({ target, disposition });
},
[],
);
return (
<QueryClientProvider client={queryClient}>
<AssistantFileLinkResolverProvider
client={client}
serverId="server-1"
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={openWorkspaceFile}
>
<WorkspaceSwitchContext.Provider value={setWorkspaceRoot}>
{children}
</WorkspaceSwitchContext.Provider>
</AssistantFileLinkResolverProvider>
</QueryClientProvider>
);
}
const { result } = renderHook(
() => ({
link: useFileLink(SOURCE),
setWorkspaceRoot: React.useContext(WorkspaceSwitchContext),
}),
{ wrapper: Wrapper },
);
act(() => {
result.current.link.onPress();
});
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(openedFiles).toEqual([]);
});
});
const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {});

View File

@@ -0,0 +1,347 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
type AssistantFileLinkResolverContextValue,
} from "./provider";
import {
classifyForResolution,
fetchDaemonResolution,
UnresolvedFileLinkError,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
} from "./resolver";
export interface UseFileLinkResult {
target: InlinePathTarget | null;
onHoverIn: () => void;
onPress: () => void;
onAuxPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
}
export interface AssistantFileLinkActions {
open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void;
canOpen(source: AssistantFileLinkSource): boolean;
canResolveFile(source: AssistantFileLinkSource): boolean;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
string,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const context = useAssistantFileLinkResolverContext();
const queryClient = useQueryClient();
const stableSource = useStableSource(source);
const activeConfig = context.configRef.current;
const workspaceRoot = activeConfig.workspaceRoot;
const serverId = activeConfig.serverId;
const resolution = useMemo(
() =>
classifyForResolution(stableSource, {
workspaceRoot,
}),
[stableSource, workspaceRoot],
);
const queryKey = useMemo(
() =>
resolution.kind === "needsLookup"
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
ambiguousQuery: resolution.ambiguousQuery,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
);
const query = useQuery({
queryKey,
queryFn: () => {
if (resolution.kind !== "needsLookup") {
throw new Error("Assistant file link lookup requested for a sync link.");
}
return fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot,
getDirectorySuggestions: context.getDirectorySuggestions,
});
},
enabled: false,
retry: 0,
staleTime: Infinity,
});
const open = useStableEvent(
(nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
openAssistantFileLink({
source: nextSource,
disposition,
context,
queryClient,
});
},
);
const onHoverIn = useStableEvent(() => {
if (resolution.kind !== "needsLookup") {
return;
}
void queryClient.prefetchQuery({
queryKey,
queryFn: () =>
fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot,
getDirectorySuggestions: context.getDirectorySuggestions,
}),
retry: 0,
staleTime: Infinity,
});
});
const onPress = useStableEvent(() => {
open(stableSource, "main");
});
const onAuxPress = useStableEvent(() => {
open(stableSource, "side");
});
const target = useMemo(() => {
if (resolution.kind === "resolved") {
return resolution.value.kind === "file" ? resolution.value.target : null;
}
return query.data ?? null;
}, [query.data, resolution]);
return useMemo(
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
[target, onHoverIn, onPress, onAuxPress, open],
);
}
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
const context = useAssistantFileLinkResolverContext();
const actionLink = useFileLink(ACTION_LINK_SOURCE);
const open = useStableEvent(
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
actionLink.open(source, disposition);
},
);
const canOpen = useCallback(
(source: AssistantFileLinkSource) =>
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
const canResolveFile = useCallback(
(source: AssistantFileLinkSource) =>
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]);
}
function openAssistantFileLink(input: {
source: AssistantFileLinkSource;
disposition: OpenFileDisposition;
context: AssistantFileLinkResolverContextValue;
queryClient: ReturnType<typeof useQueryClient>;
}): void {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
workspaceRoot: capturedConfig.workspaceRoot,
});
if (capturedResolution.kind === "resolved") {
void dispatchResolvedLink({
resolution: capturedResolution,
disposition: input.disposition,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
return;
}
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
ambiguousQuery: capturedResolution.ambiguousQuery,
});
const run = async () => {
try {
const target = await input.queryClient.fetchQuery({
queryKey: capturedQueryKey,
queryFn: () =>
fetchDaemonResolution({
ambiguousQuery: capturedResolution.ambiguousQuery,
token: capturedResolution.token,
target: capturedResolution.target,
workspaceRoot: capturedConfig.workspaceRoot,
getDirectorySuggestions: input.context.getDirectorySuggestions,
}),
retry: 0,
staleTime: Infinity,
});
await dispatchFileTarget({
target,
disposition: input.disposition,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
} catch (error) {
await dispatchUnresolvedError({
error,
fallbackToken: capturedResolution.token,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
}
};
void run();
}
function canOpenAssistantFileLink(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
}
function canResolveAssistantFileLinkToFile(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
}
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
const { href, text, markup, sourceInfo, sourceType } = source;
return useMemo(
() => ({ href, text, markup, sourceInfo, sourceType }),
[href, text, markup, sourceInfo, sourceType],
);
}
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
ambiguousQuery: string;
}): AssistantFileLinkQueryKey {
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
input.ambiguousQuery,
];
}
async function dispatchResolvedLink(input: {
resolution: Extract<AssistantFileLinkResolution, { kind: "resolved" }>;
disposition: OpenFileDisposition;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const { value } = input.resolution;
if (value.kind === "file") {
await dispatchFileTarget({
target: value.target,
disposition: input.disposition,
capturedServerId: input.capturedServerId,
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
context: input.context,
});
return;
}
if (value.kind === "external") {
await dispatchExternalUrl({
url: value.url,
capturedServerId: input.capturedServerId,
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
context: input.context,
});
}
}
async function dispatchFileTarget(input: {
target: InlinePathTarget;
disposition: OpenFileDisposition;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
current.onOpenWorkspaceFile?.(input.target, input.disposition);
}
async function dispatchExternalUrl(input: {
url: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
await openExternalUrl(input.url);
}
async function dispatchUnresolvedError(input: {
error: unknown;
fallbackToken: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
const token =
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
current.toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
}
const ACTION_LINK_SOURCE: AssistantFileLinkSource = {
href: "",
};

View File

@@ -1,90 +0,0 @@
import { useMemo, useRef } from "react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ToastApi } from "@/components/toast-host";
import {
createAssistantFileLinkResolver,
type AssistantFileLinkContext,
type AssistantFileLinkOpenInput,
type AssistantFileLinkPrefetchInput,
} from "./resolver";
import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {
client?: DaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkActions {
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
}
export function useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
const context: AssistantFileLinkContext = useMemo(
() => ({
serverId,
workspaceRoot,
}),
[serverId, workspaceRoot],
);
const latestContextRef = useRef(context);
latestContextRef.current = context;
const resolver = useMemo(
() =>
createAssistantFileLinkResolver({
async getDirectorySuggestions(input) {
if (!client) {
return { entries: [], error: null };
}
const result = await client.getDirectorySuggestions(input);
return {
entries: result.entries,
error: result.error,
};
},
openWorkspaceFile(target, disposition) {
onOpenWorkspaceFile?.(target, disposition);
},
openExternalUrl,
onUnresolvedFileCandidate(token) {
toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
},
isCurrentContext(candidate) {
const current = latestContextRef.current;
return (
current.serverId === candidate.serverId &&
current.workspaceRoot === candidate.workspaceRoot
);
},
}),
[client, onOpenWorkspaceFile, toast],
);
return useMemo(
() => ({
prefetch(input) {
void resolver.prefetch({ ...input, context });
},
open(input) {
void resolver.open({ ...input, context });
},
}),
[context, resolver],
);
}

View File

@@ -57,33 +57,41 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
}
describe("resolveClientSlashCommand", () => {
it("declares the exact client commands that execute immediately", () => {
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
["quit", "immediate"],
["exit", "immediate"],
["q", "immediate"],
["clear", "immediate"],
["new", "immediate"],
it("declares the exact canonical client commands with their aliases", () => {
expect(
CLIENT_SLASH_COMMANDS.map((command) => [
command.name,
[...command.aliases],
command.execution,
]),
).toEqual([
["exit", ["quit", "q"], "immediate"],
["clear", ["new"], "immediate"],
]);
});
it("resolves exact client commands after trimming", () => {
it("resolves canonical names and aliases after trimming", () => {
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
execution: "immediate",
});
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
});
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
});
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })).toMatchObject({
name: "clear",
kind: "replace-agent-with-draft",
});
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })).toMatchObject({
name: "clear",
kind: "replace-agent-with-draft",
});
});
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {

View File

@@ -6,6 +6,7 @@ export type ClientSlashCommandExecution = "immediate" | "insert";
export interface ClientSlashCommand {
name: string;
aliases: readonly string[];
description: string;
argumentHint: string;
kind: ClientSlashCommandKind;
@@ -13,22 +14,9 @@ export interface ClientSlashCommand {
}
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
{
name: "quit",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "exit",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "q",
aliases: ["quit", "q"],
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
@@ -36,13 +24,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
{
name: "clear",
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
},
{
name: "new",
aliases: ["new"],
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
@@ -50,7 +32,13 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
];
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
const COMMAND_BY_NAME = new Map<string, ClientSlashCommand>();
for (const command of CLIENT_SLASH_COMMANDS) {
COMMAND_BY_NAME.set(command.name, command);
for (const alias of command.aliases) {
COMMAND_BY_NAME.set(alias, command);
}
}
export function resolveClientSlashCommand(input: {
text: string;

View File

@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
@@ -202,6 +202,15 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
},
adaptiveInputText: {
color: theme.colors.foreground,
},
adaptiveInputPlaceholder: {
color: theme.colors.foregroundMuted,
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
@@ -231,25 +240,43 @@ export type AdaptiveTextInputProps = TextInputProps & {
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
// can seed it once with initialValue and remount with resetKey for real resets.
// See https://github.com/facebook/react-native/issues/44157
//
// Text color and placeholder color are owned by this leaf — not the caller.
// `@gorhom/bottom-sheet` mounts header subtrees before the sheet is visible
// under whatever theme is active at mount time, then keeps them mounted across
// theme changes; any caller that paints color via `StyleSheet.create((theme) =>
// ...)` from outside this leaf ends up with stale colors in dark mode (see
// docs/unistyles.md "Hidden Sheet Content"). withUnistyles wraps the actual
// TextInput so theme-driven re-renders land on the wrapper.
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
const { value: _value, initialValue, resetKey, defaultValue, style, ...inputProps } = props;
// Leaf-owned color goes LAST so callers cannot override it with a stale
// theme read. Outline color is theme-aware on web :focus-visible.
const textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
};
if (isMobile && isNative) {
return (
<BottomSheetTextInput
<ThemedBottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
@@ -325,7 +352,6 @@ export function SheetHeaderView({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
@@ -381,7 +407,6 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}

View File

@@ -489,7 +489,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.palette.white} />
<Check size={14} color={theme.colors.accentForeground} />
</View>
) : null}
</View>

View File

@@ -32,6 +32,10 @@ import {
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import {
buildProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -93,8 +97,7 @@ interface ControlledAgentStatusBarProps {
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions: AgentProviderDefinition[];
allProviderModels?: Map<string, AgentModelDefinition[]>;
canSelectModelProvider?: (providerId: string) => boolean;
modelSelectorProviders?: ProviderSelectorProvider[];
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
@@ -114,7 +117,7 @@ export interface DraftAgentStatusBarProps {
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
allProviderModels: Map<string, AgentModelDefinition[]>;
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
@@ -184,10 +187,6 @@ const MODE_ICONS = {
ShieldQuestionMark,
} as const;
function alwaysTrue() {
return true;
}
function resolveDisplayModel(
isModelLoading: boolean,
modelOptions: StatusOption[] | undefined,
@@ -235,23 +234,29 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
return (options ?? []).map((o) => ({ id: o.id, label: o.label }));
}
function buildFallbackAllProviderModels(
function buildFallbackModelSelectorProviders(
provider: string,
modelOptions: StatusOption[] | undefined,
): Map<string, AgentModelDefinition[]> {
const map = new Map<string, AgentModelDefinition[]>();
): ProviderSelectorProvider[] {
if (!modelOptions || modelOptions.length === 0) {
return map;
return [];
}
map.set(
provider,
modelOptions.map((option) => ({
provider: provider,
id: option.id,
label: option.label,
})),
);
return map;
return [
{
id: provider,
label: provider,
modelSelection: {
kind: "models",
rows: modelOptions.map((option) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
provider,
providerLabel: provider,
modelId: option.id,
modelLabel: option.label,
})),
},
},
];
}
function makeBadgePressableStyle(
@@ -459,8 +464,7 @@ function ControlledStatusBar({
disabled = false,
isModelLoading = false,
providerDefinitions,
allProviderModels,
canSelectModelProvider,
modelSelectorProviders,
favoriteKeys = new Set<string>(),
onToggleFavoriteModel,
features,
@@ -521,13 +525,11 @@ function ControlledStatusBar({
() => toComboboxOptions(modeOptions),
[modeOptions],
);
const fallbackAllProviderModels = useMemo(
() => buildFallbackAllProviderModels(provider, modelOptions),
const fallbackModelSelectorProviders = useMemo(
() => buildFallbackModelSelectorProviders(provider, modelOptions),
[modelOptions, provider],
);
const effectiveProviderDefinitions = providerDefinitions;
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
const canSelectProviderInModelMenu = canSelectModelProvider ?? alwaysTrue;
const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders;
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
() => toComboboxOptions(thinkingOptions),
[thinkingOptions],
@@ -701,10 +703,8 @@ function ControlledStatusBar({
canSelectMode={canSelectMode}
canSelectModel={canSelectModel}
canSelectThinking={canSelectThinking}
canSelectProviderInModelMenu={canSelectProviderInModelMenu}
modelSelectorProviders={effectiveModelSelectorProviders}
modelDisabled={modelDisabled}
effectiveProviderDefinitions={effectiveProviderDefinitions}
effectiveAllProviderModels={effectiveAllProviderModels}
comboboxProviderOptions={comboboxProviderOptions}
comboboxModeOptions={comboboxModeOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
@@ -751,10 +751,8 @@ function ControlledStatusBar({
canSelectMode={canSelectMode}
canSelectModel={canSelectModel}
canSelectThinking={canSelectThinking}
canSelectProviderInModelMenu={canSelectProviderInModelMenu}
modelSelectorProviders={effectiveModelSelectorProviders}
modelDisabled={modelDisabled}
effectiveProviderDefinitions={effectiveProviderDefinitions}
effectiveAllProviderModels={effectiveAllProviderModels}
comboboxModeOptions={comboboxModeOptions}
comboboxThinkingOptions={comboboxThinkingOptions}
ModeIconComponent={ModeIconComponent}
@@ -799,10 +797,8 @@ interface DesktopStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
canSelectProviderInModelMenu: (providerId: string) => boolean;
modelSelectorProviders: ProviderSelectorProvider[];
modelDisabled: boolean;
effectiveProviderDefinitions: AgentProviderDefinition[];
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
comboboxProviderOptions: ComboboxOption[];
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
@@ -868,10 +864,8 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
canSelectMode,
canSelectModel,
canSelectThinking,
canSelectProviderInModelMenu,
modelSelectorProviders,
modelDisabled,
effectiveProviderDefinitions,
effectiveAllProviderModels,
comboboxProviderOptions,
comboboxModeOptions,
comboboxThinkingOptions,
@@ -942,11 +936,9 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
<TooltipTrigger asChild triggerRefProp="ref">
<View>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
providers={modelSelectorProviders}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleDesktopModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
@@ -1069,10 +1061,8 @@ interface SheetStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
canSelectProviderInModelMenu: (providerId: string) => boolean;
modelSelectorProviders: ProviderSelectorProvider[];
modelDisabled: boolean;
effectiveProviderDefinitions: AgentProviderDefinition[];
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
@@ -1118,10 +1108,8 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
canSelectMode,
canSelectModel,
canSelectThinking,
canSelectProviderInModelMenu,
modelSelectorProviders,
modelDisabled,
effectiveProviderDefinitions,
effectiveAllProviderModels,
comboboxModeOptions,
comboboxThinkingOptions,
ModeIconComponent,
@@ -1214,11 +1202,9 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
<>
{canSelectModel ? (
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
providers={modelSelectorProviders}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
@@ -1678,6 +1664,14 @@ export const AgentStatusBar = memo(function AgentStatusBar({
() => buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models],
);
const agentModelSelectorProviders = useMemo(
() =>
buildProviderSelectorProviders({
providerDefinitions: agentProviderDefinitions,
modelsByProvider: agentProviderModels,
}),
[agentProviderDefinitions, agentProviderModels],
);
const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId);
@@ -1841,7 +1835,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
modeOptions={fallbackModeOptions}
selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels}
modelSelectorProviders={agentModelSelectorProviders}
onSelectMode={handleSelectMode}
modelOptions={modelOptions}
selectedModelId={modelSelection.activeModelId ?? undefined}
@@ -1872,7 +1866,7 @@ export function DraftAgentStatusBar({
selectedModel,
onSelectModel,
isModelLoading: _isModelLoading,
allProviderModels,
modelSelectorProviders,
isAllModelsLoading,
onSelectProviderAndModel,
thinkingOptions,
@@ -1937,8 +1931,7 @@ export function DraftAgentStatusBar({
return (
<View style={styles.container}>
<CombinedModelSelector
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
providers={modelSelectorProviders}
selectedProvider={selectedProvider ?? ""}
selectedModel={selectedModel}
onSelect={onSelectProviderAndModel}
@@ -1973,7 +1966,7 @@ export function DraftAgentStatusBar({
<ControlledStatusBar
provider={selectedProvider ?? ""}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
modelSelectorProviders={modelSelectorProviders}
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
selectedModeId={effectiveSelectedMode}
onSelectMode={onSelectMode}

View File

@@ -74,7 +74,10 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/assistant-file-links";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
@@ -83,6 +86,7 @@ import {
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setExpandedInlineToolCallIds(new Set());
}, [agentId]);
const handleInlinePathPress = useCallback(
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
checkout,
});
},
[
agent.cwd,
agent.projectPlacement?.checkout?.isGit,
isMobile,
openFileExplorerForCheckout,
onOpenWorkspaceFile,
requestDirectoryListing,
resolvedServerId,
setExplorerTabForCheckout,
workspaceId,
],
);
const handleToolCallOpenFile = useCallback(
(filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
},
[handleInlinePathPress],
);
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
});
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
belowItem,
});
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
<AssistantFileLinkResolverProvider
client={client}
serverId={resolvedServerId}
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
spacing={spacing}
/>
>
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={spacing}
/>
</AssistantFileLinkResolverProvider>
);
},
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(

View File

@@ -1,119 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import {
buildModelRows,
buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch,
resolveProviderLabel,
} from "./combined-model-selector.utils";
describe("combined model selector helpers", () => {
const providerDefinitions = [
{
id: "claude",
label: "Claude",
description: "Claude provider",
defaultModeId: "default",
modes: [],
},
{
id: "codex",
label: "Codex",
description: "Codex provider",
defaultModeId: "auto",
modes: [],
},
];
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet-4.6",
label: "Sonnet 4.6",
},
];
const codexModels: AgentModelDefinition[] = [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
];
it("keeps enough data to search by model and provider name", async () => {
const rows = buildModelRows(
providerDefinitions,
new Map([
["claude", claudeModels],
["codex", codexModels],
]),
);
expect(rows).toEqual([
expect.objectContaining({
providerLabel: "Claude",
modelLabel: "Sonnet 4.6",
modelId: "sonnet-4.6",
}),
expect.objectContaining({
providerLabel: "Codex",
modelLabel: "GPT-5.4",
modelId: "gpt-5.4",
}),
]);
expect(matchesSearch(rows[0], "claude")).toBe(true);
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
});
it("matches across label, provider, and description with multi-token fuzzy search", () => {
const row = {
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
provider: "opencode",
providerLabel: "OpenCode",
modelId: "opencode-zen/kimi-k2.5",
modelLabel: "Kimi K2.5",
description: "OpenCode Zen - kimi",
};
expect(matchesSearch(row, "kimi zen")).toBe(true);
expect(matchesSearch(row, "zen kimi")).toBe(true);
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
expect(matchesSearch(row, "kimi gemini")).toBe(false);
});
it("ranks model search results by fuzzy match quality", () => {
const rows = [
{
favoriteKey: "openai:gpt-4.1",
provider: "openai",
providerLabel: "OpenAI",
modelId: "gpt-4.1",
modelLabel: "GPT-4.1",
},
{
favoriteKey: "openai:gpt-5.4",
provider: "openai",
providerLabel: "OpenAI",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
},
{
favoriteKey: "google:gemini",
provider: "google",
providerLabel: "Google",
modelId: "gemini",
modelLabel: "Gemini",
},
];
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
});
it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
});
});

View File

@@ -12,8 +12,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
const IS_WEB = platformIsWeb;
@@ -46,12 +45,15 @@ function drillDownRowStyle({
}
import { getProviderIcon } from "@/components/provider-icons";
import {
buildModelRows,
buildSelectedTriggerLabel,
filterAndRankModelRows,
resolveProviderLabel,
type SelectorModelRow,
} from "./combined-model-selector.utils";
getAllProviderModelRows,
getProviderDefaultLabel,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
@@ -61,13 +63,11 @@ type SelectorView =
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
canSelectProvider?: (provider: string) => boolean;
favoriteKeys?: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
renderTrigger?: (input: {
@@ -83,35 +83,26 @@ interface CombinedModelSelectorProps {
interface SelectorContentProps {
view: SelectorView;
providerDefinitions: AgentProviderDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
}
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
if (!models || models.length === 0) {
return "Select model";
}
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function sortFavoritesFirst(
rows: SelectorModelRow[],
rows: ProviderSelectionModelRow[],
favoriteKeys: Set<string>,
): SelectorModelRow[] {
const favorites: SelectorModelRow[] = [];
const rest: SelectorModelRow[] = [];
): ProviderSelectionModelRow[] {
const favorites: ProviderSelectionModelRow[] = [];
const rest: ProviderSelectionModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favorites.push(row);
@@ -122,44 +113,17 @@ function sortFavoritesFirst(
return [...favorites, ...rest];
}
function groupRowsByProvider(
rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
const grouped = new Map<
string,
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
>();
for (const row of rows) {
const existing = grouped.get(row.provider);
if (existing) {
existing.rows.push(row);
continue;
}
grouped.set(row.provider, {
providerId: row.provider,
providerLabel: row.providerLabel,
rows: [row],
});
}
return Array.from(grouped.values());
}
function ModelRow({
row,
isSelected,
isFavorite,
disabled = false,
elevated = false,
onPress,
onToggleFavorite,
}: {
row: SelectorModelRow;
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
elevated?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
@@ -181,7 +145,7 @@ function ModelRow({
);
const trailingSlot = useMemo(
() =>
onToggleFavorite && !disabled ? (
onToggleFavorite ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
@@ -207,7 +171,6 @@ function ModelRow({
) : null,
[
onToggleFavorite,
disabled,
handleToggleFavorite,
isFavorite,
row.provider,
@@ -225,7 +188,6 @@ function ModelRow({
label={row.modelLabel}
description={showDescription ? row.description : undefined}
selected={isSelected}
disabled={disabled}
elevated={elevated}
onPress={onPress}
leadingSlot={leadingSlot}
@@ -235,10 +197,9 @@ function ModelRow({
}
interface SelectableModelRowProps {
row: SelectorModelRow;
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
elevated?: boolean;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
@@ -248,7 +209,6 @@ function SelectableModelRow({
row,
isSelected,
isFavorite,
disabled,
elevated,
onSelect,
onToggleFavorite,
@@ -261,7 +221,6 @@ function SelectableModelRow({
row={row}
isSelected={isSelected}
isFavorite={isFavorite}
disabled={disabled}
elevated={elevated}
onPress={handlePress}
onToggleFavorite={onToggleFavorite}
@@ -275,19 +234,15 @@ function FavoritesSection({
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
favoriteRows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme: _theme } = useUnistyles();
if (favoriteRows.length === 0) {
return null;
}
@@ -303,7 +258,6 @@ function FavoritesSection({
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
elevated
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
@@ -317,52 +271,68 @@ interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
defaultLabel: string | null;
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}
function GroupProviderButton({
providerId,
providerLabel,
rowCount,
defaultLabel,
onDrillDown,
onSelectDefault,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
if (defaultLabel) {
onSelectDefault(providerId);
return;
}
onDrillDown(providerId, providerLabel);
}, [onDrillDown, providerId, providerLabel]);
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{rowCount} {rowCount === 1 ? "model" : "models"}
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{defaultLabel ? null : (
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function GroupedProviderRows({
groupedRows,
providers,
onDrillDown,
onSelectDefault,
}: {
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}) {
return (
<View>
{groupedRows.map((group, index) => {
{providers.map((provider, index) => {
const rows = getProviderModelRows(provider);
const defaultLabel = getProviderDefaultLabel(provider);
return (
<View key={group.providerId}>
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton
providerId={group.providerId}
providerLabel={group.providerLabel}
rowCount={group.rows.length}
providerId={provider.id}
providerLabel={provider.label}
rowCount={rows.length}
defaultLabel={defaultLabel}
onDrillDown={onDrillDown}
onSelectDefault={onSelectDefault}
/>
</View>
);
@@ -371,22 +341,49 @@ function GroupedProviderRows({
);
}
function DefaultProviderRow({
providerId,
isSelected,
onSelect,
}: {
providerId: string;
isSelected: boolean;
onSelect: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
onSelect(providerId, "");
}, [onSelect, providerId]);
const leadingSlot = useMemo(
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Default"
selected={isSelected}
onPress={handlePress}
leadingSlot={leadingSlot}
/>
);
}
function ProviderModelRows({
rows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
normalizedQuery,
}: {
rows: SelectorModelRow[];
rows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
normalizedQuery: string;
}) {
@@ -397,19 +394,18 @@ function ProviderModelRows({
[favoriteKeys, normalizedQuery, rows],
);
const renderItem = useCallback(
({ item }: { item: SelectorModelRow }) => (
({ item }: { item: ProviderSelectionModelRow }) => (
<SelectableModelRow
row={item}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
isFavorite={favoriteKeys.has(item.favoriteKey)}
disabled={!canSelectProvider(item.provider)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
),
[canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
if (useVirtualizedList) {
return (
@@ -436,44 +432,42 @@ function ProviderModelRows({
function SelectorContent({
view,
providerDefinitions,
allProviderModels,
providers,
selectedProvider,
selectedModel,
searchQuery,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels),
[allProviderModels, providerDefinitions],
);
const scopedRows = useMemo(() => {
if (view.kind === "provider") {
return allRows.filter((row) => row.provider === view.providerId);
}
return allRows;
}, [allRows, view]);
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo(
() =>
view.kind === "provider"
? providers.find((provider) => provider.id === view.providerId)
: null,
[providers, view],
);
const visibleRows = useMemo(
() => filterAndRankModelRows(scopedRows, normalizedQuery),
[normalizedQuery, scopedRows],
() =>
selectedViewProvider
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
: [],
[normalizedQuery, selectedViewProvider],
);
const favoriteRows = useMemo(
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, visibleRows],
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
const handleSelectDefaultProvider = useCallback(
(providerId: string) => {
onSelect(providerId, "");
},
[onSelect],
);
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -482,6 +476,20 @@ function SelectorContent({
);
if (view.kind === "provider") {
if (!selectedViewProvider) {
return emptyState;
}
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
return (
<DefaultProviderRow
providerId={view.providerId}
isSelected={view.providerId === selectedProvider && !selectedModel}
onSelect={onSelect}
/>
);
}
if (visibleRows.length === 0) {
return emptyState;
}
@@ -493,7 +501,6 @@ function SelectorContent({
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
normalizedQuery={normalizedQuery}
/>
@@ -508,12 +515,15 @@ function SelectorContent({
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{allGroupedRows.length > 0 ? (
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
{providers.length > 0 ? (
<GroupedProviderRows
providers={providers}
onDrillDown={onDrillDown}
onSelectDefault={handleSelectDefaultProvider}
/>
) : null}
{!hasResults ? emptyState : null}
@@ -522,13 +532,11 @@ function SelectorContent({
}
export function CombinedModelSelector({
providerDefinitions,
allProviderModels,
providers,
selectedProvider,
selectedModel,
onSelect,
isLoading,
canSelectProvider = () => true,
favoriteKeys = new Set<string>(),
onToggleFavorite,
renderTrigger,
@@ -544,26 +552,26 @@ export function CombinedModelSelector({
const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
// Single-provider mode: only one provider with models → skip Level 1 entirely
// Single-provider mode: only one provider → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => {
const providers = Array.from(allProviderModels.keys());
if (providers.length !== 1) return null;
const providerId = providers[0];
const label = resolveProviderLabel(providerDefinitions, providerId);
return { kind: "provider", providerId, providerLabel: label };
}, [allProviderModels, providerDefinitions]);
const provider = providers[0];
if (!provider) return null;
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}, [providers]);
const computeInitialView = useCallback((): SelectorView => {
if (singleProviderView) return singleProviderView;
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
const label = resolveProviderLabel(providerDefinitions, selectedProvider);
return { kind: "provider", providerId: selectedProvider, providerLabel: label };
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider)
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}
return { kind: "all" };
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]);
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -594,28 +602,22 @@ export function CombinedModelSelector({
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => {
if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
return isLoading ? "Loading..." : "Select model";
}
const models = allProviderModels.get(selectedProvider);
if (!models) {
return isLoading ? "Loading..." : "Select model";
}
const model = models.find((entry) => entry.id === selectedModel);
return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
return resolveSelectedModelLabel({
providers,
selectedProvider,
selectedModel,
isLoading,
});
}, [isLoading, providers, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
const models = allProviderModels.get(view.providerId);
const modelCount = models?.length ?? 0;
const provider = providers.find((entry) => entry.id === view.providerId);
const modelCount = provider ? getProviderModelRows(provider).length : 0;
return Math.min(80 + modelCount * 40, 400);
}, [allProviderModels, view]);
}, [providers, view]);
const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
@@ -750,14 +752,12 @@ export function CombinedModelSelector({
{isContentReady ? (
<SelectorContent
view={view}
providerDefinitions={providerDefinitions}
allProviderModels={allProviderModels}
providers={providers}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
searchQuery={searchQuery}
favoriteKeys={favoriteKeys}
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={handleDrillDown}
/>

View File

@@ -1,77 +0,0 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type SelectorModelRow = FavoriteModelRow;
export function resolveProviderLabel(
providerDefinitions: AgentProviderDefinition[],
providerId: string,
): string {
return (
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
);
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
export function buildModelRows(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): SelectorModelRow[] {
const providerLabelMap = new Map(
providerDefinitions.map((definition) => [definition.id, definition.label]),
);
const rows: SelectorModelRow[] = [];
for (const definition of providerDefinitions) {
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
for (const model of allProviderModels.get(definition.id) ?? []) {
rows.push({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
});
}
}
return rows;
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
return scoreModelRow(row, normalizedQuery) !== null;
}
function getModelRowSearchFields(row: SelectorModelRow): string[] {
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
}
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
}
export function filterAndRankModelRows(
rows: SelectorModelRow[],
normalizedQuery: string,
): SelectorModelRow[] {
if (!normalizedQuery) return rows;
const scored = rows
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
Boolean(entry.score),
);
scored.sort((a, b) => {
const cmp = compareMatchScores(a.score, b.score);
if (cmp !== 0) return cmp;
return a.row.modelLabel.localeCompare(b.row.modelLabel);
});
return scored.map((entry) => entry.row);
}

View File

@@ -8,7 +8,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -106,6 +106,8 @@ function CommandCenterActionRow({
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],

View File

@@ -0,0 +1,66 @@
import { useCallback } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Heart } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { GitHubIcon } from "@/components/icons/github-icon";
import { DiscordIcon } from "@/components/icons/discord-icon";
import { openExternalUrl } from "@/utils/open-external-url";
const renderGitHubIcon = (color: string) => <GitHubIcon color={color} size={14} />;
const renderDiscordIcon = (color: string) => <DiscordIcon color={color} size={14} />;
export function CommunityLinks() {
const handleOpenGitHub = useCallback(() => {
void openExternalUrl("https://github.com/getpaseo/paseo");
}, []);
const handleOpenSponsor = useCallback(() => {
void openExternalUrl("https://github.com/sponsors/boudra");
}, []);
const handleOpenDiscord = useCallback(() => {
void openExternalUrl("https://discord.gg/jz8T2uahpH");
}, []);
return (
<View style={styles.row}>
<Button
variant="ghost"
size="sm"
leftIcon={renderGitHubIcon}
onPress={handleOpenGitHub}
testID="community-links-github-star"
>
Star
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={Heart}
onPress={handleOpenSponsor}
testID="community-links-sponsor"
>
Sponsor
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={renderDiscordIcon}
onPress={handleOpenDiscord}
testID="community-links-discord"
>
Community
</Button>
</View>
);
}
const styles = StyleSheet.create(() => ({
row: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 0,
},
}));

View File

@@ -19,7 +19,7 @@ describe("resolveStatusControlMode", () => {
selectedModel: "",
onSelectModel: () => undefined,
isModelLoading: false,
allProviderModels: new Map(),
modelSelectorProviders: [],
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
thinkingOptions: [],

View File

@@ -62,7 +62,7 @@ import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { Autocomplete } from "@/components/ui/autocomplete";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import {
useHostRuntimeAgentDirectoryStatus,
@@ -256,25 +256,6 @@ function renderQueueList(args: RenderQueueListArgs): ReactElement | null {
);
}
function renderAutocompletePopover(
autocomplete: ReturnType<typeof useAgentAutocomplete>,
): ReactElement | null {
if (!autocomplete.isVisible) return null;
return (
<View style={styles.autocompletePopover} pointerEvents="box-none">
<Autocomplete
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
onSelect={autocomplete.onSelectOption}
/>
</View>
);
}
interface RenderComposerAttachmentPillArgs {
attachment: ComposerAttachment;
index: number;
@@ -463,7 +444,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
accessibilityLabel="Send queued message now"
accessibilityRole="button"
>
<ArrowUp size={ICON_SIZE.sm} color="white" />
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
</Pressable>
</View>
</View>
@@ -756,7 +737,8 @@ function ComposerRightControlsSlot({
...voiceProps
}: ComposerRightControlsSlotProps) {
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
const showVoiceModeButton =
!isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
if (!showVoiceModeButton && !shouldShowCancelButton) return null;
return (
@@ -1588,10 +1570,7 @@ export function Composer({
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
);
const autocompletePopover = useMemo(
() => renderAutocompletePopover(autocomplete),
[autocomplete],
);
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading;
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
@@ -1613,8 +1592,18 @@ export function Composer({
{queueList}
{sendErrorNode}
<View style={styles.messageInputContainer}>
{autocompletePopover}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocomplete.isVisible && isPaneFocused}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
@@ -1711,14 +1700,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
width: "100%",
gap: theme.spacing[3],
},
autocompletePopover: {
position: "absolute",
left: 0,
right: 0,
bottom: "100%",
marginBottom: theme.spacing[3],
zIndex: 30,
},
cancelButton: {
width: 28,
height: 28,
@@ -1846,6 +1827,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
const ThemedPencil = withUnistyles(Pencil);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
@@ -1854,3 +1836,4 @@ const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });

View File

@@ -166,16 +166,16 @@ export function DictationOverlay({
[actionsDisabled, isFailed],
);
const overlayTimerTextStyle = useMemo(
() => [overlayStyles.timerText, { color: theme.colors.palette.white }],
[theme.colors.palette.white],
() => [overlayStyles.timerText, { color: theme.colors.accentForeground }],
[theme.colors.accentForeground],
);
const overlayTranscriptTextStyle = useMemo(
() => [overlayStyles.transcriptText, { color: theme.colors.palette.white, opacity: 0.95 }],
[theme.colors.palette.white],
() => [overlayStyles.transcriptText, { color: theme.colors.accentForeground, opacity: 0.95 }],
[theme.colors.accentForeground],
);
const overlayRetryButtonStyle = useMemo(
() => [overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }],
[theme.colors.palette.white],
() => [overlayStyles.actionButton, { backgroundColor: theme.colors.accentForeground }],
[theme.colors.accentForeground],
);
const overlayConfirmButtonStyle = overlayRetryButtonStyle;
@@ -192,7 +192,7 @@ export function DictationOverlay({
accessibilityLabel="Cancel dictation"
style={overlayCancelButtonStyle}
>
<X size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
</Pressable>
<View style={overlayStyles.centerContainer}>
@@ -202,7 +202,7 @@ export function DictationOverlay({
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.palette.white}
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
@@ -216,7 +216,7 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
@@ -239,7 +239,7 @@ export function DictationOverlay({
>
<Pencil
size={theme.iconSize.lg}
color={theme.colors.palette.white}
color={theme.colors.accentForeground}
strokeWidth={2.5}
/>
</Pressable>

View File

@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -95,20 +96,27 @@ export function DiffViewer({
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
);
const linesContainerStyle = React.useMemo(
() => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }],
() => [
styles.linesContainer,
scrollViewWidth > 0 && inlineUnistylesStyle({ minWidth: scrollViewWidth }),
],
[scrollViewWidth],
);
const keyedDiffLines = React.useMemo(
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
[diffLines],
);
const webVerticalContentStyle = React.useMemo(
() => [styles.verticalContent, fillAvailableHeight && styles.fillHeight],
[fillAvailableHeight],
);
if (!diffLines.length) {
return (
@@ -118,29 +126,39 @@ export function DiffViewer({
);
}
return (
const lines = (
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
);
const horizontalScroll = (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
{lines}
</ScrollView>
);
const content = (
<ScrollView
style={outerScrollStyle}
contentContainerStyle={styles.verticalContent}
contentContainerStyle={webVerticalContentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={handleInnerLayout}
>
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
</ScrollView>
{horizontalScroll}
</ScrollView>
);
return content;
}
const styles = StyleSheet.create((theme) => {

View File

@@ -17,6 +17,7 @@ import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
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 { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
@@ -120,7 +121,10 @@ const CodeLine = React.memo(function CodeLine({
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],

View File

@@ -0,0 +1,14 @@
import Svg, { Path } from "react-native-svg";
interface DiscordIconProps {
size?: number;
color?: string;
}
export function DiscordIcon({ size = 16, color = "currentColor" }: DiscordIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</Svg>
);
}

View File

@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
@@ -61,9 +61,21 @@ vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => () => null,
}));
vi.mock("lucide-react-native", () => {
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
Inbox: icon("Inbox"),
RotateCw: icon("RotateCw"),
};
});
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () =>
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
@@ -100,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
title,
header,
children,
testID,
}: {
visible: boolean;
title: string;
header?: { title: string; actions?: ReactNode };
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{title}</h1>
<h1>{header?.title}</h1>
{header?.actions}
{children}
</section>
) : null,
@@ -146,6 +159,8 @@ interface RenderOptions {
visible?: boolean;
onClose?: () => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
cwd?: string | null;
snapshot?: {
entries?: ProviderSnapshotEntry[];
supportsSnapshot?: boolean;
@@ -168,15 +183,18 @@ function renderSheet(
},
});
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
return render(
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={options?.visible ?? true}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd={cwd}
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
onImported={options?.onImported}
/>
</QueryClientProvider>,
);
@@ -254,7 +272,7 @@ function createSnapshotEntry(
};
}
describe("WorkspaceImportSheet", () => {
describe("ImportSessionSheet", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -420,11 +438,11 @@ describe("WorkspaceImportSheet", () => {
function TestSheet({ visible }: { visible: boolean }) {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={visible}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd="/repo/paseo"
onClose={vi.fn()}
onImportedAgent={vi.fn()}
/>
@@ -472,7 +490,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
@@ -508,7 +526,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await screen.findByText("Could not import selected session.");
expect(importAgent).toHaveBeenCalledWith({
@@ -654,12 +672,12 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude");
screen.getByText("Session codex");
@@ -691,8 +709,8 @@ describe("WorkspaceImportSheet", () => {
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
expect(screen.queryByTestId("import-session-filters")).toBeNull();
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
@@ -720,4 +738,119 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("No importable providers are enabled.");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
title: "Cross-project session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 15,
});
});
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ cwd: expect.anything() }),
);
await screen.findByText("/home/me/work/other-project");
});
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onImported = vi.fn();
const onImportedAgent = vi.fn();
const onClose = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
onClose,
onImported,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/home/me/work/other-project",
});
});
expect(onImported).toHaveBeenCalledTimes(1);
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("refetches sessions when the refresh button is clicked", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
title: "Refreshable session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Refreshable session");
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("import-session-refresh"));
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -4,6 +4,7 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -13,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
@@ -24,13 +24,16 @@ type RecentProviderSessionsClient = Pick<
"fetchRecentProviderSessions" | "importAgent"
>;
interface WorkspaceImportSheetProps {
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
interface ImportSessionSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
workspaceDirectory: string | null;
cwd?: string | null;
onClose: () => void;
onImportedAgent: (agentId: string) => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: ImportedAgent) => void;
}
type RecentSessionsResponse = Awaited<
@@ -83,20 +86,20 @@ function buildSessionsQueriesConfig(args: {
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
workspaceDirectory: string | null;
cwd: string | null | undefined;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client && workspaceDirectory);
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
cwd: workspaceDirectory,
...(cwd ? { cwd } : {}),
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
@@ -172,8 +175,6 @@ interface SheetStatusMessagesProps {
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
showEmptyState: boolean;
allAlreadyImported: boolean;
}
function SheetStatusMessages({
@@ -184,12 +185,10 @@ function SheetStatusMessages({
allQueriesErrored,
erroredProviderLabels,
importErrored,
showEmptyState,
allAlreadyImported,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
@@ -216,45 +215,117 @@ function SheetStatusMessages({
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
) : null}
{showEmptyState ? (
<Text style={styles.statusText}>
{allAlreadyImported
? "All recent sessions are already imported."
: "No recent sessions to import."}
</Text>
) : null}
</>
);
}
interface EmptyStateInputs {
isLoadingSessions: boolean;
allQueriesErrored: boolean;
isQueryingProviders: boolean;
allQueriesSettled: boolean;
selectedProvider: string;
aggregatedCount: number;
visibleCount: number;
totalAlreadyImportedCount: number;
providerLabelById: ReadonlyMap<string, string>;
}
function computeEmptyState(input: EmptyStateInputs): {
showEmptyState: boolean;
emptyStateTitle: string;
} {
const showEmptyState =
!input.isLoadingSessions &&
!input.allQueriesErrored &&
input.isQueryingProviders &&
input.allQueriesSettled &&
input.visibleCount === 0;
if (!showEmptyState) {
return { showEmptyState, emptyStateTitle: "" };
}
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
}
if (input.totalAlreadyImportedCount > 0) {
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
}
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
pressed && styles.refreshButtonPressed,
],
[],
);
return (
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel="Refresh sessions"
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
>
<View style={styles.refreshIconSlot}>
{isRefreshing ? (
<LoadingSpinner color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={16} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function SheetEmptyState({ title }: { title: string }) {
const { theme } = useUnistyles();
return (
<View style={styles.emptyState} testID="import-session-empty-state">
<View style={styles.emptyStateIcon}>
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
</View>
<Text style={styles.emptyStateTitle}>{title}</Text>
</View>
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `workspace-import-filter-${provider}`,
testID: `import-session-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function WorkspaceImportSheetRow({
function ImportSessionSheetRow({
entry,
disabled,
importing,
showCwd,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
showCwd: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
@@ -285,7 +356,7 @@ function WorkspaceImportSheetRow({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={pressableStyle}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowIconWrap}>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -300,19 +371,25 @@ function WorkspaceImportSheetRow({
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
</Text>
{showCwd && entry.cwd ? (
<Text style={styles.rowCwd} numberOfLines={1}>
{entry.cwd}
</Text>
) : null}
</View>
</Pressable>
);
}
export function WorkspaceImportSheet({
export function ImportSessionSheet({
visible,
client,
serverId,
workspaceDirectory,
cwd,
onClose,
onImportedAgent,
}: WorkspaceImportSheetProps) {
onImported,
}: ImportSessionSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
@@ -330,8 +407,8 @@ export function WorkspaceImportSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
);
const queriesConfig = useMemo(
@@ -341,9 +418,9 @@ export function WorkspaceImportSheet({
sessionsQueryRoot,
visible,
client,
workspaceDirectory,
cwd,
}),
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
);
const queries = useQueries({ queries: queriesConfig });
@@ -379,20 +456,25 @@ export function WorkspaceImportSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
const effectiveCwd = cwd ?? entry.cwd;
if (!effectiveCwd) {
throw new Error("Session is missing a working directory");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: workspaceDirectory,
cwd: effectiveCwd,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent(agent.id);
onImportedAgent?.(agent.id);
onImported?.(agent);
},
});
@@ -413,6 +495,20 @@ export function WorkspaceImportSheet({
[queries, providersToFetch, providerLabelById],
);
const isRefreshing = queries.some((query) => query.isFetching);
const handleRefresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
}, [queryClient, sessionsQueryRoot]);
const header = useMemo<SheetHeader>(
() => ({
title: "Import session",
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh],
);
const isSnapshotUnsupported = !supportsSnapshot;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
@@ -423,21 +519,25 @@ export function WorkspaceImportSheet({
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
const showEmptyState =
!isLoadingSessions &&
!allQueriesErrored &&
isQueryingProviders &&
allQueriesSettled &&
aggregatedEntries.length === 0;
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
const { showEmptyState, emptyStateTitle } = computeEmptyState({
isLoadingSessions,
allQueriesErrored,
isQueryingProviders,
allQueriesSettled,
selectedProvider,
aggregatedCount: aggregatedEntries.length,
visibleCount: visibleEntries.length,
totalAlreadyImportedCount,
providerLabelById,
});
const showFilter = filterProviders.length > 1;
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet"
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
@@ -448,7 +548,7 @@ export function WorkspaceImportSheet({
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="workspace-import-filters"
testID="import-session-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
@@ -457,29 +557,29 @@ export function WorkspaceImportSheet({
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client && workspaceDirectory)}
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
showEmptyState={showEmptyState}
allAlreadyImported={allAlreadyImported}
/>
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<WorkspaceImportSheetRow
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
showCwd={!cwd}
onImportSession={handleImportSession}
/>
))}
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
);
}
@@ -539,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
rowCwd: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
@@ -549,4 +653,34 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[8],
paddingHorizontal: theme.spacing[4],
},
emptyStateIcon: {
opacity: 0.6,
marginBottom: theme.spacing[1],
},
emptyStateTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
textAlign: "center",
},
refreshButton: {
padding: theme.spacing[2],
marginRight: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
refreshButtonPressed: {
backgroundColor: theme.colors.surface2,
},
refreshIconSlot: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
}));

View File

@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, MessagesSquare, Settings, X } from "lucide-react-native";
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -58,6 +58,7 @@ import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import {
buildHostOpenProjectRoute,
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
@@ -94,6 +95,7 @@ interface SidebarSharedProps {
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
renderHostOption: (input: {
option: ComboboxOption;
@@ -223,6 +225,17 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSettingsRoute());
}, []);
const handleHomeMobile = useCallback(() => {
if (!activeServerId) return;
showMobileAgent();
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId, showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return;
@@ -272,6 +285,7 @@ export const LeftSidebar = memo(function LeftSidebar({
isOpen={isOpen}
closeToAgent={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
@@ -284,6 +298,7 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
@@ -414,6 +429,7 @@ function SidebarFooter({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
}: {
theme: SidebarTheme;
@@ -427,6 +443,7 @@ function SidebarFooter({
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
@@ -456,6 +473,13 @@ function SidebarFooter({
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
</TooltipContent>
</Tooltip>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
accessibilityLabel="Home"
icon={Home}
theme={theme}
/>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
@@ -501,6 +525,7 @@ function MobileSidebar({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
insetsBottom,
@@ -729,6 +754,7 @@ function MobileSidebar({
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
/>
</View>
@@ -758,6 +784,7 @@ function DesktopSidebar({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
isOpen,
@@ -871,6 +898,7 @@ function DesktopSidebar({
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
/>

View File

@@ -315,12 +315,12 @@ function SendButtonContent({
buttonIconSize: number;
}) {
if (isSubmitLoading) {
return <ActivityIndicator size="small" color="white" />;
return <ThemedActivityIndicator size="small" uniProps={iconAccentForegroundMapping} />;
}
if (submitIcon === "return") {
return <CornerDownLeft size={buttonIconSize} color="white" />;
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
}
return <ArrowUp size={buttonIconSize} color="white" />;
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
}
function resolveSubmitAccessibilityLabel(input: {
@@ -799,6 +799,7 @@ interface ToggleRealtimeVoiceContext {
voiceAgentId: string | undefined;
isConnected: boolean;
disabled: boolean;
isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
}
@@ -812,6 +813,10 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
void ctx.handleStopRealtimeVoice();
return;
}
if (ctx.isAgentRunning) {
ctx.toast.error("Interrupt the agent before starting voice mode");
return;
}
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = extractErrorMessage(error);
@@ -1450,10 +1455,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isConnected,
disabled,
isAgentRunning,
handleStopRealtimeVoice,
toast,
});
}, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]);
}, [
disabled,
handleStopRealtimeVoice,
isAgentRunning,
isConnected,
toast,
voice,
voiceAgentId,
voiceServerId,
]);
const minimizeInputHeight = useCallback(() => {
inputHeightRef.current = MIN_INPUT_HEIGHT;
@@ -1962,10 +1977,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
const ThemedPlus = withUnistyles(Plus);
const ThemedMic = withUnistyles(Mic);
const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const textInputPlaceholderColorMapping = (theme: Theme) => ({
placeholderTextColor: theme.colors.surface4,
});

View File

@@ -62,18 +62,17 @@ import Animated, {
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
import type { ToastApi } from "@/components/toast-host";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import {
AssistantInlineCodePathLink,
classifyAssistantFileLink,
type AssistantFileLinkSource,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
type InlinePathTarget,
useAssistantFileLinkResolver,
useAssistantFileLinkActions,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
interface AssistantMessageProps {
message: string;
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string;
serverId?: string;
client?: DaemonClient | null;
toast?: ToastApi | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
}
@@ -1121,7 +1117,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return;
}
await Clipboard.setStringAsync(content);
await writeMarkdownToRichClipboard(content);
setCopied(true);
if (copyTimeoutRef.current) {
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp: _timestamp,
onInlinePathPress,
workspaceRoot,
serverId,
client,
toast,
spacing = "default",
}: AssistantMessageProps) {
const markdownParser = useMemo(() => {
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
return parser;
}, []);
const fileLinkResolver = useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile: onInlinePathPress,
toast,
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
fileLinkActions.open({ href: url }, "main");
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
});
const handleLinkPress = useCallback(
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
fileLinkResolver.open({ source, disposition });
},
[fileLinkResolver],
);
const handleLinkPrefetch = useCallback(
(source: AssistantFileLinkSource) => {
fileLinkResolver.prefetch({ source });
},
[fileLinkResolver],
);
const handleMarkdownLinkPress = useCallback(
(url: string) => {
fileLinkResolver.open({ source: { href: url }, disposition: "main" });
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
},
[fileLinkResolver],
);
const markdownRules = useMemo<RenderRules>(() => {
return {
text: (
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
) => {
const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link");
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
const inlineCodeSource: AssistantFileLinkSource = {
href: content,
text: content,
sourceType: "inline-code",
};
const shouldResolveInlinePath =
onInlinePathPress &&
!isLinkedInlineCode &&
inlineCodeFileLink &&
inlineCodeFileLink.kind !== "external";
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
if (shouldResolveInlinePath) {
return (
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline}
linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
/>
);
}
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline}
linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
>
{content}
</AssistantMarkdownCodeLink>
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
source={getMarkdownLinkSource(node)}
style={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
>
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
}, [
client,
handleLinkPrefetch,
handleLinkPress,
markdownParser,
onInlinePathPress,
serverId,
workspaceRoot,
]);
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(

View File

@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("lucide-react-native", () => ({
Bot: function Bot() {
return null;
},
PackagePlus: function PackagePlus() {
return null;
},
}));
import { Bot, PackagePlus } from "lucide-react-native";
import { getProviderIcon } from "./provider-icons";
describe("getProviderIcon", () => {
it("keeps built-in provider icons", () => {
expect(getProviderIcon("kiro")).toBe(PackagePlus);
});
it("uses vendored ACP catalog icons for catalog provider ids", () => {
const icon = getProviderIcon("amp-acp");
expect(icon).not.toBe(Bot);
expect(getProviderIcon("amp-acp")).toBe(icon);
});
it("falls back to the robot icon for unknown custom providers", () => {
expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
});
});

View File

@@ -1,18 +1,63 @@
import { Bot } from "lucide-react-native";
import { Bot, PackagePlus } from "lucide-react-native";
import { createElement, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { PiIcon } from "@/components/icons/pi-icon";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
copilot: CopilotIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
pi: PiIcon as unknown as typeof Bot,
export interface ProviderIconProps {
size: number;
color: string;
}
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
pi: PiIcon as unknown as ProviderIconComponent,
};
export function getProviderIcon(provider: string): typeof Bot {
return PROVIDER_ICONS[provider] ?? Bot;
const CATALOG_ICON_SVGS = new Map(
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [[entry.id, entry.iconSvg]] : [])),
);
const catalogIconComponents = new Map<string, ProviderIconComponent>();
function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent {
const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) =>
createElement(SvgXml, {
xml: iconSvg,
width: size,
height: size,
color,
});
CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`;
return CatalogProviderIcon;
}
function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
const cached = catalogIconComponents.get(provider);
if (cached) {
return cached;
}
const iconSvg = CATALOG_ICON_SVGS.get(provider);
if (!iconSvg) {
return undefined;
}
const icon = createCatalogIcon(provider, iconSvg);
catalogIconComponents.set(provider, icon);
return icon;
}
export function getProviderIcon(provider: string): ProviderIconComponent {
return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot;
}

View File

@@ -0,0 +1,368 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AdaptiveRenameModal } from "./rename-modal";
const { theme, adaptiveInputState } = vi.hoisted(() => ({
adaptiveInputState: {
latestProps: null as {
onChangeText?: (next: string) => void;
onSubmitEditing?: () => void;
} | null,
},
theme: {
spacing: { 2: 8, 3: 12 },
fontSize: { sm: 13, base: 15 },
borderRadius: { md: 6 },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
palette: { red: { 300: "#f87171" } },
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/components/adaptive-modal-sheet", async () => {
const ReactModule = await import("react");
const AdaptiveModalSheet = ({
visible,
title,
children,
onClose,
testID,
}: {
visible: boolean;
title: string;
children: React.ReactNode;
onClose: () => void;
testID?: string;
}) => {
if (!visible) return null;
return ReactModule.createElement(
"div",
{ "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
ReactModule.createElement(
"button",
{
type: "button",
"data-testid": "adaptive-modal-sheet-close",
onClick: onClose,
},
"Close",
),
children,
);
};
// Mirrors production AdaptiveTextInput: native-owned input seeded by
// initialValue, remounted (via key) when resetKey changes so the new
// initialValue takes effect.
const AdaptiveTextInput = ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
(props, ref) => {
const p = props as {
initialValue?: string;
defaultValue?: string;
editable?: boolean;
maxLength?: number;
testID?: string;
onChangeText?: (next: string) => void;
onSubmitEditing?: () => void;
};
adaptiveInputState.latestProps = {
onChangeText: p.onChangeText,
onSubmitEditing: p.onSubmitEditing,
};
return ReactModule.createElement("input", {
ref,
defaultValue: p.initialValue ?? p.defaultValue ?? "",
disabled: p.editable === false,
maxLength: p.maxLength,
"data-testid": p.testID,
onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
if (e.key === "Enter") {
e.preventDefault();
p.onSubmitEditing?.();
}
},
});
},
);
return { AdaptiveModalSheet, AdaptiveTextInput };
});
vi.mock("@/components/ui/button", async () => {
const ReactModule = await import("react");
return {
Button: ({
children,
onPress,
disabled,
testID,
}: {
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
testID?: string;
}) =>
ReactModule.createElement(
"button",
{
type: "button",
"data-testid": testID,
disabled: disabled || undefined,
onClick: () => !disabled && onPress?.(),
},
children,
),
};
});
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
adaptiveInputState.latestProps = null;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
interface RenderOptions {
visible?: boolean;
initialValue?: string;
title?: string;
placeholder?: string;
submitLabel?: string;
onClose?: () => void;
onSubmit?: (value: string) => Promise<void> | void;
validate?: (value: string) => string | null;
maxLength?: number;
}
function renderModal(options: RenderOptions = {}): void {
const {
visible = true,
initialValue = "",
title = "Rename",
placeholder,
submitLabel,
onClose = vi.fn(),
onSubmit = vi.fn(),
validate,
maxLength,
} = options;
act(() => {
root?.render(
<AdaptiveRenameModal
visible={visible}
title={title}
initialValue={initialValue}
placeholder={placeholder}
submitLabel={submitLabel}
onClose={onClose}
onSubmit={onSubmit}
validate={validate}
maxLength={maxLength}
testID="rename-modal"
/>,
);
});
}
function queryInput(): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>('[data-testid="rename-modal-input"]');
}
function querySubmit(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-submit"]');
}
function queryCancel(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-cancel"]');
}
function queryError(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-testid="rename-modal-error"]');
}
function click(element: Element | null): void {
if (!element) throw new Error("Cannot click null element");
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function typeInto(value: string): void {
act(() => {
adaptiveInputState.latestProps?.onChangeText?.(value);
});
}
function pressEnter(): void {
act(() => {
adaptiveInputState.latestProps?.onSubmitEditing?.();
});
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("RenameModal", () => {
it("renders with the initial value pre-filled and selects it after open", async () => {
vi.useFakeTimers();
renderModal({ initialValue: "main" });
const input = queryInput();
expect(input).not.toBeNull();
expect(input?.value).toBe("main");
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
const focused = document.activeElement as HTMLInputElement | null;
expect(focused).toBe(input);
expect(focused?.selectionStart).toBe(0);
expect(focused?.selectionEnd).toBe("main".length);
});
it("submits on Enter keypress in the input when the value has changed", async () => {
const onSubmit = vi.fn();
const onClose = vi.fn();
renderModal({ initialValue: "feature", onSubmit, onClose });
typeInto("feature-2");
pressEnter();
await flush();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith("feature-2");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => {
const onClose = vi.fn();
const onSubmit = vi.fn();
renderModal({ initialValue: "main", onClose, onSubmit });
click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]'));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
});
it("disables submit when draft equals initialValue and re-enables after a change", async () => {
const onSubmit = vi.fn();
renderModal({ initialValue: "main", onSubmit });
expect(querySubmit()?.disabled).toBe(true);
pressEnter();
await flush();
expect(onSubmit).not.toHaveBeenCalled();
typeInto("main-v2");
await flush();
expect(querySubmit()?.disabled).toBe(false);
typeInto("main");
await flush();
expect(querySubmit()?.disabled).toBe(true);
});
it("surfaces validate errors inline and blocks submission", async () => {
const onSubmit = vi.fn();
const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null));
renderModal({ initialValue: "ok", validate, onSubmit });
typeInto("bad");
const submit = querySubmit()!;
expect(submit.disabled).toBe(true);
pressEnter();
await flush();
expect(onSubmit).not.toHaveBeenCalled();
const errorNode = queryError();
expect(errorNode?.textContent).toContain("Invalid name");
});
it("disables the submit button while onSubmit is pending", async () => {
let resolve: () => void = () => {};
const onSubmit = vi.fn(
() =>
new Promise<void>((r) => {
resolve = r;
}),
);
renderModal({ initialValue: "main", onSubmit });
typeInto("main-renamed");
click(querySubmit());
await flush();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(querySubmit()?.disabled).toBe(true);
expect(queryCancel()?.disabled).toBe(true);
await act(async () => {
resolve();
await Promise.resolve();
});
});
it("keeps the modal open with an error when onSubmit rejects", async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no"));
const onClose = vi.fn();
renderModal({ initialValue: "main", onSubmit, onClose });
typeInto("main-renamed");
click(querySubmit());
await flush();
expect(onClose).not.toHaveBeenCalled();
expect(queryError()?.textContent).toContain("Server said no");
expect(querySubmit()?.disabled).toBe(false);
});
});

View File

@@ -0,0 +1,195 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
export interface AdaptiveRenameModalProps {
visible: boolean;
title: string;
initialValue: string;
placeholder?: string;
submitLabel?: string;
onClose: () => void;
onSubmit: (value: string) => Promise<void> | void;
validate?: (value: string) => string | null;
maxLength?: number;
testID?: string;
}
export function AdaptiveRenameModal({
visible,
title,
initialValue,
placeholder,
submitLabel = "Rename",
onClose,
onSubmit,
validate,
maxLength,
testID,
}: AdaptiveRenameModalProps) {
const [draft, setDraft] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const inputRef = useRef<TextInput>(null);
useEffect(() => {
if (!visible) return;
setDraft(initialValue);
setError(null);
setIsPending(false);
}, [visible, initialValue]);
useEffect(() => {
if (!visible) return;
const length = initialValue.length;
const timeout = setTimeout(() => {
const node = inputRef.current;
if (!node) return;
node.focus();
if (isWeb && node instanceof HTMLInputElement) {
node.setSelectionRange(0, length);
} else if (!isWeb && length > 0) {
node.setNativeProps({ selection: { start: 0, end: length } });
}
}, 50);
return () => clearTimeout(timeout);
}, [visible, initialValue]);
const computeError = useCallback(
(value: string): string | null => {
if (!value.trim()) return "Name is required";
return validate ? validate(value) : null;
},
[validate],
);
const handleChange = useCallback((value: string) => {
setDraft(value);
setError(null);
}, []);
const handleSubmit = useCallback(async () => {
if (isPending) return;
const value = draft;
if (value === initialValue) return;
const validationError = computeError(value);
if (validationError) {
setError(validationError);
return;
}
try {
setIsPending(true);
await onSubmit(value);
setIsPending(false);
onClose();
} catch (err) {
setIsPending(false);
const message = err instanceof Error && err.message ? err.message : "Unable to save";
setError(message);
}
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
const handleCancel = useCallback(() => {
if (isPending) return;
onClose();
}, [isPending, onClose]);
const handleSubmitVoid = useCallback(() => {
void handleSubmit();
}, [handleSubmit]);
const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null;
const inputTestID = testID ? `${testID}-input` : undefined;
const errorTestID = testID ? `${testID}-error` : undefined;
const submitTestID = testID ? `${testID}-submit` : undefined;
const cancelTestID = testID ? `${testID}-cancel` : undefined;
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
return (
<AdaptiveModalSheet
visible={visible}
onClose={handleCancel}
header={sheetHeader}
testID={testID}
>
<View style={styles.body}>
<AdaptiveTextInput
ref={inputRef}
initialValue={initialValue}
onChangeText={handleChange}
placeholder={placeholder}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
maxLength={maxLength}
onSubmitEditing={handleSubmitVoid}
style={styles.input}
testID={inputTestID}
/>
{error ? (
<Text style={styles.errorText} testID={errorTestID}>
{error}
</Text>
) : null}
<View style={styles.actions}>
<Button
variant="secondary"
size="sm"
style={styles.actionButton}
onPress={handleCancel}
disabled={isPending}
testID={cancelTestID}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
style={styles.actionButton}
onPress={handleSubmitVoid}
disabled={submitDisabled}
testID={submitTestID}
>
{isPending ? "Saving..." : submitLabel}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
body: {
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
input: {
backgroundColor: theme.colors.surface0,
color: theme.colors.foreground,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
actionButton: {
flex: 1,
},
}));

View File

@@ -0,0 +1,58 @@
import type { StyleProp, TextStyle } from "react-native";
import { useMemo } from "react";
import { TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
interface SettingsTextAreaProps {
accessibilityLabel: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
testID?: string;
style?: StyleProp<TextStyle>;
}
export function SettingsTextArea({
accessibilityLabel,
value,
onChangeText,
placeholder,
testID,
style,
}: SettingsTextAreaProps) {
const { theme } = useUnistyles();
const inputStyle = useMemo(() => [styles.input, style], [style]);
return (
<TextInput
testID={testID}
accessibilityLabel={accessibilityLabel}
multiline
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
/>
);
}
export function SettingsTextAreaCard(props: SettingsTextAreaProps) {
return (
<View style={settingsStyles.card}>
<SettingsTextArea {...props} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
minHeight: 96,
textAlignVertical: "top",
},
}));

View File

@@ -12,7 +12,10 @@ import {
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import {
useCallback,
useMemo,
@@ -45,6 +48,7 @@ import {
SquareTerminal,
Monitor,
MoreVertical,
Pencil,
Plus,
Trash2,
} from "lucide-react-native";
@@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps {
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
@@ -565,6 +571,7 @@ const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColor
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
@@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
}: {
workspace: SidebarWorkspaceEntry;
isHovered: boolean;
@@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
}) {
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
return (
@@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
@@ -701,6 +711,7 @@ function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onArchive,
archiveLabel,
archiveStatus,
@@ -710,6 +721,7 @@ function WorkspaceKebabMenu({
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
@@ -750,6 +762,15 @@ function WorkspaceKebabMenu({
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
@@ -1319,6 +1340,7 @@ function WorkspaceRowInner({
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const _isCompact = useIsCompactFormFactor();
@@ -1429,6 +1451,7 @@ function WorkspaceRowInner({
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
/>
</View>
{prHint ? (
@@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
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,
});
@@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({
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");
useKeyboardActionHandler({
@@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({
});
return (
<WorkspaceRowInner
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<>
<WorkspaceRowInner
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={canCopyBranchName ? handleOpenRename : 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}`}
/>
</>
);
}

View File

@@ -86,6 +86,7 @@ interface SplitContainerProps {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
@@ -362,6 +363,7 @@ export function SplitContainer({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -577,6 +579,7 @@ export function SplitContainer({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -716,6 +719,7 @@ function SplitNodeView({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -768,6 +772,7 @@ function SplitNodeView({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -813,6 +818,7 @@ function SplitNodeView({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
@@ -864,6 +870,7 @@ function SplitPaneView({
onCopyResumeCommand,
onCopyAgentId,
onReloadAgent,
onRenameTab,
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
@@ -1004,6 +1011,7 @@ function SplitPaneView({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}

View File

@@ -0,0 +1,532 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ComponentProps,
type Ref,
} from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
interface TerminalEmulatorProps {
dom?: unknown;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: 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;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
type BridgeInboundMessage =
| {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
| { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number }
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
type WebViewProps = ComponentProps<typeof WebView>;
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
}
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
function createMountMessage(input: {
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
return {
type: "mount",
streamKey: input.streamKey,
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
}
export default function TerminalEmulator({
ref,
streamKey,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const webViewRef = useRef<WebView>(null);
const [webViewEpoch, setWebViewEpoch] = useState(0);
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
const bridgeReadyRef = useRef(false);
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
pendingModifiers,
swipeGesturesEnabled,
});
mountConfigRef.current = {
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onSwipeLeft,
onSwipeRight,
});
callbacksRef.current = {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onSwipeLeft,
onSwipeRight,
};
const clearBridgeReadyTimeout = useCallback(() => {
if (bridgeReadyTimeoutRef.current === null) return;
clearTimeout(bridgeReadyTimeoutRef.current);
bridgeReadyTimeoutRef.current = null;
}, []);
const clearRendererReadyTimeout = useCallback(() => {
if (rendererReadyTimeoutRef.current === null) return;
clearTimeout(rendererReadyTimeoutRef.current);
rendererReadyTimeoutRef.current = null;
}, []);
const resetWebViewDocument = useCallback(() => {
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountedStreamKeyRef.current = null;
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
setWebViewEpoch((value) => value + 1);
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
const scheduleBridgeReadyWatchdog = useCallback(() => {
clearBridgeReadyTimeout();
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
bridgeReadyTimeoutRef.current = setTimeout(() => {
bridgeReadyTimeoutRef.current = null;
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
return;
}
resetWebViewDocument();
}, BRIDGE_READY_TIMEOUT_MS);
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
const scheduleRendererReadyWatchdog = useCallback(() => {
clearRendererReadyTimeout();
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
rendererReadyTimeoutRef.current = setTimeout(() => {
rendererReadyTimeoutRef.current = null;
if (
rendererReadyVersionRef.current !== expectedRendererReadyVersion ||
mountedStreamKeyRef.current === streamKey
) {
return;
}
resetWebViewDocument();
}, RENDERER_READY_TIMEOUT_MS);
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
const flushPendingMessages = useCallback(() => {
if (!bridgeReadyRef.current || !webViewRef.current) return;
const pending = pendingMessagesRef.current.splice(0);
for (const message of pending) {
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}
}, []);
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
if (!bridgeReadyRef.current || !webViewRef.current) {
pendingMessagesRef.current.push(message);
return;
}
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}, []);
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
mountedStreamKeyRef.current = streamKey;
sendToWebView(mountMessage);
flushPendingMessages();
scheduleRendererReadyWatchdog();
}, [
bridgeReadyVersion,
flushPendingMessages,
scheduleRendererReadyWatchdog,
sendToWebView,
streamKey,
]);
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
}, [pendingModifiers, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
return () => {
if (mountedStreamKeyRef.current) {
const previousStreamKey = mountedStreamKeyRef.current;
callbacksRef.current.onRendererReadyChange?.({
streamKey: previousStreamKey,
isReady: false,
});
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
}
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountedStreamKeyRef.current = null;
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
};
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
const handleLifecycleMessage = useCallback(
(message: BridgeOutboundMessage): boolean => {
if (message.type === "bridgeReady") {
bridgeReadyRef.current = true;
bridgeReadyVersionRef.current += 1;
clearBridgeReadyTimeout();
setBridgeReadyVersion((value) => value + 1);
return true;
}
if (message.type === "rendererReady") {
mountedStreamKeyRef.current = message.isReady ? message.streamKey : null;
if (message.isReady) {
rendererReadyVersionRef.current += 1;
clearRendererReadyTimeout();
}
callbacksRef.current.onRendererReadyChange?.({
streamKey: message.streamKey,
isReady: message.isReady,
});
return true;
}
return false;
},
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols });
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
key: message.key,
ctrl: message.ctrl,
shift: message.shift,
alt: message.alt,
meta: message.meta,
});
break;
case "pendingModifiersConsumed":
callbacksRef.current.onPendingModifiersConsumed?.();
break;
case "inputModeChange":
callbacksRef.current.onInputModeChange?.(message.state);
break;
case "openExternalUrl":
void openExternalUrl(message.url);
break;
case "swipeLeft":
callbacksRef.current.onSwipeLeft?.();
break;
case "swipeRight":
callbacksRef.current.onSwipeRight?.();
break;
case "debug":
break;
}
},
[],
);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: BridgeOutboundMessage;
try {
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
} catch {
return;
}
if (message.type === "bridgeReady" || message.type === "rendererReady") {
handleLifecycleMessage(message);
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage],
);
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
bridgeReadyRef.current = false;
mountedStreamKeyRef.current = null;
scheduleBridgeReadyWatchdog();
}, [scheduleBridgeReadyWatchdog]);
const handleContentProcessDidTerminate = useCallback<
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleRenderProcessGone = useCallback<
NonNullable<WebViewProps["onRenderProcessGone"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }],
[xtermTheme.background],
);
return (
<View style={styles.root} testID={testId}>
<WebView
key={webViewEpoch}
ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle}
containerStyle={styles.webViewContainer}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled
nestedScrollEnabled
bounces={false}
overScrollMode="never"
keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
textInteractionEnabled={false}
allowsLinkPreview={false}
setSupportMultipleWindows={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
onRenderProcessGone={handleRenderProcessGone}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
backgroundColor: "#0b0b0b",
},
webView: {
flex: 1,
backgroundColor: "#0b0b0b",
},
webViewContainer: {
flex: 1,
backgroundColor: "#0b0b0b",
},
});

View File

@@ -19,7 +19,10 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -29,7 +32,8 @@ import {
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -254,8 +258,12 @@ export default function TerminalEmulator({
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
@@ -274,8 +282,11 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });

View File

@@ -25,7 +25,9 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -171,6 +173,9 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -354,11 +359,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
emulatorRef.current?.writeOutput(data);
},
onRestore: ({ terminalId: restoreTerminalId, data }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -367,6 +379,12 @@ export function TerminalPane({
}
emulatorRef.current?.renderSnapshot(state);
},
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus,
});
@@ -386,6 +404,7 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots,
]);

View File

@@ -13,6 +13,7 @@ import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { DiffViewer } from "./diff-viewer";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -79,7 +80,7 @@ function useDetailStyles(
const codeVerticalScrollStyle = useMemo(
() => [
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -88,7 +89,7 @@ function useDetailStyles(
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
webScrollbarStyle,
],
@@ -97,7 +98,7 @@ function useDetailStyles(
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
webScrollbarStyle,
],
[resolvedMaxHeight, webScrollbarStyle],

View File

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

View File

@@ -20,6 +20,8 @@ import {
useWindowDimensions,
type LayoutChangeEvent,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -56,6 +58,7 @@ import {
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { FloatingSurface } from "@/components/ui/floating";
const IS_WEB = isWeb;
@@ -191,7 +194,6 @@ export function SearchInput({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={value}
resetKey={resetKey}
value={value}
@@ -858,7 +860,7 @@ interface DesktopContainerStyleInput {
availableHeight: number | undefined;
}
function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
function buildDesktopFrameStyle(input: DesktopContainerStyleInput): StyleProp<ViewStyle> {
const {
desktopMinWidth,
referenceWidth,
@@ -877,7 +879,6 @@ function buildDesktopContainerStyle(input: DesktopContainerStyleInput) {
? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) }
: null;
return [
styles.desktopContainer,
{
position: "absolute" as const,
minWidth: desktopMinWidth ?? referenceWidth ?? 200,
@@ -1070,7 +1071,7 @@ interface DesktopBodyProps {
handleClose: () => void;
refs: ReturnType<typeof useFloating>["refs"];
shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown;
desktopFrameStyle: StyleProp<ViewStyle>;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
@@ -1191,11 +1192,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
>
<View ref={props.refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={props.handleClose} />
<Animated.View
<FloatingSurface
testID="combobox-desktop-container"
entering={props.shouldUseDesktopFade ? FadeIn.duration(100) : undefined}
exiting={props.shouldUseDesktopFade ? FadeOut.duration(100) : undefined}
style={props.desktopContainerStyle as never}
style={styles.desktopContainer}
frameStyle={props.desktopFrameStyle}
ref={props.refs.setFloating}
collapsable={false}
onLayout={props.handleDesktopContentLayout}
@@ -1227,7 +1229,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
renderOption={props.renderOption}
/>
)}
</Animated.View>
</FloatingSurface>
</View>
</Modal>
);
@@ -1492,9 +1494,9 @@ export function Combobox({
[theme.colors.palette.zinc],
);
const desktopContainerStyle = useMemo(
const desktopFrameStyle = useMemo(
() =>
buildDesktopContainerStyle({
buildDesktopFrameStyle({
desktopMinWidth,
referenceWidth,
desktopFixedHeight,
@@ -1561,7 +1563,7 @@ export function Combobox({
handleClose={handleClose}
refs={refs}
shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle}
desktopFrameStyle={desktopFrameStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}

View File

@@ -18,7 +18,6 @@ import {
Modal,
Platform,
Pressable,
ScrollView,
StatusBar,
Text,
View,
@@ -28,7 +27,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
@@ -38,6 +37,7 @@ import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -498,7 +498,7 @@ export function ContextMenuContent({
[],
);
const animatedContentStyle = useMemo(() => {
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -508,7 +508,6 @@ export function ContextMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -566,23 +565,24 @@ export function ContextMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<Animated.View
<FloatingSurface
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={animatedContentStyle}
style={styles.content}
frameStyle={frameStyle}
>
<ScrollView
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</ScrollView>
</Animated.View>
</FloatingScrollView>
</FloatingSurface>
</View>
</Modal>
);

View File

@@ -14,7 +14,6 @@ import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
Text,
View,
Dimensions,
@@ -25,10 +24,10 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -172,42 +171,37 @@ function computePosition({
return { x, y, actualPlacement };
}
interface SharedDropdownContentProps {
collapsable: false;
testID?: string;
style: StyleProp<ViewStyle>;
}
function renderDropdownSurface(input: {
isWebSurface: boolean;
sharedProps: SharedDropdownContentProps;
frameStyle: StyleProp<ViewStyle>;
testID?: string;
surfaceStyle: StyleProp<ViewStyle>;
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
onExited: () => void;
}): ReactElement {
const { isWebSurface, sharedProps, scrollable, scrollViewportStyle, content, onExited } = input;
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
input;
const body = scrollable ? (
<ScrollView
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{content}
</ScrollView>
</FloatingScrollView>
) : (
content
);
if (isWebSurface) {
return <View {...sharedProps}>{body}</View>;
}
return (
<Animated.View
{...sharedProps}
<FloatingSurface
collapsable={false}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
@@ -217,7 +211,7 @@ function renderDropdownSurface(input: {
})}
>
{body}
</Animated.View>
</FloatingSurface>
);
}
@@ -513,7 +507,8 @@ export function DropdownMenuContent({
[],
);
const contentStyle = useMemo(() => {
const surfaceStyle = styles.content;
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
@@ -523,7 +518,6 @@ export function DropdownMenuContent({
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
@@ -543,14 +537,6 @@ export function DropdownMenuContent({
actualPlacement,
align,
]);
const sharedContentProps = useMemo(
() => ({
collapsable: false as const,
testID,
style: contentStyle,
}),
[testID, contentStyle],
);
const scrollViewportStyle = useMemo(
() => [webScrollbarStyle, visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize, webScrollbarStyle],
@@ -583,8 +569,9 @@ export function DropdownMenuContent({
/>
{!closing
? renderDropdownSurface({
isWebSurface: isWeb,
sharedProps: sharedContentProps,
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,

View File

@@ -0,0 +1,63 @@
import { forwardRef, useMemo, type ComponentProps, type ReactElement, type ReactNode } from "react";
import {
ScrollView,
StyleSheet,
type ScrollViewProps,
type StyleProp,
type View,
type ViewStyle,
} from "react-native";
import Animated from "react-native-reanimated";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
export interface FloatingSurfaceProps extends Omit<ComponentProps<typeof Animated.View>, "style"> {
frameStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle>;
}
export const FloatingSurface = forwardRef<View, FloatingSurfaceProps>(function FloatingSurface(
{ frameStyle, style, ...props },
ref,
): ReactElement {
const inlineFrameStyle = useMemo(() => {
const flattened = StyleSheet.flatten(frameStyle);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
}, [frameStyle]);
const surfaceStyle = useMemo(() => [style, inlineFrameStyle], [inlineFrameStyle, style]);
return <Animated.View {...props} ref={ref} style={surfaceStyle} />;
});
export interface FloatingScrollViewProps {
bounces?: boolean;
children: ReactNode;
contentContainerStyle?: StyleProp<ViewStyle>;
keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"];
showsVerticalScrollIndicator?: boolean;
style?: StyleProp<ViewStyle>;
}
export function FloatingScrollView({
bounces,
children,
contentContainerStyle,
keyboardShouldPersistTaps,
showsVerticalScrollIndicator,
style,
}: FloatingScrollViewProps): ReactElement {
const inlineStyle = useMemo(() => {
const flattened = StyleSheet.flatten(style);
return flattened ? inlineUnistylesStyle(flattened) : undefined;
}, [style]);
return (
<ScrollView
bounces={bounces}
contentContainerStyle={contentContainerStyle}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
style={inlineStyle}
>
{children}
</ScrollView>
);
}

View File

@@ -142,7 +142,6 @@ const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "stretch",
maxWidth: "100%",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
gap: 2,

View File

@@ -4,23 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
function colorWithOpacity(color: string, opacity: number): string {
const hex = color.trim();
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
const r = Number.parseInt(hex[1] + hex[1], 16);
const g = Number.parseInt(hex[2] + hex[2], 16);
const b = Number.parseInt(hex[3] + hex[3], 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
return `rgba(255, 255, 255, ${opacity})`;
}
export function Shortcut({
keys,
chord,
@@ -70,7 +53,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05),
backgroundColor: theme.colors.surface2,
borderWidth: 0,
},
sequence: {
@@ -82,6 +65,6 @@ const styles = StyleSheet.create((theme) => ({
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: colorWithOpacity(theme.colors.foreground, 0.6),
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -25,9 +25,10 @@ import {
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
@@ -494,19 +495,18 @@ export function TooltipContent({
[],
);
const contentStyle = useMemo(
const frameStyle = useMemo(
() => [
styles.content,
{ maxWidth },
style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
maxWidth,
},
],
[maxWidth, style, position?.x, position?.y],
[maxWidth, position?.x, position?.y],
);
const contentStyle = useMemo(() => [styles.content, style], [style]);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
@@ -519,7 +519,7 @@ export function TooltipContent({
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -527,9 +527,10 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</Animated.View>
</FloatingSurface>
</View>
</Portal>
);
@@ -544,7 +545,7 @@ export function TooltipContent({
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<Animated.View
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -552,9 +553,10 @@ export function TooltipContent({
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</Animated.View>
</FloatingSurface>
</Pressable>
</Modal>
);

View File

@@ -1,17 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PanResponder,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ViewStyle,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import {
computeScrollOffsetFromDragDelta,
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
import { isWeb as platformIsWeb } from "@/constants/platform";
const METRICS_EPSILON = 0.5;
const HANDLE_WIDTH_IDLE = 6;
@@ -27,6 +30,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
interface WebPointerStyle {
cursor?: "grab" | "grabbing";
touchAction?: "none";
userSelect?: "none";
transitionProperty?: string;
transitionDuration?: string;
transitionTimingFunction?: string;
}
interface PointerLikeEvent {
clientY?: number;
pageY?: number;
@@ -59,6 +71,13 @@ function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
);
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function useWebDesktopScrollbarMetrics() {
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
offset: 0,
@@ -115,13 +134,6 @@ export function useWebDesktopScrollbarMetrics() {
};
}
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
}
export function WebDesktopScrollbarOverlay({
enabled,
metrics,
@@ -273,8 +285,12 @@ export function WebDesktopScrollbarOverlay({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
onPanResponderGrant: (event: GestureResponderEvent) => {
const clientY = readClientY(event);
dragStartOffsetRef.current = normalizedOffsetRef.current;
if (clientY !== null) {
dragStartClientYRef.current = clientY;
}
setIsDragging(true);
},
onPanResponderMove: (_event, gestureState) => {
@@ -362,20 +378,19 @@ export function WebDesktopScrollbarOverlay({
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
{
top: 0,
inlineUnistylesStyle({
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
}),
platformIsWeb &&
({
inlineUnistylesStyle({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as object),
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
@@ -383,19 +398,19 @@ export function WebDesktopScrollbarOverlay({
const handleStyle = useMemo(
() => [
styles.handle,
{
inlineUnistylesStyle({
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
}),
platformIsWeb &&
({
inlineUnistylesStyle({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as object),
} satisfies WebPointerStyle as unknown as ViewStyle),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
@@ -413,8 +428,6 @@ export function WebDesktopScrollbarOverlay({
{...(platformIsWeb
? ({
onPointerDown: startWebDrag,
onPointerEnter: handleGrabHoverIn,
onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as object)
@@ -446,5 +459,6 @@ const styles = StyleSheet.create(() => ({
position: "absolute",
right: -3,
width: HANDLE_GRAB_WIDTH,
top: 0,
},
}));

View File

@@ -9,7 +9,7 @@ import {
type ReactNode,
} from "react";
import { Dimensions, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
@@ -24,6 +24,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
interface Rect {
@@ -254,23 +255,19 @@ function WorkspaceHoverCardContent({
[],
);
const cardStyle = useMemo(
() => [
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
],
const frameStyle = useMemo(
() => ({
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
}),
[position?.x, position?.y],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View
<FloatingSurface
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
@@ -279,7 +276,8 @@ function WorkspaceHoverCardContent({
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
testID="workspace-hover-card"
style={cardStyle}
style={styles.card}
frameStyle={frameStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
@@ -303,7 +301,7 @@ function WorkspaceHoverCardContent({
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
</>
) : null}
</Animated.View>
</FloatingSurface>
</View>
</Portal>
);
@@ -441,6 +439,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingTop: theme.spacing[2],
width: HOVER_CARD_WIDTH,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,

View File

@@ -135,6 +135,15 @@ const CATALOG_DATA = [
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.7"],
},
{
id: "deepseek-tui",
title: "DeepSeek TUI",
description: "Terminal coding agent for DeepSeek V4",
version: "0.8.39",
iconId: "deepseek-tui",
installLink: "https://github.com/Hmbown/DeepSeek-TUI",
command: ["deepseek", "serve", "--acp"],
},
{
id: "dimcode",
title: "DimCode",
@@ -240,6 +249,15 @@ const CATALOG_DATA = [
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
command: ["kilo", "acp"],
},
{
id: "kiro",
title: "Kiro CLI",
description: "Amazon's AI coding agent with native ACP support",
version: "manual",
iconId: null,
installLink: "https://kiro.dev/docs/cli/acp/",
command: ["kiro-cli", "acp"],
},
{
id: "kimi",
title: "Kimi CLI",

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