Compare commits

...

234 Commits

Author SHA1 Message Date
Mohamed Boudra
4d6a154f6e Fix Unistyles theme splits 2026-04-18 15:06:42 +07:00
Mohamed Boudra
5c3cc99d8c refactor(app): restructure settings with modular section navigation 2026-04-18 14:21:40 +07:00
github-actions[bot]
63a46acaf1 fix: update lockfile signatures and Nix hash 2026-04-17 11:34:00 +00:00
Mohamed Boudra
aac780ddf7 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	nix/package.nix
2026-04-17 16:16:04 +07:00
Mohamed Boudra
49d5426059 fix(app): swap github icon for external link on hover in checks row 2026-04-17 15:54:40 +07:00
Mohamed Boudra
d6585f7c3a fix(server): harden worktree archive with parallel teardown
Fan out agent close + terminal kill concurrently with Promise.allSettled
so one failure never leaves the worktree half-dead. Await real PTY exit
with SIGHUP→SIGKILL escalation before rm to avoid EBUSY races. Tolerate
missing repoRoot and retry directory removal with backoff. Purge stale
client workspace layout/tab state after successful archive.
2026-04-17 15:50:48 +07:00
Mohamed Boudra
ed2dc08443 fix(app): hide attachment remove X until hover on desktop
Keep the button visible on native and compact breakpoints so touch users
can still remove attachments without hover.
2026-04-17 15:46:58 +07:00
Mohamed Boudra
4b82ca77c8 fix(server): route list_provider_models through the snapshot manager
The legacy list_provider_models_request handler was calling
providerRegistry[p].fetchModels() directly, so every client call spawned
the provider fresh (e.g. repeated copilot ENOENT errors). Old clients
still send this RPC.

Route it through ProviderSnapshotManager so simultaneous calls dedupe
through the in-flight warmUps map, cached entries (ready / error /
unavailable) are served without spawning, and at most one spawn occurs
per cwd per TTL window regardless of client count. Direct-fetch path
kept only as a fallback for the null-manager case (tests).
2026-04-17 15:20:09 +07:00
Mohamed Boudra
07d10f55f9 fix(app): stop spawning providers per client by warming the snapshot store
The workspace screen was prefetching per-provider model lists via
`useProviderModels`, which fanned out N parallel
`list_provider_models_request`s that bypassed `ProviderSnapshotManager`
and spawned each provider fresh on every call (e.g. repeated copilot
ENOENT errors). The return value was never consumed.

Replace it with `useProvidersSnapshot(serverId, workspaceDirectory)` so
warmup goes through the shared, deduped, TTL-cached snapshot path that
consumers already read from.
2026-04-17 15:05:28 +07:00
Mohamed Boudra
13bfdd8cc1 fix(app): land on agent tab after creating a workspace
Workspace creation landed on the setup tab because openTab implicitly
focused the tab it opened, and the setup auto-open effect ran after
the setup dialog's navigation, stomping on the agent tab's focus.

Split the store API into explicit openTabFocused and openTabInBackground
so every caller declares intent. The setup auto-open effect now opens in
background and no longer steals focus; user-intent call sites (sidebar,
kebab menus, file explorer, terminal creation, prepareWorkspaceTab)
use openTabFocused and drop the redundant paired focusTab call.
2026-04-17 14:45:22 +07:00
Mohamed Boudra
9a8b01a738 fix(app): unstick iOS image picker and land reusable Maestro primitives
On iOS, tapping "+" → "Add image" in the Composer left an invisible
backdrop trapping touches after the native photo picker dismissed. Root
cause: the dropdown menu's onSelect fired while the RN Modal was still
completing UIKit dismissal, so PHPicker presented over a dismissing
Modal that never cleanly released.

DropdownMenu now defers the selected item's onSelect until Modal
onDismiss (UIKit-level completion) plus a short buffer on iOS, then
invokes it. Same DropdownMenu instance — no per-call opt-in.

Along the way, the Maestro repro surfaced a controlled-TextInput
character-drop race: fast inputText on a controlled input drops
characters mid-type. Direct-host and pair-link inputs are now
ref-backed uncontrolled inputs with stable testIDs.

New reusable E2E primitives under packages/app/maestro/:
- flows/land-in-chat.yaml — clearState + direct connect to
  127.0.0.1:6767, lands in the composer. Compose on this for any
  composer-level fixture.
- image-picker-repro.yaml — regression for the zombie-backdrop bug.

docs/MOBILE_TESTING.md gains the patterns: reach-the-composer via
land-in-chat, uncontrolled inputs for Maestro-typed fields, and the
dropdown → native-presenter dismissal timing on iOS.

Existing relay-pairing and relay-then-direct fixtures updated to use
stable testIDs instead of ambiguous text / point-based taps.
2026-04-17 14:07:15 +07:00
Mohamed Boudra
adf2f5921f fix(app): unblock host index redirect on native by dropping window.location read
`window` exists on React Native (aliased to `global`) but `window.location` is
undefined, so reading `window.location.pathname` threw and killed the redirect
effect — leaving the host index route stuck rendering null (blank screen).
Use the `pathname` from `usePathname()` directly, restoring the pre-refactor
behavior that worked on both web and native.
2026-04-17 11:42:35 +07:00
Mohamed Boudra
3838a8b969 feat(app): unify composer attachments with pill + lightbox redesign
Images and GitHub issues/PRs now live in one draft-store list (ComposerAttachment union) with cwd persisted on the draft. Paperclip becomes a + drop-up menu; queue button removed. Each pill uses a shared AttachmentPill wrapper with a corner-X that mirrors the auto-update toast spec. Pill body opens the attachment: GitHub via openExternalUrl, images via a new fullscreen AttachmentLightbox. GitHub picker auto-closes on select.
2026-04-17 11:36:08 +07:00
Mohamed Boudra
11524dc349 fix(server): make paseo worktree archive idempotent after partial teardown
If a first archive attempt removed git's admin dir but failed to clean the
working tree (e.g. due to file churn during setup), retrying the archive
failed the ownership gate with "Worktree is not a Paseo-owned worktree"
because isPaseoOwnedWorktreeCwd required the worktree to still appear in
git worktree list.

- isPaseoOwnedWorktreeCwd now decides ownership by path shape
  ($PASEO_HOME/worktrees/<hash>/<slug>[/...]) and treats git as
  best-effort for resolving repoRoot.
- deletePaseoWorktree tolerates a missing admin dir: it skips teardown
  when the tree is absent, swallows a failing git worktree remove so
  the rmSync fallback always runs, and follows up with git worktree
  prune to clear stale admin refs.
- Relabel the post-creation setup failure log as
  "Background worktree setup failed" since the worktree itself already
  exists at that point.
2026-04-16 23:05:41 +07:00
Mohamed Boudra
a001c72726 fix(server): expand ~ in file-explorer scoped paths
Assistant markdown images with ~-prefixed paths hit ENOENT because
path.resolve doesn't expand ~, leaving paths like
workspaceRoot/~/rest-of-path that never resolve. Reuse the existing
resolvePathFromBase helper so tilde paths inside the workspace load
instead of getting stuck on the image spinner; the sandbox check still
rejects tilde paths that resolve outside the workspace.
2026-04-16 22:43:07 +07:00
github-actions[bot]
8344296789 fix: update lockfile signatures and Nix hash 2026-04-16 15:12:47 +00:00
Mohamed Boudra
40f8a8414a chore(release): cut 0.1.59 2026-04-16 22:10:49 +07:00
Mohamed Boudra
9386901896 docs: add 0.1.59 changelog entry for Opus 4.7 2026-04-16 21:58:00 +07:00
Mohamed Boudra
6390f8d995 Merge branch 'main' of github.com:getpaseo/paseo into dev 2026-04-16 21:53:12 +07:00
Mohamed Boudra
daefa8fb7f feat(server): add Opus 4.7 to claude provider with xhigh effort
Adds claude-opus-4-7 and claude-opus-4-7[1m] alongside the existing 4.6
entries (4.6 stays default). Opus 4.7 introduces a new "xhigh" effort
level between high and max; the option only surfaces on 4.7 models.

The SDK 0.2.71 effort union doesn't yet include xhigh, so the value is
cast at the assignment site.
2026-04-16 21:52:40 +07:00
Mohamed Boudra
981b94fa56 fix(app): balance composer right-side button spacing
Tightens transparent icon gaps to 4px and gives filled action buttons
(send, interrupt) a 4px left margin so they sit 8px from neighbors.
Shrinks context-window meter SVG from 20 to 16 to match the visual
weight of surrounding icons.
2026-04-16 20:30:16 +07:00
Mohamed Boudra
827092a246 fix(app): avoid infinite render loop in FilePanel zustand selector
The selector returned a freshly constructed authority object each call, so
useSyncExternalStore saw a new reference every render and force-rerendered,
producing a maximum update depth crash. Select the specific workspace
descriptor (stable reference) and derive the authority via useMemo.
2026-04-16 20:18:56 +07:00
Mohamed Boudra
213767e3d5 Merge remote-tracking branch 'origin/dev' into dev 2026-04-16 19:51:18 +07:00
Mohamed Boudra
6ffffc38a0 fix(app): update route test expectations for b64_ workspace ID prefix
The opaque-workspace-identifier refactor (e6044264) added a `b64_` prefix
to base64-encoded workspace IDs in route builders. Two app tests still
expected the unprefixed form and failed in CI.
2026-04-16 19:51:00 +07:00
github-actions[bot]
270c12d524 fix: update lockfile signatures and Nix hash 2026-04-16 12:49:20 +00:00
Mohamed Boudra
53ed34bacf Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	nix/package.nix
2026-04-16 19:48:03 +07:00
Mohamed Boudra
e6bf64aed8 fix(server): drop stale snapshot mutation ownership assertions
Session.unarchiveAgentState(id) and the direct agentManager.unarchiveSnapshotByHandle
delegation were removed when main's 86bb5cc8 + a11c246b refactors moved unarchive
through the shared mcp-shared helper. Remove the now-dead test assertions so the
suite reflects the real ownership boundary.
2026-04-16 19:46:20 +07:00
Mohamed Boudra
a5582e0ee0 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	packages/server/src/server/session.ts
2026-04-16 19:37:29 +07:00
github-actions[bot]
754f1facb6 fix: update lockfile signatures and Nix hash 2026-04-16 12:18:57 +00:00
Mohamed Boudra
727aa0cdac chore(release): cut 0.1.58 2026-04-16 19:17:37 +07:00
Mohamed Boudra
a387eaf26a docs: trim 0.1.58 changelog and add scannable-bullets guidance
Split the OpenCode reliability bullet into four per-PR entries, shorten
the Windows and provider-models bullets, and drop low-signal qualifiers.
Add a "Changelog conciseness" section to the release playbook so the
next agent knows bullets must be scannable in one glance.
2026-04-16 19:16:42 +07:00
Mohamed Boudra
a11c246bdc fix(server): share send-prompt path between MCP and Session
Extract `unarchiveAgentState` and `sendPromptToAgent` into `mcp-shared`
so every surface (app/WS, MCP, CLI-through-MCP) runs the same sequence:
unarchive → ensureAgentLoaded → optional mode change → recordUserMessage
→ startAgentRun. MCP `send_agent_prompt` previously skipped unarchive and
cold-agent rehydration, so sending to an archived agent over MCP left it
hidden from `paseo ls` and failed entirely when the agent wasn't already
in memory. Session now delegates to the shared function and its private
`unarchiveAgentState` is gone.
2026-04-16 18:58:31 +07:00
Mohamed Boudra
2e95003154 fix(server): include stderr in claude auth status diagnostic
Some status output (e.g. "Not logged in") goes to stderr. Capture both
stdout and stderr, including when the command exits non-zero, so the
diagnostic surfaces the full picture.
2026-04-16 18:54:56 +07:00
Mohamed Boudra
1b4483615a fix(server): surface raw claude auth status in diagnostic
Return stdout verbatim instead of parsing JSON fields. Parser was fragile
to upstream schema changes and added no value — users can read the raw
output directly.
2026-04-16 18:52:28 +07:00
Mohamed Boudra
4d0fe57a7a fix(server): stop detecting Windows PowerShell shims 2026-04-16 18:36:06 +07:00
Mohamed Boudra
84db0cecd7 docs: expand Windows entry and rename header to 0.1.58 2026-04-16 18:29:41 +07:00
Mohamed Boudra
14ac6984f3 fix(server): extend refresh_providers_snapshot timeout to 60s
Provider snapshot refresh was timing out at 5s for providers that need
longer to enumerate models/auth. Bump to 60s.
2026-04-16 18:29:40 +07:00
Mohamed Boudra
b6147e39ab feat(server): show Claude auth status in provider diagnostic
Runs `claude auth status` and surfaces the parsed result (auth method,
subscription, email/org) in the Claude Code provider diagnostic panel.
2026-04-16 18:29:38 +07:00
Mohamed Boudra
a2b743a6d3 fix(server): emit valid MCP list_agents payloads
Explicit `undefined` keys on optional snapshot fields were converted
to `null` by `ensureValidJson`, failing `AgentSnapshotPayloadSchema`
validation on the wire. Stop writing those keys so they remain absent,
which is what `.optional()` expects.

Also adds a test that parses `list_agents` output through the schema
to catch this class of bug.
2026-04-16 18:29:34 +07:00
Mohamed Boudra
7442323ca2 fix(server): preserve JSON args for Windows PowerShell shims 2026-04-16 18:04:17 +07:00
Mohamed Boudra
086b3098b1 fix(server): support Windows PowerShell shims in shared launcher 2026-04-16 17:58:12 +07:00
Mohamed Boudra
e0f82e7ad0 test(server): reproduce Windows ps1 shim launch gap 2026-04-16 17:52:44 +07:00
Mohamed Boudra
9cee1d8196 fix(server): restore .cmd shell routing for Windows EINVAL 2026-04-16 17:05:08 +07:00
Mohamed Boudra
c2a8e56d33 test(server): reproduce Windows EINVAL for .cmd shim with shell:false 2026-04-16 17:00:01 +07:00
Mohamed Boudra
de33dfd034 fix(server): share Windows launch handling across providers 2026-04-16 16:44:24 +07:00
Mohamed Boudra
917c2f56f4 test(server): reproduce Windows spawn EINVAL with cmd shim + spaces + JSON args 2026-04-16 16:37:17 +07:00
github-actions[bot]
a3822ca299 fix: update lockfile signatures and Nix hash 2026-04-16 09:17:53 +00:00
Mohamed Boudra
41fd45588f chore(release): cut 0.1.57 2026-04-16 16:16:30 +07:00
Mohamed Boudra
54b8126deb docs: add 0.1.57 changelog 2026-04-16 16:15:38 +07:00
github-actions[bot]
e70fa2dc84 fix: update lockfile signatures and Nix hash 2026-04-16 08:53:56 +00:00
Mohamed Boudra
cbb690efd0 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	packages/server/src/server/workspace-registry-model.test.ts
#	packages/server/src/utils/worktree.ts
2026-04-16 15:52:14 +07:00
Mohamed Boudra
7c50b7a080 fix(server): harden Codex/Windows startup and provider resolution (#454)
* fix(server): harden Codex/Windows startup and provider resolution

Addresses #452, #443, #353, #418, #403, #221, #307, and locks in #284.

- Replace custom where.exe/which output parsing with npm `which@^5` plus a
  spawn probe. findExecutable now enumerates all PATH+PATHEXT candidates
  and returns the first invokable one. A WindowsApps-ACL'd codex.exe no
  longer wins over a working codex.cmd (#452).
- Make default provider isAvailable() check the binary instead of always
  returning true. Codex/Claude/OpenCode default isAvailable() now defer
  to isCommandAvailable() so missing CLIs surface as unavailable instead
  of throwing later from spawn (#221, #443).
- Gate AgentManager.resumeAgentFromPersistence on isAvailable() so a
  persisted agent record with a missing binary cannot reach provider
  spawn during rehydration. The daemon stays up and the agent reports
  unavailable (#443, #353, #418, #403).
- Drop --path-format=absolute from rev-parse callers and validate stdout
  through a shared parser that rejects multi-line output and unknown-flag
  echoes. --show-toplevel is absolute by default in modern Git;
  --git-common-dir is resolved against the command cwd. Fixes workspace
  registration on pre-2.31 Git that echoed the unknown flag and produced
  a two-line "path" (#307).

Tests:
- Real-FS executable.test.ts using temp PATH fixtures, covering the .cmd
  fallback after .exe pre-spawn failure (Windows-only) plus the
  null-on-no-invokable-candidate case.
- provider-availability.test.ts builds real provider clients against a
  temp-dir-only PATH for Codex/Claude/OpenCode.
- bootstrap-provider-availability.test.ts builds the daemon and triggers
  ensureAgentLoaded so it actually exercises resumeAgentFromPersistence.
- claude-agent.spawn.test.ts asserts shell: false reaches spawnProcess
  from the Claude SDK spawn override, locking in 39b56af4 for #284.
- checkout-git-rev-parse.test.ts covers nested-checkout resolution and
  the old-Git multi-line stdout case via a tightly-scoped runGitCommand
  fake.

CI:
- server-tests-windows now also runs the new and modified test files so
  the Windows behaviors are exercised on windows-latest.

* fix: update lockfile signatures and Nix hash

* fix(server): handle synchronous spawn UNKNOWN on Windows

On Windows, child_process.spawn() throws synchronously when invoked on
a corrupt or invalid .exe (e.g., a WindowsApps stub the current user
cannot execute, or a zero-byte file). The executable probe did not
guard the spawn call, so the synchronous throw rejected the probe
promise instead of resolving false, preventing findExecutable() from
trying the next candidate. This is the root cause of the daemon-crash
pattern in #452: codex.exe from WindowsApps would hard-fail before the
.cmd shim ever got a chance.

Wrap spawn() in try/catch and settle false on sync throw. The existing
error-event and exit-event handlers already cover async failure modes;
sync throw just needed one more guard.

Also adjust three Windows-only test comparisons that were asserting
platform-dependent string equality:
- executable.test: compare .cmd paths case-insensitively (which@5
  preserves PATHEXT casing, which is uppercase in production).
- workspace-registry-model.test: expect normalizeWorkspaceId(path),
  not the hardcoded POSIX form.
- checkout-git-rev-parse.test: normalize separators/case when
  comparing git's Windows forward-slash output against realpathSync.

* test(server): canonicalize repo root via git on Windows to fix short-name mismatch

realpathSync on Windows preserves 8.3 short names (e.g. RUNNER~1) while git's
rev-parse --show-toplevel always returns the long-name form (runneradmin).
Use git as the canonicalizer on both sides of the assertion so the comparison
holds regardless of how Windows exposes the temp directory path.

* fix(server): Claude is always available in default mode; SDK bundles cli.js

The Phase 2 change wrongly tied Claude's default-mode isAvailable() to
isCommandAvailable("claude"). Claude's default runtime does not use an
external `claude` binary — @anthropic-ai/claude-agent-sdk ships its own
cli.js and spawnClaudeCodeProcess runs it via process.execPath. The
previous `return true` was correct; restore it.

Update provider-availability.test.ts to assert the truthful behavior:
Claude reports available even when no `claude` binary is on PATH.

Codex and OpenCode genuinely require their binaries on PATH, so their
availability checks remain unchanged.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-16 16:36:13 +08:00
Mohamed Boudra
21742bbeb4 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	nix/package.nix
#	packages/app/src/components/adaptive-modal-sheet.tsx
#	packages/app/src/hooks/use-providers-snapshot.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/agent/agent-management-mcp.ts
#	packages/server/src/server/agent/mcp-server.ts
#	packages/server/src/server/persistence-hooks.ts
#	packages/server/src/server/schedule/service.ts
#	packages/server/src/server/session.ts
2026-04-16 14:14:32 +07:00
Mohamed Boudra
11b407c804 fix(app): hide branch icon on non-git workspace headers
Gate the GitBranch icon behind isGitCheckout, and keep the header
skeleton visible until the checkout status query resolves so the icon
doesn't pop in and cause layout shift.
2026-04-16 13:14:00 +07:00
Mohamed Boudra
814098852c feat: provider model freshness TTL and diagnostic UI (#426)
* feat: add server-side TTL for provider snapshots and model list UI

Provider snapshots cached forever, causing newly available models
(e.g. OpenCode Go minimax, glm) to never appear in the picker.

Server: ProviderSnapshotManager now returns stale data immediately
and kicks off a background re-warm when the snapshot is older than
5 minutes. Injectable TTL/clock for testing.

App: Provider settings rows show model count and are tappable to
open a diagnostic sheet with a read-only model list (label + ID).

* feat: add per-provider refresh for models and diagnostic

* refactor: await refresh completion and clean up diagnostic sheet state

- ProviderSnapshotManager.refresh is now async; session.ts awaits it so
  the RPC ACK reflects real completion instead of just queuing work.
- Preserve models/modes/fetchedAt on entries during targeted refresh so
  the list no longer flashes empty mid-refresh.
- Show "Updated Xs ago" next to the Models count, plus loading and
  error states for the list body.
- Split resetSnapshotToLoading into full vs targeted branches.
- Flatten nested ternary in model list rendering into renderModelsBody.
- Drop redundant local refreshing state and cargo useMemo wrappers.

* fix(app): widen isImeComposingKeyboardEvent type to accept optional fields

TextInputKeyPressEventData has isComposing and keyCode as optional, so
Pick<KeyboardEvent, ...> was too narrow and broke typecheck on main.
2026-04-16 13:14:02 +08:00
Li Mu Zhi
5c6f175db5 fix: make code file preview text selectable on iOS (#447)
Add `selectable` prop to the code line Text component so users can
long-press to select and copy text on iOS. On web/desktop this is
already the default behavior via CSS user-select.

Markdown file preview has a similar issue but requires a different
fix (custom rules for react-native-markdown-display), left for a
follow-up.

Refs #238
Related #21

Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
2026-04-16 11:06:16 +08:00
Mohamed Boudra
278acf5fe1 docs: credit external contributors with PR/GitHub links in changelog
- Add attribution format requiring PR link and contributor GitHub for each bullet
- Skip attribution for core team (@boudra); changelog highlights community work
- One bullet can reference multiple PRs and contributors; de-duplicate names
- Document ordering: user-facing features first, then QoL, then internal-with-user-benefit
2026-04-16 10:03:27 +07:00
Aaron Florey
165ae58f75 fix(server): Improve OpenCode permission prompt context (#398)
Surface structured permission detail for OpenCode requests so clients can show command intent and richer context instead of generic placeholders. Humanize permission titles and include scope/reason metadata to make approval decisions clearer.
2026-04-16 11:01:56 +08:00
amir
a1f3256923 style(app): theme native scrollbars across all web views (#399)
* style(app): theme native scrollbars in tool call detail views

Apply CSS scrollbar-color to all ScrollViews in ToolCallDetailsContent
and DiffViewer so the browser scrollbar matches the app's dark theme
instead of showing the default white native scrollbar.

* refactor: split useWebScrollbarStyle into .web.ts/.native.ts platform files

- Native shim returns undefined without calling useUnistyles
- Web variant uses properly typed WebScrollbarStyle interface instead of `as any`
- Add .d.ts for TypeScript module resolution

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-16 10:59:31 +08:00
Mohamed Boudra
86bb5cc827 fix: make MCP tools work for archived agents, matching CLI code paths (#423)
Extract shared functions from session.ts (toAgentPersistenceHandle,
buildStoredAgentPayload, ensureAgentLoaded) so both CLI/WebSocket
handlers and MCP tools use the same code paths for agent lookup.

Fix get_agent_status, get_agent_activity, and list_agents MCP tools
to fall back to persistent storage for archived agents. Add
includeArchived param to list_agents. Fix setupFinishNotification
to not wake archived callers. Delete dead agent-management-mcp.ts.
2026-04-16 10:44:57 +08:00
Mohamed Boudra
483312e1dc feat: add disallowedTools to provider config
Allow providers to specify tools that should be disabled via a
disallowedTools array in config.json. This is useful for providers
that extend "claude" but point to third-party API endpoints that
don't support Anthropic-only server-side tools like WebSearch.

Closes #390
2026-04-16 09:42:54 +07:00
Edvard Chen
0b3c29b2ec fix(desktop): allow localhost origins in dev (#419)
Desktop dev uses Electron pages on random localhost ports, so the daemon must accept those websocket origins during local development.
2026-04-16 10:34:30 +08:00
Aaron Florey
1885d8602f feat(app): Render markdown files in file pane (#427)
Detect .md and .markdown files in the shared file pane and render them as markdown instead of syntax highlighted code. This makes README-style files easier to read without changing the existing preview flow for other text files or .mdx.

Keep the change focused to the app renderer by reusing the existing markdown stack and adding a narrow extension check with targeted coverage.
2026-04-16 10:33:49 +08:00
Aaron Florey
57312d4f75 fix(server): Map OpenCode todo and compaction events (#429)
Translate OpenCode todo and compaction events into Paseo timeline items so issue #106 uses the existing todo list and compaction UI without changing the client model.

Keep session.status handling limited to existing terminal states and add focused translator coverage for each mapped event.

Fixes #106

Co-authored-by: OpenCode <noreply@openai.com>
2026-04-16 10:29:05 +08:00
Rui Fan
ffbb2ffd06 fix: retry file explorer init when client reconnects after page refresh (#442)
hasInitializedRef was set to true before confirming the directory
listing request actually succeeded. On page refresh the WebSocket
client is still reconnecting, so requestDirectoryListing returned
early with "Host is not connected" and the ref stayed true —
preventing any retry once the client became available.

Fix: make requestDirectoryListing return Promise<boolean> and reset
hasInitializedRef on failure so the init effect re-runs automatically
when requestDirectoryListing is recreated after the client reconnects.

Fixes #441

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 10:28:02 +08:00
Edvard Chen
45a5ba8637 Fix duplicate command args for generic ACP providers (#444)
* Fix duplicate command args for generic ACP providers

* Fix desktop IME enter handling
2026-04-16 10:23:05 +08:00
github-actions[bot]
b8ade39f73 fix: update lockfile signatures and Nix hash 2026-04-15 14:03:10 +00:00
Mohamed Boudra
deca82653e chore(release): cut 0.1.57-rc.1 2026-04-15 21:02:07 +07:00
Mohamed Boudra
4e9d80d118 fix: prevent model name truncation in combobox item rows 2026-04-15 19:25:24 +07:00
Mohamed Boudra
5a20e78dff fix: restore timeline hydration after daemon restart and clean up defensive code
AgentLoadingService was extracted from session.ts but dropped all
hydrateTimelineFromProvider() calls, leaving agents with empty timelines
after daemon restart. Also fixed a duplicate initial-prompt send bug and
removed redundant null checks, dedup maps, and hasOwnProperty theater.
2026-04-15 18:20:46 +07:00
Mohamed Boudra
e6044264e2 refactor: transition to opaque workspace identifiers 2026-04-15 17:59:09 +07:00
github-actions[bot]
beeb315c8d fix: update lockfile signatures and Nix hash 2026-04-15 09:08:50 +00:00
Mohamed Boudra
e635c6beca merge: integrate main into dev
Resolve 9 merge conflicts preserving both sides' intent:
- nix hash, bootstrap hostnames rename, websocket-server wiring
- session.ts handler dedup, snapshot fallback, worktree registration
- worktree-session git service + script/setup features
- sidebar-workspace-list setup navigation, session-context cleanup
- workspace-registry-bootstrap test kept (code still exists)
- checkout-git test additions from both sides

Post-merge fixes: isPaneFocused prop rename, server type rebuild.
2026-04-15 16:07:39 +07:00
Li Mu Zhi
918949c7fa fix: file preview shows stale content when re-opening a file (#411)
* fix: file preview shows stale content when re-opening a file

The file content query inherits `refetchOnMount: false` and
`gcTime: Infinity` from the global QueryClient defaults, so
re-opening a previously viewed file never re-fetches its content.

Set `staleTime: 0` and `refetchOnMount: true` on the file preview
query so it always fetches the latest content when the component
mounts.

Fixes #351

* fix: keep original staleTime, only add refetchOnMount

staleTime: 5_000 is reasonable — avoids redundant fetches when
quickly toggling the same file. The actual fix only needs
refetchOnMount: true to override the global default of false.

---------

Co-authored-by: muzhi1991 <muzhi1991@limuzhideMac-mini-2.local>
2026-04-15 13:24:14 +08:00
José Albornoz
c1df523a77 fix: update lockfile signatures and Nix hash (#412)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 13:14:47 +08:00
Mohamed Boudra
60cc73f87e fix: thread allowEmptySubmit through composer submit path
The Create button on the new workspace screen was visible but clicking
it did nothing — submitAgentInput() still rejected empty payloads as
"noop". Thread allowEmptySubmit through Composer into submitAgentInput
so the callback fires and workspace creation + redirect completes.

Adds regression test for the empty-submit path.
2026-04-15 11:53:05 +07:00
Mohamed Boudra
7a8f65805d refactor: rename allowedHosts to hostnames (#413)
* refactor: rename allowedHosts to hostnames for DNS rebinding config

The old name confused users who thought it was related to CORS.
"hostnames" communicates that you're declaring the daemon's own
hostnames, not granting access to external parties.

Backward compatible: old config files (daemon.allowedHosts), env var
(PASEO_ALLOWED_HOSTS), CLI flag (--allowed-hosts), and Nix option
(allowedHosts) all still work as deprecated aliases.

* fix(nix): update npmDepsHash to match current package-lock.json
2026-04-15 12:47:49 +08:00
Mohamed Boudra
43bec954ea fix: resolve e2e test failures for terminal creation and new workspace
Terminal tests (launcher-tab, terminal-performance):
- Terminal creation no longer silently no-ops when workspace metadata
  hasn't hydrated. Early clicks are queued and flushed once the workspace
  directory is ready, with toast feedback.
- Terminal button is disabled while waiting on workspace readiness.
- Removed isFocused gate from ?open= intent consumption in workspace
  layout so deep-linked terminals open reliably on cold startup.

New workspace test:
- New workspace screen now shows a visible Create button even when the
  composer is empty, via new allowEmptySubmit and
  submitButtonAccessibilityLabel props threaded through Composer and
  MessageInput.
2026-04-15 11:42:18 +07:00
Mohamed Boudra
751a1df682 chore: clean up debug logging, temp files, and restore changelog 2026-04-15 11:15:50 +07:00
Mohamed Boudra
79ddf6980a fix: format message-input.tsx 2026-04-15 11:10:14 +07:00
Mohamed Boudra
9d30b7e85a fix: sync dev branch with main timeline/schema changes and remove stale providers
Forward-port main's timeline epoch tracking, projected window selection,
and schema updates into dev. Remove aider/amp/gemini providers and
provider-history-compatibility layers that are no longer needed.
2026-04-15 10:52:28 +07:00
Mohamed Boudra
89b87c0235 docs: add local test suite execution rules 2026-04-15 10:40:24 +07:00
Mohamed Boudra
fc96b260d1 refactor: rename isInputActive to isPaneFocused for clarity
The prop represents whether the pane/panel is focused, not whether the
text input is focused. Rename across all 5 files to match the caller
names and reduce confusion.
2026-04-15 10:36:36 +07:00
Mohamed Boudra
3fe79535cc fix: only show ⌘L focus hint when agent panel is active 2026-04-15 10:33:42 +07:00
Mohamed Boudra
d40e4b6f0d feat: add ⌘L shortcut to focus message input with hint
Show a muted "⌘L to focus" hint in the top-right of the message input
when unfocused and empty on web. Registers Cmd+L (Mac) / Ctrl+L
(non-Mac) bindings for the existing message-input.focus action.
2026-04-15 09:57:05 +07:00
Aaron Florey
8f04e65cf1 fix(opencode): Wait for SSE after slash command timeouts (#407)
Treat OpenCode slash-command header timeouts as recoverable transport failures so Paseo waits for the SSE terminal event instead of failing the turn immediately.

Add a regression test for the timeout path to keep slash commands aligned with the existing event-stream completion flow.
2026-04-15 10:52:58 +08:00
Aaron Florey
c5295a4ab3 fix(server): archive OpenCode sessions on close (#408)
Reconcile OpenCode provider state when Paseo closes or archives an agent so upstream sessions do not remain active after local teardown.

Keep the close path idempotent by attempting an abort first and then marking the upstream session archived even when the run is already idle or missing.

Fixes #400

Co-authored-by: OpenCode <noreply@openai.com>
2026-04-15 10:52:15 +08:00
Mohamed Boudra
f59af4c810 fix: catch unhandled timeout rejection in attention clear
clearAgentAttention returns a promise that was never caught,
causing uncaught rejection errors on daemon timeout.
2026-04-15 09:16:09 +07:00
Mohamed Boudra
e012c8f52e feat: return agent snapshots from cancel and clear-attention RPCs
Convert cancel_agent_request and clear_agent_attention from
fire-and-forget to request/response RPCs that return the authoritative
agent snapshot. This enables client-side self-healing when agent_update
messages are missed (e.g. agent appears stuck running when it isn't).
2026-04-14 23:21:37 +07:00
Mohamed Boudra
312a6e22fe fix: flush head before applying canonical catch-up entries on mobile
On mobile, the server drops live stream events for backgrounded or
unfocused agents.  When the user returns, a seq gap triggers a
catch-up fetch whose canonical entries are appended to the tail —
but the head was never flushed.  Stale live items from before the
gap stayed in the head and rendered after the newer catch-up entries,
breaking chronological order.  Worse, subsequent live events of the
same kind (e.g. assistant_message) would append to the stale head
item, garbling message content.

Now the incremental path in processTimelineResponse flushes head →
tail before reducing canonical entries, keeping the timeline ordered
and the head clean for new live events.
2026-04-14 23:03:23 +07:00
Mohamed Boudra
7f92f7f51d fix: stabilize branch switcher layout on mobile
Always render the GitBranch icon regardless of branch availability so
the layout doesn't shift when the branch name loads. Only the chevron
remains dynamic. Also removes vertical padding on mobile to tighten
the gap between the branch row and project subtitle.
2026-04-14 22:27:06 +07:00
Mohamed Boudra
200453f032 docs: add 0.1.56 changelog 2026-04-14 21:17:12 +07:00
Mohamed Boudra
326dad93e4 fix: remove hasSelectedAgent gate from Cmd+E and focus mode shortcuts
The hasSelectedAgent condition was redundantly gating Cmd+E (toggle right
sidebar) and Cmd+Shift+F (toggle focus mode) behind a pathname check that
Cmd+B and Cmd+. didn't require. The action handler already guards against
invalid states, making the when-clause check unnecessary and broken on the
dev branch's navigation flow.
2026-04-14 17:34:51 +07:00
Mohamed Boudra
c80d7a8165 fix: require content to create workspace — no more empty submissions
Replace allowEmptySubmit/emptySubmitLabel with hasExternalContent prop
that treats GitHub items the same as text and images in submit gating.
2026-04-14 17:28:31 +07:00
Mohamed Boudra
89db6d3187 fix(app): restore combobox flash prevention condition
The `referenceAtOrigin` check in `hasResolvedDesktopPosition` was
accidentally inverted during the provider profiles refactor (6f280276).
`!referenceAtOrigin` made the condition trivially true for all normal
triggers, letting the dropdown show at (0,0) before floating-ui resolved
the real position — visible as a left-side flash on the very first open.
2026-04-14 17:27:23 +07:00
Mohamed Boudra
d55209b81e fix: stabilize workspace e2e routing and cli helper export 2026-04-14 17:01:38 +07:00
Mohamed Boudra
0f0efa1707 fix: format providers snapshot hook 2026-04-14 16:41:35 +07:00
Mohamed Boudra
fb24029c69 chore: retrigger PR workflows on latest head 2026-04-14 16:33:42 +07:00
github-actions[bot]
7cba6cf414 fix: update lockfile signatures and Nix hash 2026-04-14 09:03:39 +00:00
Mohamed Boudra
16386ba6d7 Merge branch 'main' into dev 2026-04-14 16:02:23 +07:00
Mohamed Boudra
9c90657d60 fix: simplify workspace route navigation 2026-04-14 15:41:49 +07:00
Mohamed Boudra
d0be7c3ee7 fix: lock workspace navigation tab sync 2026-04-14 15:35:16 +07:00
Mohamed Boudra
1e0f62976d fix: checkpoint CI and provider snapshot changes 2026-04-14 14:23:25 +07:00
Mohamed Boudra
5241727b29 Merge branch 'main' into dev
# Conflicts:
#	packages/server/src/server/session.ts
2026-04-14 13:41:01 +07:00
Mohamed Boudra
7652ee10ca fix(e2e): click agent tab before asserting composer visibility
Workspace setup may auto-open a setup tab that steals focus, hiding
the agent panel. Click the agent tab to ensure it's active before
checking the composer textbox.
2026-04-14 13:16:34 +07:00
Mohamed Boudra
612979f95f fix(e2e): check for agent tab instead of draft "New Agent" tab
The empty-submit flow creates an actual agent, not a draft tab.
Assert on the agent tab testID prefix instead of specific tab text.
2026-04-14 12:41:28 +07:00
Mohamed Boudra
d480101115 fix: thread allowEmptySubmit through submit pipeline
submitAgentInput returned "noop" for empty messages regardless of
allowEmptySubmit, breaking the new-workspace empty-submit flow.
Now the flag is passed from Composer through to submitAgentInput
so empty submissions are allowed when the prop is set.
2026-04-14 12:25:59 +07:00
Mohamed Boudra
795c3cac97 docs: add custom provider configuration guide
Covers Z.AI (Zhipu), Alibaba Cloud (Qwen) coding plans, ACP providers
(Gemini CLI, Hermes), multiple profiles, custom binaries, and disabling
providers. All examples backed by official provider documentation.
2026-04-14 12:09:56 +07:00
Mohamed Boudra
4d6774f0c2 fix(e2e): scope Create button locator to message-input-root
The unscoped getByRole("button", { name: "Create" }) matched sidebar
"Create a new workspace" buttons from other test suites. Scoping to
message-input-root targets only the Composer submit button.
2026-04-14 12:07:57 +07:00
Mohamed Boudra
d888da8591 fix(e2e): submit creation form in new-workspace test
clickNewWorkspaceButton now completes the full user flow: after
clicking the sidebar button, waits for the /new route, then clicks
the Create button to submit the empty form and trigger workspace
creation + redirect.
2026-04-14 11:54:15 +07:00
Mohamed Boudra
74a65bd851 fix(e2e): derive new workspace ID from URL instead of sidebar rows
assertNewWorkspaceSidebarAndHeader was scanning all sidebar workspace
rows to find the newly created workspace, but would pick up workspaces
from concurrent test suites (e.g. archive-tab). Use the page URL as
source of truth since the app redirects to the new workspace after
creation.
2026-04-14 11:35:32 +07:00
Mohamed Boudra
4d5e8d870a fix: resolve archive-tab and new-workspace e2e failures (round 4)
- archive-tab: after workspace reload, only verify archived tab is
  hidden (agent tabs don't auto-appear without ?open= intent). Real-time
  archive propagation still fully verified before reload.
- new-workspace: use gotoAppShell + sidebar navigation instead of direct
  workspace URL navigation. Matches the pattern of passing tests where
  the WebSocket connection and workspace hydration complete before
  sidebar interaction.
2026-04-14 11:20:29 +07:00
Mohamed Boudra
95ea02a87f fix: resolve 6 remaining Playwright e2e test failures on CI
- archive-tab: replace waitForFunction with waitForURL for ?open= intent
  consumption, increase test timeout to 300s for slow rootNavigationState
  init on CI
- launcher-tab: use Control+t on Linux instead of Meta+t to match app's
  keyboard shortcut definitions for non-Mac platforms
- new-workspace: add waitForSidebarHydration to wait for daemon
  connection before checking sidebar rows, add re-navigation fallback
  if app redirects during hydration, increase timeouts
- setup-streaming: use waitForTerminalContent (xterm buffer API) instead
  of fragile .xterm-rows CSS text matching, wait for terminal attachment
  before checking content
2026-04-14 10:54:38 +07:00
Mohamed Boudra
e647c61fb5 Fix 5 remaining Playwright e2e failures
- Wait for ?open= intent consumption before expecting agent tabs
- Blur composer before second Meta+t to prevent shortcut swallowing
- Use projectDisplayName from daemon instead of computed path label
- Increase setup streaming script terminal timeout for CI
2026-04-14 10:19:42 +07:00
Mohamed Boudra
19585709d5 Fix 8 failing Playwright e2e tests after SQLite removal
- Delete workspace-hover-card.spec.ts (tests expected unimplemented script UI)
- Fix archive-tab helper to handle idle agents archived without modal
- Add waitForWorkspaceInSidebar helper for hydration timing
- Fix launcher-tab draft counting race with expect.poll waits
- Increase terminal-perf navigation timeouts for CI
- Spawn workspace scripts after worktree setup completes
2026-04-14 09:37:45 +07:00
Mohamed Boudra
369b46ea08 Fix e2e helpers: workspace IDs are strings, not numbers
After removing SQLite, workspace IDs are cwd path strings instead of
numeric database IDs. Update type declarations in workspace-setup.ts
and terminal-perf.ts, and remove the numeric parsing in
fetchWorkspaceById that would reject path-based IDs.
2026-04-14 06:26:38 +07:00
Mohamed Boudra
25ab97fe8b Fix e2e workspace-setup helper: use Node WebSocket factory
The workspace-setup helper was creating DaemonClient without providing a
webSocketFactory, causing failures on Node 20 where globalThis.WebSocket
is not available. Use createNodeWebSocketFactory() like all other e2e
helpers do.
2026-04-14 05:56:33 +07:00
Mohamed Boudra
1f13eee4a7 Restore authorAgentId in chat post command
The merge accidentally dropped the authorAgentId from postChatMessage,
causing the CLI chat test to fail since it asserts the sender ID appears
in the output.
2026-04-14 05:52:32 +07:00
Mohamed Boudra
cc6993042f Skip zsh-dependent terminal tests when /bin/zsh is unavailable
CI runners (Ubuntu) don't have zsh installed. Skip the two tests that
explicitly spawn /bin/zsh rather than failing the entire suite.
2026-04-14 05:44:53 +07:00
Mohamed Boudra
3f0c9f2679 Fix messages.workspaces.test.ts: add missing workspaceDirectory field 2026-04-14 05:41:10 +07:00
Mohamed Boudra
bc53cf0a04 Fix CI failures: server tests, app typecheck, workspace schema alignment
- Fix workspace test files to use file-backed registry schema (cwd/workspaceId
  instead of directory/numeric id)
- Remove DB-specific test setup (direct DB inserts) from snapshot-mutation test
- Fix worktree-session.ts: remove duplicate createAgentWorktree call, fix imports
- Fix daemon-client.test.ts and agent-manager.test.ts assertions
- Fix app typecheck: add `as any` casts for web-only backgroundImage CSS
- Fix session.workspace-git-watch.test.ts workspace shape
- Fix workspace-reconciliation-service.test.ts registry mocks
2026-04-14 05:36:58 +07:00
Mohamed Boudra
367d945954 Merge branch 'dev' of github.com:getpaseo/paseo into dev 2026-04-14 05:06:05 +07:00
Mohamed Boudra
c54e2ccabe Fix CI failures: server tests, app tests, CLI typecheck
- worktree.ts: remove double-shelling in execFileAsync (shell integration tests)
- agent-manager.test: expect "closed" status after archive+close
- use-open-project.test: fix workspace ID encoding (no longer base64)
- use-agent-input-draft.live.test: add QueryClientProvider wrapper
- voice-runtime.test: align assertions with dev branch runtime changes
- ci.yml: add server build step before typecheck (CLI needs dist types)
2026-04-14 05:05:54 +07:00
github-actions[bot]
750d38d395 fix: update lockfile signatures and Nix hash 2026-04-13 21:53:26 +00:00
Mohamed Boudra
84600609f0 Merge branch 'dev' of github.com:getpaseo/paseo into dev 2026-04-14 04:52:17 +07:00
Mohamed Boudra
4190a0aa72 Remove SQLite/Drizzle infrastructure from dev branch
Strip all database dependencies and revert to main's file-backed stores:
- Delete packages/server/src/server/db/ (schema, migrations, DB stores, legacy importers)
- Remove better-sqlite3, drizzle-orm, drizzle-kit, @electron/rebuild deps
- Revert bootstrap.ts to FileBackedProjectRegistry/WorkspaceRegistry/AgentStorage
- Restore workspace-registry-bootstrap.ts from main
- Revert AgentSnapshotStore → AgentStorage across all files
- Fix session.ts field names (directory→cwd, numeric id→string workspaceId)
- Remove DB-only tests from bootstrap.smoke.test, agent-manager.test, etc.
2026-04-14 04:51:53 +07:00
github-actions[bot]
43a9606c55 fix: update lockfile signatures and Nix hash 2026-04-13 21:08:58 +00:00
Mohamed Boudra
e3962e1753 Merge origin/main into dev (second round)
Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1).
Fix all type errors and syntax issues from merge artifacts.
2026-04-14 04:07:36 +07:00
Mohamed Boudra
8b1fbb7b86 Merge branch 'main' into dev
Resolve 38 conflicts preserving both sides' work:
- Server: DB-based registries (dev) + WorkspaceGitService/DaemonConfigStore (main)
- App: script proxy/voice MCP (dev) + pin/hide tabs/bootstrap boundary (main)
- Shared: WorkspaceScriptPayload (dev) + git/GitHub runtime schemas (main)
- Deleted workspace-registry-bootstrap.ts (dev's DB approach supersedes)
2026-04-14 03:05:45 +07:00
Mohamed Boudra
149a415016 fix: rebuild better-sqlite3 for Electron's Node ABI in prod builds
Electron 41 embeds Node 24 (ABI 145) while system Node 22 (ABI 127)
produces incompatible native modules. Add electron-rebuild step to the
desktop build pipeline so better-sqlite3 is recompiled against
Electron's headers before packaging.
2026-04-11 18:33:49 +07:00
Mohamed Boudra
28f45a41a5 feat: add prompt attachment support across sessions 2026-04-11 16:58:39 +07:00
Mohamed Boudra
aa1d33f0a0 chore: checkpoint current worktree state 2026-04-11 13:09:51 +07:00
Mohamed Boudra
3b8cfcbec6 Support remote workspace service links 2026-04-11 10:15:10 +07:00
Mohamed Boudra
2bbdc7ed86 fix: add 5px offset between combobox popover and anchor on web 2026-04-10 22:05:33 +07:00
Mohamed Boudra
404b3b1dbf feat: populate GitHub dropdown on new workspace screen
- Fire GitHub search query on mount instead of waiting for 2+ chars
- Add staleTime so combobox reuses cached results on open/close
- Single-select: replace multi-item chips with a transforming trigger
- Trigger shows issue/PR icon, truncated title, and ExternalLink icon
- ExternalLink opens GitHub URL via openExternalUrl (desktop-aware)
- Add ExternalLink trailingSlot on each combobox option row
- Hover on ExternalLink changes color from foregroundMuted to foreground
- Constrain badge width (maxWidth: 240) with flexShrink on text
2026-04-10 22:03:57 +07:00
Mohamed Boudra
7f63fc6ceb Replace script status dots with colored type-aware icons
Use Globe for services and SquareTerminal for scripts instead of
plain status dots. Play icon for the Scripts section title. Running
status uses blue instead of green. Scripts button moved before Open
in Editor in the workspace header.
2026-04-10 22:03:39 +07:00
Mohamed Boudra
944978ddb6 Merge branch 'main' into dev 2026-04-10 20:59:14 +07:00
github-actions[bot]
a27858e743 fix: update lockfile signatures and Nix hash 2026-04-10 13:26:08 +00:00
Mohamed Boudra
8789ef4ad8 fix: resolve nix hash conflict, take main's updated hash 2026-04-10 20:22:25 +07:00
Mohamed Boudra
321ffce6a3 Merge remote-tracking branch 'origin/main' into dev 2026-04-10 19:41:19 +07:00
Mohamed Boudra
78b7e51396 Merge branch 'main' into dev 2026-04-10 17:15:58 +07:00
Mohamed Boudra
0deaed3794 Merge branch 'main' into dev 2026-04-10 14:44:57 +07:00
github-actions[bot]
18adfb4f1d fix: update lockfile signatures and Nix hash 2026-04-10 03:18:22 +00:00
Mohamed Boudra
4176bd8aa1 Merge branch 'main' into merge/main-into-dev
# Conflicts:
#	packages/app/src/components/combined-model-selector.tsx
#	packages/app/src/components/message-input.tsx
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/screens/agent/draft-agent-screen.tsx
#	packages/app/src/screens/settings-screen.tsx
#	packages/desktop/src/main.ts
#	packages/server/src/server/agent/provider-launch-config.ts
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/shared/messages.ts
#	packages/server/src/utils/checkout-git.ts
2026-04-10 10:15:49 +07:00
Mohamed Boudra
4215120d7c Fix agent close persistence, whitespace diff filtering, and diff param cleanup
- Persist snapshot before emitting closed agent to avoid data loss
- Flush after branch rename so state hits disk immediately
- Filter phantom whitespace-only files from ignore-whitespace diffs
- Only include ignoreWhitespace param when true to keep payloads clean
- Add createFeature test helper for AgentFeature construction
2026-04-10 09:50:46 +07:00
Mohamed Boudra
e6dec3fc1a Add full CI workflow with format, typecheck, and all test suites
Adds ci.yml covering: biome format check, typecheck across all packages,
server tests (unit + integration), app unit tests, Playwright E2E,
relay tests, and CLI tests. Each job runs independently in parallel.
2026-04-10 00:11:34 +07:00
Mohamed Boudra
b1b85833bb Distinguish services from scripts with type system and runtime tracking 2026-04-09 23:45:00 +07:00
Mohamed Boudra
c7399084a4 Add hidden agent tab intent for workspace reconcile 2026-04-09 20:39:27 +07:00
github-actions[bot]
a236bd4515 fix: update lockfile signatures and Nix hash 2026-04-09 18:56:16 +07:00
Mohamed Boudra
aa59e67df5 chore(release): cut 0.1.51 2026-04-09 18:56:16 +07:00
Mohamed Boudra
96f736fa6e docs(changelog): add 0.1.51 release notes 2026-04-09 18:56:16 +07:00
Mohamed Boudra
1b309ae0c9 fix(app): fix BottomSheetTextInput crash on iPad model selector
On iPad, Combobox uses the desktop Modal path (no BottomSheet context)
since the breakpoint is "md"+, but ProviderSearchInput was checking
Platform.OS === "web" to choose the input component. This caused
BottomSheetTextInput to render outside a BottomSheet on iPad.

Align the check with Combobox's own isMobile breakpoint logic.
2026-04-09 18:56:16 +07:00
Mohamed Boudra
fa2f501dc7 Use advertised hostnames during pairing 2026-04-09 18:56:10 +07:00
Mohamed Boudra
02a7a170f6 fix(app): clean up QR scan screen — remove instruction text, use accent color for crosshairs 2026-04-09 18:56:10 +07:00
Mohamed Boudra
56fdc47d9c feat(server): support image attachments in OpenCode agent prompts
Adds logic to process file parts and convert image attachments into data URLs for the OpenCode provider, replacing the previous behavior that stripped non-text parts.
2026-04-09 18:56:10 +07:00
Mohamed Boudra
74f7b9e416 Add configurable send behavior 2026-04-09 18:56:09 +07:00
Mohamed Boudra
ce77947654 fix(app): wrap daemon settings section in React Query with loading state
Prevents the daemon section from flashing "not running" / "PID —" while
IPC data loads. The whole card now shows a spinner until the first fetch
completes, and mutations (restart/stop/start) optimistically update the
query cache with the returned status.
2026-04-09 18:56:02 +07:00
Illia Panasenko
0f0bfb551c feat(app): Add WebStorm editor target (#220)
* Add WebStorm editor target

* Fix rebase type errors and add TODO date tag

- Await listAvailableEditorTargets() which became async on main
- Wrap sendAgentMessage callback to match Promise<void> return type
- Add TODO(2026-07) date to legacy editor target ids comment

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-04-09 18:56:02 +07:00
Benjamin Kitt
f4b487df78 Fix commands not loading for Pi agent (#225) 2026-04-09 18:55:19 +07:00
Mohamed Boudra
2a5ac7a013 Commit current desktop and CLI diff 2026-04-09 18:55:19 +07:00
Mohamed Boudra
4068554532 Fix opencode terminal states and model refresh 2026-04-09 18:55:19 +07:00
Mohamed Boudra
9c8bc2e208 Fix agent follow-up turns and wait lifecycle 2026-04-09 18:55:19 +07:00
Mohamed Boudra
d69d5c92ce fix(app): improve pair device screen with read-only input for link
Replace truncating text with a read-only TextInput + copy button pattern
so the pairing URL doesn't get cut off. Increase QR code size from
200x200 to 320x320.
2026-04-09 18:54:55 +07:00
Mohamed Boudra
b8e048c7fa refactor: make executable resolution async and centralize spawn
- Rename findExecutable → findExecutableSync, add async findExecutable
  that uses promisified execFile instead of execFileSync
- Simplify Windows PATH resolution: rely on inherited env from Explorer
  instead of shelling out to PowerShell for registry PATH entries
- Add spawnProcess() helper that centralizes Windows shell/quoting concerns
- Update all agent providers and consumers to use async executable resolution
- Add windowsHide: true across all Windows child process calls
2026-04-09 18:54:55 +07:00
Mohamed Boudra
75542c10d8 fix(claude): filter <local-command-stdout> messages from timeline
Claude Code writes local CLI command outputs (model switches, /context,
/compact, goodbye messages, etc.) as user entries in the JSONL history.
These were leaking through as user_message timeline items in Paseo.
2026-04-09 18:54:46 +07:00
Mohamed Boudra
0e3e9bcd0c docs(website): update mobile app availability status
Apps are now available on App Store and Play Store.
2026-04-09 18:54:46 +07:00
Mohamed Boudra
4786bbbb0b fix(ci): prevent duplicate GitHub releases in desktop workflow
Add a create-release job that runs first and creates the GitHub release
before any platform build jobs start. This prevents the race condition
where parallel electron-builder jobs each create their own release.

Platform jobs now depend on create-release and just upload assets to
the existing release. Single-platform retry tags (desktop-macos-v*,
desktop-linux-v*, desktop-windows-v*) skip the create-release job
since the release already exists from the original build.
2026-04-09 18:54:46 +07:00
github-actions[bot]
4f58f889d6 fix: update lockfile signatures and Nix hash 2026-04-09 18:54:46 +07:00
Mohamed Boudra
d389200dc3 chore(release): cut 0.1.51-rc.1 2026-04-09 18:54:25 +07:00
Mohamed Boudra
c1420aff3c fix(desktop): enable electron-log console transport for stdout output
Ensures renderer console.log output (including layout-debug) is
printed to stdout in packaged builds, not just the log file.
Also removes unnecessary webContents event listeners.
2026-04-09 18:54:25 +07:00
Mohamed Boudra
9cb0358fae debug(desktop): add renderer console-message and lifecycle event logging
Pipes all renderer console.log output to the main process stdout via
electron-log, and logs did-finish-load/did-fail-load/render-process-gone
events to help diagnose layout and loading issues on Linux.
2026-04-09 18:54:25 +07:00
Mohamed Boudra
65b3779da5 fix: log bootstrap errors to daemon.log and add layout debug logging
- Log fatal errors during daemon bootstrap and startup to pino (daemon.log)
  instead of only writing to stderr, which is invisible on Windows desktop
- Delay process.exit to let pino async streams flush
- Make voice MCP bridge script resolution non-fatal (warn + disable instead
  of crashing the daemon)
- Add PASEO_ELECTRON_FLAGS env var for passing Chromium flags on Linux
- Add layout debug logging to AppContainer for diagnosing sidebar issues
2026-04-09 18:54:25 +07:00
Mohamed Boudra
7a9348a5fd feat: add GitHub issue and PR context picker to new workspace screen 2026-04-09 18:54:25 +07:00
Mohamed Boudra
397d454de0 refactor: rename services to scripts and extract reusable components 2026-04-09 18:54:25 +07:00
github-actions[bot]
5cf806dc77 fix: update lockfile signatures and Nix hash 2026-04-08 05:05:36 +00:00
Mohamed Boudra
529e743f1e chore: update package-lock.json with expo, react, and react-native dependencies 2026-04-08 12:04:27 +07:00
Mohamed Boudra
cefc189331 Merge remote-tracking branch 'origin/main' into merge/main-into-dev
# Conflicts:
#	packages/app/src/app/h/[serverId]/index.tsx
#	packages/app/src/components/agent-stream-view.tsx
#	packages/app/src/utils/host-routes.ts
2026-04-08 11:57:24 +07:00
Mohamed Boudra
666d3fe807 Merge remote-tracking branch 'origin/main' into merge/main-into-dev
# Conflicts:
#	nix/package.nix
#	packages/app/src/screens/settings-screen.tsx
#	packages/server/src/server/session.workspace-git-watch.test.ts
#	packages/server/src/server/session.workspaces.test.ts
2026-04-08 11:54:46 +07:00
Mohamed Boudra
65f8823e4e Merge remote-tracking branch 'origin/main' into merge/main-into-dev
# Conflicts:
#	CLAUDE.md
#	nix/package.nix
#	packages/app/src/components/combined-model-selector.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/app/src/stores/session-store.test.ts
#	packages/server/src/client/daemon-client.ts
#	packages/server/src/server/agent/agent-manager.ts
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspace-git-watch.test.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/shared/messages.ts
2026-04-08 11:38:17 +07:00
Mohamed Boudra
07618f248f Extract PrBadge component and refactor workspace hover card 2026-04-06 22:31:43 +07:00
Mohamed Boudra
7b19dd736f feat: add service start capability with health monitoring 2026-04-06 20:16:32 +07:00
Mohamed Boudra
7173342bcb feat: replace launcher panel with direct tab creation, use directory-based workspace IDs, and add backward-compatible schema fields 2026-04-06 18:15:30 +07:00
Mohamed Boudra
6e5cad812e feat: service route branch handling, workspace hover card redesign, and agent form state improvements
- Add service-route-branch-handler for routing requests to branch-specific services
- Add service-hostname utility for generating hostnames from branch names
- Redesign workspace hover card with CI check status display
- Update combined model selector and sidebar workspace list
- Improve agent form state and input draft hooks
- Update checkout-git with enhanced branch handling
- Add workspace git watch and bootstrap improvements
2026-04-06 14:38:29 +07:00
Mohamed Boudra
5d1b3383d7 fix: only confirm archive when agent is running
Skip the confirmation dialog when archiving idle agents — archive
immediately. Show a warning that the agent will be stopped only when
it is running or initializing.
2026-04-06 11:04:05 +07:00
Mohamed Boudra
f75e28a77f feat: add CI check status to workspace sidebar and hover card
Extend the existing gh pr view call with statusCheckRollup and
reviewDecision fields so check data arrives in a single request.
The workspace row now shows an aggregate check icon (green checkmark,
red X, or amber dot) next to the PR badge, and the hover card lists
each individual check with its status and a link to details.
2026-04-06 10:54:56 +07:00
Mohamed Boudra
eabd561fde remove monospace font from command text 2026-04-06 10:19:17 +07:00
Mohamed Boudra
339023548d refactor: replace model tooltip with inline description for opencode/pi 2026-04-06 09:53:42 +07:00
Mohamed Boudra
6eb26f8c6c fix: keep composer text visible during workspace creation
No-op clearDraft so the prompt stays while worktree + agent are being
created. Screen navigates away on success; text remains for retry on error.
2026-04-06 09:43:13 +07:00
Mohamed Boudra
fc55eaf12b fix: prevent base64 misinterpretation of numeric workspace IDs
Numeric string IDs like "164" are valid base64 that decodes to garbage
(Hebrew character "׮"), causing silent workspace lookup failures.
Skip base64 encoding/decoding for numeric IDs and only use it for
legacy path-based workspace identifiers.
2026-04-06 09:28:49 +07:00
Mohamed Boudra
5d09f7449e fix: revert getCurrentPathname regression and hide startup splash on web
getCurrentPathname used window.location.pathname instead of Expo Router's
pathname, which returns a file path on Electron — blocking navigation from
the index route. Also gate the bootstrap progress UI to desktop-only since
web has no local server to start/connect.
2026-04-06 09:24:54 +07:00
Mohamed Boudra
dc772021a8 fix: use source field in package.json exports instead of array 2026-04-06 08:35:04 +07:00
Mohamed Boudra
93b2199c56 fix: emit workspace update after shortstat cache warm
Add onComplete callback to warmCheckoutShortstatInBackground so session
can push a workspace_update once diff stats resolve.
2026-04-06 08:35:02 +07:00
Mohamed Boudra
8d4d445c85 fix: migrate workspace/project IDs to string and widen schema enums
- Change workspace id/projectId from z.number() to z.union([z.string(), z.number()]).transform(String) for backward compat
- Add "non_git" to projectKind, "local_checkout" to workspaceKind enums
- Session converts numeric IDs to strings at the descriptor boundary
- Update tests and daemon-client event types to match
2026-04-06 08:34:58 +07:00
Mohamed Boudra
d3e5d6929d fix: optimistic archiving/closing and tab UX improvements
- Archive agents optimistically on close with rollback on error
- Close tabs immediately instead of waiting for RPC response
- Kill terminals in background with cache invalidation on failure
- Bulk close fires RPC in background, closes all tabs upfront
- Move new-tab button out of scroll area, rename to "New agent tab"
- Support invalidateQueries option in applyArchivedAgentCloseResults
- Fix workspace descriptor id/projectId types (string not number)
2026-04-06 08:32:10 +07:00
Mohamed Boudra
6362d429c7 feat: new workspace screen with sticky search combobox design
- Replace workspace setup dialog with full-screen new workspace route
- Restyle Combobox SearchInput to match CombinedModelSelector Level 2 design:
  borderless sticky search bar above scroll area instead of inline bordered box
- CombinedModelSelector now uses shared SearchInput, removing duplicate ProviderSearchInput
- Fix TooltipTrigger children type to accept render functions (remove PropsWithChildren wrapper)
- Fix empty rightControls View causing gap between dictation and send buttons
- Add branch picker with searchable Combobox and GitBranch icons
- Muted icon buttons (attachment, dictation, voice mode) that brighten on hover
2026-04-06 08:31:28 +07:00
Mohamed Boudra
c6e87744f1 fix: reconcile display names for worktrees, not just checkouts
The reconciliation service was skipping worktree workspaces when
updating displayName from the current git branch, causing the sidebar
to show the initial random animal name instead of the actual branch.
2026-04-05 22:31:07 +07:00
Mohamed Boudra
8f83876a3c fix(stream): restore live timeline emission and preserve timeline events 2026-04-05 22:28:11 +07:00
Mohamed Boudra
25213ce83a server: cache workspace shortstat and warm in background 2026-04-05 22:28:07 +07:00
Mohamed Boudra
ce821afd27 fix: harden migration/reconciliation and fix legacy import project grouping
- DbProjectRegistry/DbWorkspaceRegistry: use ON CONFLICT(directory) instead
  of ON CONFLICT(id) so inserts and upserts handle duplicate directories
  gracefully instead of crashing
- Bootstrap: wrap legacy imports in try/catch (non-fatal), move
  reconciliation start after imports so first pass cleans up stale data
- Legacy project/workspace import: deduplicate by rootPath (prefer git
  over non_git), derive gitRemote from legacy projectId field
- Legacy agent snapshot import: detect git metadata and group agents by
  remote/toplevel instead of creating one project per cwd, clamp
  running/initializing statuses to closed
- Timeline hydration: set historyPrimed based on whether durable store
  has rows (not just whether it exists), remove hard gate so imported
  agents get their timelines populated lazily from provider history
- Durable timeline append: use bulkInsert with pre-assigned seq instead
  of appendCommitted which recalculates seq, fixing UNIQUE constraint
  failures during concurrent hydration writes
2026-04-05 22:26:45 +07:00
Mohamed Boudra
cc6b0f80be fix: remove duplicate imports in daemon-client.ts 2026-04-05 21:56:24 +07:00
github-actions[bot]
5f0b6e7146 fix: update lockfile signatures and Nix hash 2026-04-05 14:27:22 +00:00
Mohamed Boudra
76a9c6f950 Merge branch 'main' into merge/main-into-dev
Brings in 8 main commits (0.1.48 release, provider overhaul, keyboard
shortcuts, desktop login shell env, question form Enter key) with proper
git history. Resolves conflicts by keeping dev branch architecture where
it subsumes main's changes, and main's provider snapshot COMPAT comments.
2026-04-05 21:26:07 +07:00
Mohamed Boudra
4dca672711 Restore features lost during main merge and fix workspace setup flow
Restores all features dropped when merging main into dev:
- Keyboard shortcuts (send, dictation-confirm) with full e2e chain
- Feature toggles (plan/fast mode) in agent status bar
- Sidebar kebab menu (Remove Project) on project rows
- Combined model selector (sticky header, sizing, favorites sort, search)
- Question form Enter key submit with guard
- Input focus restoration (composer + AgentStatusBar onDropdownClose)
- Draft-agent feature toggles and focus restoration
- Provider diagnostics UI (settings section, diagnostic sheet, status badge)
- Provider diagnostics server (snapshot manager, diagnostic utils, RPC)

Fixes dev-branch regressions:
- New workspace button now opens composer in setup dialog via beginWorkspaceSetup
- Setup tab auto-opens when workspace setup is running
2026-04-05 21:10:16 +07:00
Mohamed Boudra
8e482b1613 fix: use numeric workspace ID for worktree setup tracking and improve service list UI 2026-04-05 18:16:31 +07:00
Mohamed Boudra
2dd597f5f5 Harden SQLite migration and apply post-merge fixes
Migration hardening:
- Back up JSON to $PASEO_HOME/backup/pre-migration/ before import
- Deduplicate projects/workspaces by path before insert
- Log per-batch progress during agent snapshot import
- Clear error messages for corrupt JSON files
- 5 new test cases for backup, dedup, progress, and error clarity

Post-merge fixes:
- Add worktreeRoot to session checkout result (type error fix)
- Port reload agent tab action from main
- Remove deleted useDelayedHistoryRefreshToast hook usage
2026-04-05 13:05:54 +07:00
Mohamed Boudra
fcfb9c99da Merge branch 'main' into dev
Surgical merge of 74 commits from main into dev. Key features ported:

- Provider visibility gating (appVersion filtering for Pi/Copilot)
- Pi agent provider + Copilot re-enabled
- Provider-declared features system (Codex fast mode)
- Workspace dedup by worktree root (adapted to integer IDs)
- Bulk close archiving fix for stored agents
- Agent creation timeout increase to 60s
- Audio/voice crash fixes (external buffer copies)
- Multi-host setup fix
- Reload agent tab action

Dev architecture preserved: SQLite storage, service proxy,
workspace setup dialog, hover cards, removed launcher tabs.
2026-04-05 12:59:07 +07:00
Mohamed Boudra
5e1b24b217 refactor(app): remove launcher tabs from workspace flow 2026-04-04 15:37:26 +07:00
Mohamed Boudra
2ca7dd71e2 Tweak new tab button: larger icon, foreground hover, more padding 2026-04-04 15:37:26 +07:00
Mohamed Boudra
449a4b48d2 Support PowerShell and resolve workspaces by directory path 2026-04-04 15:37:26 +07:00
github-actions[bot]
5d5a79a2a8 fix: update lockfile signatures and Nix hash 2026-04-03 03:40:45 +00:00
Mohamed Boudra
59bc3bd3d1 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	nix/package.nix
#	packages/app/src/components/icons/opencode-icon.tsx
#	packages/app/src/components/provider-icons.ts
#	packages/app/src/panels/agent-panel.tsx
#	packages/cli/src/commands/provider/ls.ts
#	packages/server/src/server/agent/provider-manifest.ts
#	packages/server/src/server/agent/provider-registry.ts
#	packages/server/src/server/agent/providers/claude-agent.test.ts
#	packages/server/src/server/agent/providers/claude-agent.ts
#	packages/server/src/server/persistence-hooks.ts
#	packages/server/src/server/session.ts
2026-04-03 10:10:20 +07:00
Mohamed Boudra
86cee8e43a Merge branch 'dev' of github.com:getpaseo/paseo into dev 2026-04-02 14:38:37 +07:00
Mohamed Boudra
c4c17df022 Merge branch 'main' of github.com:getpaseo/paseo into dev 2026-04-02 14:37:00 +07:00
Mohamed Boudra
34c27fe77e refactor: simplify workspace setup dialog to single-step chat-first flow and stabilize worktree PASEO_HOME 2026-04-02 14:36:42 +07:00
Mohamed Boudra
b730d83943 fix: deduplicate projects by git remote and detect worktree workspaces
Reuse existing project when a matching git remote is found instead of
creating duplicates. Detect git worktrees via --git-common-dir and set
workspace kind accordingly.
2026-04-02 11:31:13 +07:00
github-actions[bot]
f1ebe518f8 fix: update lockfile signatures and Nix hash 2026-04-02 03:59:16 +00:00
Mohamed Boudra
68c8efc5d3 Merge branch 'dev' of github.com:getpaseo/paseo into dev
# Conflicts:
#	nix/package.nix
2026-04-02 10:58:04 +07:00
Mohamed Boudra
73e6a42141 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	nix/package.nix
2026-04-02 10:56:17 +07:00
Mohamed Boudra
060f3d457c refactor: remove terminal agent concept entirely
Remove the "agent can be a terminal" branching from the entire codebase.
An agent is now always a session-backed chat agent. Standalone terminal
infrastructure (terminal component, ANSI handling, terminal-stream-protocol)
is preserved.

Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails,
launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited,
supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from
all providers, terminal agent persistence/projections.

App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts,
terminal/terminalExit fields on agent state, "Terminal Agents" launcher
section, terminal-agent workspace setup flow, terminal badge in agent list.

CLI: remove terminal column from ls, terminal-agent error from send.

60 files changed, -3592 lines
2026-04-02 10:55:54 +07:00
github-actions[bot]
255270b5ee fix: update lockfile signatures and Nix hash 2026-04-01 16:12:25 +00:00
Mohamed Boudra
59cb4d7499 fix: resolve type errors and test failures from branch integration
- Align portless code with storage branch's numeric workspace IDs and new field names
- Update workspace kind comparisons (local_checkout → checkout/worktree)
- Add missing services, supportsTerminalMode, terminal fields to test fixtures
- Fix archive timestamp assertions to match dynamic archiveSnapshot flow
- Fix dictation, voice runtime, and service health monitor test timing
- Add xterm-addon-ligatures type declaration for terminal emulator
2026-04-01 22:58:59 +07:00
Mohamed Boudra
d75dd33773 Merge branch 'investigate/portless' into dev
# Conflicts:
#	packages/app/e2e/helpers/workspace-setup.ts
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/contexts/session-context.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.test.ts
#	packages/app/src/screens/workspace/workspace-tab-menu.ts
#	packages/app/src/stores/workspace-setup-store.ts
#	packages/app/src/stores/workspace-tabs-store.ts
#	packages/app/src/utils/sidebar-project-row-model.test.ts
#	packages/app/src/utils/sidebar-shortcuts.test.ts
#	packages/app/src/utils/workspace-tab-identity.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/worktree-session.ts
2026-04-01 22:06:39 +07:00
Mohamed Boudra
f1ffecfcd4 Merge branch 'storage-terminal-ui-dev' into dev
# Conflicts:
#	nix/package.nix
#	packages/app/src/app/_layout.tsx
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/hooks/use-command-center.ts
#	packages/app/src/screens/agent/draft-agent-screen.tsx
#	packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/terminal/terminal.test.ts
#	packages/server/src/terminal/terminal.ts
2026-04-01 21:51:29 +07:00
Mohamed Boudra
a7d5ab2497 WIP: workspace execution refactor + notification routing + setup store updates 2026-04-01 21:28:07 +07:00
Mohamed Boudra
d8243bfb72 WIP: snapshot workspace execution refactor baseline 2026-04-01 10:35:06 +07:00
Mohamed Boudra
5b3ec572cb feat: service proxy, health monitor, hover card, and homepage section
Built-in service proxy with branch-based URLs, service health
monitoring, workspace hover card with service status, and
"Forget about ports" homepage section.
2026-04-01 10:31:15 +07:00
Mohamed Boudra
806079c7ad Emit running state when workspace setup begins 2026-03-31 16:22:51 +07:00
Mohamed Boudra
1f6ffa9e0e Add workspace setup streaming and setup tab 2026-03-31 09:09:38 +07:00
Mohamed Boudra
266d042528 feat(server): built-in service proxy — absorb portless into daemon
Services defined in paseo.json get reverse-proxied through the daemon
via hostname-based routing on *.localhost. Each service receives $PORT,
$HOST, and $PASEO_SERVICE_URL env vars, and is accessible at
{service}.localhost:6767 (main) or {branch}.{service}.localhost:6767
(worktrees).
2026-03-30 21:41:03 +07:00
github-actions[bot]
e026223c80 fix: update lockfile signatures and Nix hash 2026-03-30 10:31:47 +00:00
Mohamed Boudra
2190910e6f fix: resolve type errors from main merge
- Remove orphaned PID lock code from bootstrap (moved to supervisor)
- Fix worktree archive adapter to look up workspace by directory
- Replace registerWorktreeWorkspaceRecord with inline SQLite implementation
- Remove unused imports (stat, createPersistedWorkspaceRecord, PersistedProjectRecord)
2026-03-30 17:30:31 +07:00
Mohamed Boudra
0d3f59feed Merge branch 'main' into storage-terminal-ui-dev 2026-03-30 17:14:03 +07:00
github-actions[bot]
edcd2fe5d5 fix: update lockfile signatures and Nix hash 2026-03-29 16:22:05 +00:00
Mohamed Boudra
7d02e24d84 feat: add sqlite storage and terminal ui 2026-03-29 23:20:18 +07:00
374 changed files with 33565 additions and 8110 deletions

View File

@@ -43,6 +43,9 @@ jobs:
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Typecheck all packages
run: npm run typecheck
@@ -99,7 +102,22 @@ jobs:
- name: Run Windows-critical server tests
working-directory: packages/server
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
run: >
npx vitest run
src/utils/executable.test.ts
src/utils/spawn.launch-regression.test.ts
src/utils/spawn.test.ts
src/utils/run-git-command.test.ts
src/utils/checkout-git-rev-parse.test.ts
src/server/agent/provider-registry.test.ts
src/server/agent/provider-launch-config.test.ts
src/server/agent/provider-snapshot-manager.test.ts
src/server/agent/providers/claude-agent.spawn.test.ts
src/server/agent/providers/provider-windows-launch.test.ts
src/server/agent/providers/provider-availability.test.ts
src/server/workspace-registry-model.test.ts
src/server/persisted-config.test.ts
src/server/bootstrap-provider-availability.test.ts
app-tests:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,45 @@
# Changelog
## 0.1.59 - 2026-04-16
### Added
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
- Extra High reasoning effort for Opus 4.7, between High and Max.
## 0.1.58 - 2026-04-16
### Added
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
- `disallowedTools` option in provider config to block specific tools from an agent.
### Improved
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey))
- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey))
- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423))
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
### Fixed
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
- Workspace headers no longer show a branch icon for non-git workspaces.
- Branch switcher layout is stable on mobile.
- Model names no longer truncate mid-word in the picker rows.
- Messages appear in the correct order after reconnecting on mobile.
- Clearing agent attention no longer throws on timeout.
## 0.1.56 - 2026-04-14
### Fixed
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
- A single problematic project can no longer prevent the rest of your workspaces from loading.
## 0.1.55 - 2026-04-14
### Added

View File

@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- **Always run typecheck after every change.**
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:

View File

@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
## Agent authentication

View File

@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
listen: "127.0.0.1:0", // OS picks a free port
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
hostnames: true,
mcpEnabled: false,
staticDir,
mcpDebug: false,

View File

@@ -0,0 +1,158 @@
# Attachment-Based Review Context Plan
## Goal
Use structured attachments as the source of truth for review context during agent creation.
This covers two related behaviors:
1. Provider-facing context for the agent prompt
2. Worktree checkout behavior when creating a worktree from a review item
The design should stay compatible with future forge support such as GitLab.
## Core Direction
- Do not parse the prompt to discover issue/PR intent.
- Do not introduce deeply nested transport objects for this flow.
- Make the attachment itself the discriminated union.
- Keep forge-specific checkout logic below the attachment layer.
- Let each agent provider translate attachments into its own prompt/input format.
## Initial Attachment Types
Two initial structured attachment types:
- `github_pr`
- `github_issue`
With separate MIME types:
- `application/github-pr`
- `application/github-issue`
Proposed wire shape:
```ts
type AgentAttachment =
| {
type: "github_pr";
mimeType: "application/github-pr";
number: number;
title: string;
url: string;
body?: string | null;
baseRefName?: string | null;
headRefName?: string | null;
}
| {
type: "github_issue";
mimeType: "application/github-issue";
number: number;
title: string;
url: string;
body?: string | null;
};
```
## Backward Compatibility
- Add new optional `attachments` fields; keep existing `images` fields.
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
- Malformed attachment entries should also be ignored safely.
- Old clients continue working because `attachments` is optional.
- New clients can send `attachments` without breaking older flows that still rely on `images`.
## Request-Level Changes
Add optional `attachments` to:
- `create_agent_request`
- `send_agent_message_request`
Existing `initialPrompt` remains plain user text.
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
## App Responsibilities
When the user selects a GitHub item:
- If it is a PR, create a `github_pr` attachment
- If it is an issue, create a `github_issue` attachment
- Include the attachment in the create-agent request
The UI can still render friendly labels and previews from the same data.
The app should stop treating PR/issue context as prompt-only metadata.
## Worktree Creation Behavior
During agent creation, if worktree creation is requested:
- Inspect normalized attachments
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
The important rule:
- attachment type identifies the review object
- server-side git logic decides how to check it out
This keeps the attachment contract simple while allowing fork-safe implementation details.
## Checkout Resolution
The attachment itself should not encode the full checkout strategy.
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
Examples of possible implementation strategies:
- `gh pr checkout`
- `git fetch origin refs/pull/<number>/head:<local-branch>`
- another GitHub-aware resolver if needed later
This is intentionally a server implementation detail, not part of the attachment schema.
## Provider Responsibilities
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
Examples:
- Claude: render attachment into text blocks
- Codex: inject as blocks or text
- OpenCode: send as file/resource-like input where appropriate
- ACP: convert to resource/text forms as supported
The session layer should not hardcode one translation strategy for all providers.
## Suggested Implementation Order
1. Add tolerant attachment schema and normalization in shared/server message handling.
2. Thread `attachments` through app -> daemon client -> session layer.
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
6. Optionally migrate image transport into the same attachment mechanism later.
## Files Likely Involved
- `packages/server/src/shared/messages.ts`
- `packages/server/src/client/daemon-client.ts`
- `packages/server/src/server/session.ts`
- `packages/server/src/server/worktree-session.ts`
- `packages/server/src/server/agent/agent-sdk-types.ts`
- `packages/server/src/server/agent/providers/claude-agent.ts`
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/acp-agent.ts`
- `packages/app/src/screens/new-workspace-screen.tsx`
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
- `packages/app/src/contexts/session-context.tsx`
## Notes
- The current image path can remain in place initially.
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.

View File

@@ -107,6 +107,7 @@ Required fields for custom providers:
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Automated setup is also available: `npx @z_ai/coding-helper`
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
@@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
- API keys must be created in the **Singapore region**
- The coding plan is for personal use only in interactive coding tools
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
---
@@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields:
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
@@ -460,6 +463,29 @@ Each entry in the `models` array:
| `description` | `string` | No | Short description |
| `isDefault` | `boolean` | No | Mark as the default thinking option |
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
Use `disallowedTools` to disable unsupported tools:
```json
{
"agents": {
"providers": {
"my-proxy": {
"extends": "claude",
"label": "My Proxy",
"env": {
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
},
"disallowedTools": ["WebSearch"]
}
}
}
}
```
### Valid `extends` values
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`

View File

@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
version: 1,
daemon: {
listen: "127.0.0.1:6767",
allowedHosts: true | string[],
hostnames: true | string[],
mcp: { enabled: boolean },
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }

View File

@@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the SQLite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
## Build sync gotchas
### Relay → Daemon

View File

@@ -95,6 +95,37 @@ Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
### Reach the composer
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
```yaml
appId: sh.paseo
---
- runFlow: flows/land-in-chat.yaml
# ...your scenario here, starting from a ready composer
```
See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
### Dropdowns that launch native presenters (iOS)
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.

View File

@@ -139,6 +139,43 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Changelog conciseness
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog attribution
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
```markdown
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
```
Rules:
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
## Changelog ordering
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.

166
docs/UNISTYLES.md Normal file
View File

@@ -0,0 +1,166 @@
# Unistyles Gotchas
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
[`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.
## 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.
Avoid this pattern when the style depends on the theme:
```tsx
<ScrollView contentContainerStyle={styles.container} />
const styles = StyleSheet.create((theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
},
}));
```
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
## Fix Patterns
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
```tsx
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.contentContainer}>
{children}
</ScrollView>
</View>
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flexGrow: 1,
padding: theme.spacing[4],
},
}));
```
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
```tsx
import { ScrollView } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
/>
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
```tsx
const { theme } = useUnistyles();
<ScrollView
contentContainerStyle={[
styles.contentContainer,
{ backgroundColor: theme.colors.surface0 },
]}
/>
```
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
```tsx
const { theme } = useUnistyles();
<Text style={[styles.title, { color: theme.colors.foreground }]}>
{title}
</Text>
```
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
## Adaptive Themes And Persisted Settings
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
1. Unistyles config starts with `adaptiveThemes: true`.
2. The device may report system light.
3. Settings load a persisted non-auto preference, such as dark.
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
```js
[
"react-native-unistyles/plugin",
{
root: "src",
debug: true,
},
],
```
Then rebuild the bundle and look for lines such as:
```text
src/components/welcome-screen.tsx: styles.container: [Theme]
```
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
For paint-layer bugs, use high-contrast probes:
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
2. Cold-restart the app, not just Fast Refresh.
3. Screenshot the simulator and sample pixels to see which color fills the area.
4. Remove the probes before committing.
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
## References
- [Unistyles v3 documentation](https://www.unistyl.es/)
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)

View File

@@ -9,6 +9,10 @@ let
cfg = config.services.paseo;
in
{
imports = [
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
];
options.services.paseo = {
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
@@ -58,12 +62,12 @@ in
description = "Whether to open the firewall for the Paseo daemon port.";
};
allowedHosts = lib.mkOption {
hostnames = lib.mkOption {
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
default = [ ];
example = [ ".example.com" "myhost.local" ];
description = ''
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
Localhost and IP addresses are always allowed by default.
Use a leading dot to match a domain and all its subdomains
@@ -141,10 +145,10 @@ in
"/run/wrappers/bin"
"/nix/var/nix/profiles/default/bin"
]);
} // lib.optionalAttrs (cfg.allowedHosts == true) {
PASEO_ALLOWED_HOSTS = "true";
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
} // lib.optionalAttrs (cfg.hostnames == true) {
PASEO_HOSTNAMES = "true";
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
} // cfg.environment;
serviceConfig = {

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-1mnfVYAAGP6x9ywFHEQ55q4i/C9uuZ2foU1ytacRMrE=";
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

219
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.56",
"version": "0.1.59",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.56",
"version": "0.1.59",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -21,7 +21,10 @@
],
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
"@modelcontextprotocol/sdk": "^1.27.1",
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -29,8 +32,8 @@
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
}
},
@@ -9547,9 +9550,9 @@
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.6.tgz",
"integrity": "sha512-nNlJ7mdXFoq/7LMG3eJIncqjgXkpDJak3xO8Lb4yQmFI3XVI1nupPRjlYRY0ham1gLE0F/AWvKFChsKUfF5lOQ==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
"integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==",
"license": "MIT",
"engines": {
"node": ">= 20.19.4"
@@ -9701,12 +9704,12 @@
}
},
"node_modules/@react-native/community-cli-plugin": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.6.tgz",
"integrity": "sha512-oTwIheF4TU7NkfoHxwSQAKtIDx4SQEs2xufgM3gguY7WkpnhGa/BYA/A+hdHXfqEKJFKlHcXQu4BrV/7Sv1fhw==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz",
"integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==",
"license": "MIT",
"dependencies": {
"@react-native/dev-middleware": "0.81.6",
"@react-native/dev-middleware": "0.81.5",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"metro": "^0.83.1",
@@ -9730,46 +9733,6 @@
}
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-frontend": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.6.tgz",
"integrity": "sha512-aGw28yzbtm25GQuuxNeVAT72tLuGoH0yh79uYOIZkvjI+5x1NjZyPrgiLZ2LlZi5dJdxfbz30p1zUcHvcAzEZw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/dev-middleware": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.6.tgz",
"integrity": "sha512-mK2M3gJ25LtgtqxS1ZXe1vHrz8APOA79Ot/MpbLeovFgLu6YJki0kbO5MRpJagTd+HbesVYSZb/BhAsGN7QAXA==",
"license": "MIT",
"dependencies": {
"@isaacs/ttlcache": "^1.4.1",
"@react-native/debugger-frontend": "0.81.6",
"chrome-launcher": "^0.15.2",
"chromium-edge-launcher": "^0.2.0",
"connect": "^3.6.5",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"open": "^7.0.3",
"serve-static": "^1.16.2",
"ws": "^6.2.3"
},
"engines": {
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/ws": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
"license": "MIT",
"dependencies": {
"async-limiter": "~1.0.0"
}
},
"node_modules/@react-native/debugger-frontend": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz",
@@ -9811,18 +9774,18 @@
}
},
"node_modules/@react-native/gradle-plugin": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.6.tgz",
"integrity": "sha512-atUItC5MZ6yaNaI0sbsoDwUdF+KMNZcMKBIrNhXlUyIj3x1AQ6Cf8CHHv6Qokn8ZFw+uU6GWmQSiOWYUbmi8Ag==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz",
"integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/js-polyfills": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.6.tgz",
"integrity": "sha512-P5MWH/9vM24XkJ1TasCq42DMLoCUjZVSppTn6VWv/cI65NDjuYEy7bUSaXbYxGTnqiKyPG5Y+ADymqlIkdSAcw==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
"integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==",
"license": "MIT",
"engines": {
"node": ">= 20.19.4"
@@ -9841,9 +9804,9 @@
"license": "MIT"
},
"node_modules/@react-native/virtualized-lists": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.6.tgz",
"integrity": "sha512-1RrZl3a7iCoAS2SGaRLjJPIn8bg/GLNXzqkIB2lufXcJsftu1umNLRIi17ZoDRejAWSd2pUfUtQBASo4R2mw4Q==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
"integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4",
@@ -9853,7 +9816,7 @@
"node": ">= 20.19.4"
},
"peerDependencies": {
"@types/react": "^19.1.4",
"@types/react": "^19.1.0",
"react": "*",
"react-native": "*"
},
@@ -29159,9 +29122,9 @@
}
},
"node_modules/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -29199,15 +29162,15 @@
}
},
"node_modules/react-dom": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.4"
"react": "^19.1.0"
}
},
"node_modules/react-error-boundary": {
@@ -29279,19 +29242,19 @@
}
},
"node_modules/react-native": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.6.tgz",
"integrity": "sha512-X/tI8GqfzVaa+zfbE4+lySNN5UzwBIAVRHVZPKymOny9Acc5GYYcjAcEVfG3AM4h920YALWoSl8favnDmQEWIg==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
"license": "MIT",
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.6",
"@react-native/codegen": "0.81.6",
"@react-native/community-cli-plugin": "0.81.6",
"@react-native/gradle-plugin": "0.81.6",
"@react-native/js-polyfills": "0.81.6",
"@react-native/normalize-colors": "0.81.6",
"@react-native/virtualized-lists": "0.81.6",
"@react-native/assets-registry": "0.81.5",
"@react-native/codegen": "0.81.5",
"@react-native/community-cli-plugin": "0.81.5",
"@react-native/gradle-plugin": "0.81.5",
"@react-native/js-polyfills": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"@react-native/virtualized-lists": "0.81.5",
"abort-controller": "^3.0.0",
"anser": "^1.4.9",
"ansi-regex": "^5.0.0",
@@ -29326,8 +29289,8 @@
"node": ">= 20.19.4"
},
"peerDependencies": {
"@types/react": "^19.1.4",
"react": "^19.1.4"
"@types/react": "^19.1.0",
"react": "^19.1.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -29670,31 +29633,16 @@
"node": ">=10"
}
},
"node_modules/react-native/node_modules/@react-native/codegen": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.6.tgz",
"integrity": "sha512-9KoYRep/KDnELLLmIYTtIIEOClVUJ88pxWObb/0sjkacA7uL4SgfbAg7rWLURAQJWI85L1YS67IhdEqNNk1I7w==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.25.3",
"glob": "^7.1.1",
"hermes-parser": "0.29.1",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"yargs": "^17.6.2"
},
"engines": {
"node": ">= 20.19.4"
},
"peerDependencies": {
"@babel/core": "*"
}
"node_modules/react-native/node_modules/@react-native/normalize-colors": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz",
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
"node_modules/react-native/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -34906,16 +34854,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.56",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/expo-two-way-audio": "0.1.59",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/server": "0.1.59",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -34963,8 +34911,8 @@
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
@@ -35056,11 +35004,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/relay": "0.1.59",
"@getpaseo/server": "0.1.59",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35101,11 +35049,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.56",
"version": "0.1.59",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/cli": "0.1.59",
"@getpaseo/server": "0.1.59",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35139,7 +35087,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.56",
"version": "0.1.59",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35340,7 +35288,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35366,7 +35314,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35382,14 +35330,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/relay": "0.1.56",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/relay": "0.1.59",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35417,6 +35365,7 @@
"strip-ansi": "^7.1.2",
"tiny-invariant": "^1.3.3",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.1"
@@ -35640,6 +35589,15 @@
"node": ">= 0.8"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -35794,6 +35752,21 @@
"node": ">= 0.6"
}
},
"packages/server/node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
"license": "ISC",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -35817,7 +35790,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.56",
"version": "0.1.59",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.56",
"version": "0.1.59",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -39,6 +39,7 @@
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
@@ -70,8 +71,8 @@
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
@@ -95,11 +96,14 @@
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.4",
"react-dom": "19.1.4"
"react": "19.1.0",
"react-dom": "19.1.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
"@modelcontextprotocol/sdk": "^1.27.1",
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5"
}
}

View File

@@ -8,6 +8,7 @@ import {
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
@@ -19,7 +20,7 @@ test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 120_000 });
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
@@ -64,10 +65,7 @@ test.describe("Archive tab reconciliation", () => {
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceTabHidden(passivePage, archived.id);
} finally {
await passivePage.close();
}
@@ -93,10 +91,7 @@ test.describe("Archive tab reconciliation", () => {
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceTabHidden(page, archived.id);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,

View File

@@ -200,16 +200,11 @@ export const gotoHome = async (page: Page) => {
};
export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
await expect(page).toHaveURL(/\/settings\/general$/);
};
export const setWorkingDirectory = async (page: Page, directory: string) => {

View File

@@ -27,10 +27,15 @@ type ArchiveTabDaemonClient = {
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
initialPrompt?: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
};
function getDaemonPort(): string {
@@ -110,16 +115,17 @@ export async function createIdleAgent(
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "default",
modeId: "bypassPermissions",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
);
const snapshot = await client.waitForAgentUpsert(
created.id,
(agent) => agent.status === "idle",
30_000,
);
if (snapshot.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
}
return {
id: created.id,
@@ -188,6 +194,16 @@ export async function openWorkspaceWithAgents(
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here (matching terminal-perf pattern).
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
@@ -254,13 +270,19 @@ export async function archiveAgentFromSessions(
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
// Long-press the row. Idle agents are archived immediately (no modal).
// Running/initializing agents show a confirmation modal instead.
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
// If a confirmation modal appears (running agent), click the archive button.
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
const modalVisible = await archiveButton.isVisible().catch(() => false);
if (modalVisible) {
await archiveButton.click();
}
await expectSessionRowArchived(page, input.title);
}

View File

@@ -0,0 +1,195 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
// ─── Navigation ────────────────────────────────────────────────────────────
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;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);
await page.goto(route);
await waitForTabBar(page);
}
// ─── Tab bar queries ───────────────────────────────────────────────────────
/** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
}
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
);
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const testId = await tabs.nth(i).getAttribute("data-testid");
if (testId) ids.push(testId);
}
return ids;
}
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
const ids = await getTabTestIds(page);
return ids.filter((id) => id.includes(kind)).length;
}
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
export async function getActiveTabTestId(page: Page): Promise<string | null> {
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
const activeTab = page
.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
)
.first();
if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid");
}
// Fallback: the tab with focused styling
return null;
}
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
}
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible();
}
/** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button to create a terminal tab. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
/** Wait for any tab in the bar to display the given title text. */
export async function waitForTabWithTitle(
page: Page,
title: string | RegExp,
timeout = 30_000,
): Promise<void> {
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
await expect(
page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher })
.first(),
).toBeVisible({ timeout });
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-agent-tab");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
* Returns elapsed milliseconds.
*/
export async function measureTileTransition(
page: Page,
clickAction: () => Promise<void>,
successLocator: ReturnType<Page["locator"]>,
timeout = 5_000,
): Promise<number> {
const start = Date.now();
await clickAction();
await expect(successLocator).toBeVisible({ timeout });
return Date.now() - start;
}
/**
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
* Returns all unique snapshots observed.
*/
export async function sampleTabsDuringTransition(
page: Page,
action: () => Promise<void>,
durationMs = 2_000,
intervalMs = 30,
): Promise<string[][]> {
const snapshots: string[][] = [];
const startSampling = async () => {
const start = Date.now();
while (Date.now() - start < durationMs) {
snapshots.push(await getTabTestIds(page));
await page.waitForTimeout(intervalMs);
}
};
const samplingPromise = startSampling();
await action();
await samplingPromise;
return snapshots;
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */
export async function createWorkspace(
prefix = "launcher-e2e-",
): ReturnType<typeof createTempGitRepo> {
return createTempGitRepo(prefix);
}

View File

@@ -84,21 +84,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]);
}
function parseWorkspaceIdFromSidebarRowTestId(
testId: string,
input: { serverId: string; previousWorkspaceId: string },
): string | null {
const prefix = `sidebar-workspace-row-${input.serverId}:`;
if (!testId.startsWith(prefix)) {
return null;
}
const workspaceId = testId.slice(prefix.length).trim();
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
return null;
}
return workspaceId;
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
@@ -179,42 +164,36 @@ export async function clickNewWorkspaceButton(
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
await expect(button).toBeVisible({ timeout: 30_000 });
await button.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
): Promise<{ workspaceId: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null;
const sidebarWorkspaceRows = page.locator(
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
);
const deadline = Date.now() + 30_000;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? ""),
);
workspaceId =
sidebarRowTestIds
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
.find((id) => id !== null) ?? null;
if (workspaceId) {
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
break;
}
await page.waitForTimeout(250);
}
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
);
throw new Error(
[
"Expected a newly created workspace to load.",
`Current URL: ${page.url()}`,
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
].join("\n"),
);
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const createdWorkspaceRow = page.getByTestId(

View File

@@ -8,6 +8,10 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: { id: string; name: string; projectRootPath: string } | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,
@@ -79,14 +83,14 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
const route = buildHostWorkspaceRoute(serverId, workspaceId);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
function buildWorkspaceUrl(cwd: string): string {
return buildHostWorkspaceRoute(getServerId(), cwd);
function buildWorkspaceUrl(workspaceId: string): string {
return buildHostWorkspaceRoute(getServerId(), workspaceId);
}
export async function getTerminalBufferText(page: Page): Promise<string> {
@@ -125,31 +129,35 @@ export async function waitForTerminalContent(
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
input: { workspaceId: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
await page.waitForURL(
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
{ timeout: 15_000 },
{ timeout: 30_000 },
);
// Wait for daemon connection (sidebar shows host label)
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 15_000 });
.waitFor({ state: "visible", timeout: 30_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab.
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
// so we give it enough time for the full workspace hydration + tab creation cycle.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');

View File

@@ -0,0 +1,32 @@
import { expect, type Page } from "@playwright/test";
import { clickNewChat, clickTerminal } from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
}
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
const terminal = terminalSurface(page);
await expect(terminal).toBeVisible({ timeout: 20_000 });
await terminal.click();
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
await terminal.pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
}
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
await clickTerminal(page);
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
}
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
await clickNewChat(page);
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
}

View File

@@ -0,0 +1,341 @@
import { realpathSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}>;
}>;
fetchAgents(): Promise<{
entries: Array<{
agent: { id: string; cwd: string; workspaceId?: string | null };
}>;
}>;
fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown | null;
} | null>;
listTerminals(cwd: string): Promise<{
cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
export type { WorkspaceSetupDaemonClient };
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `workspace-setup-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
await gotoAppShell(page);
await expect(
page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectNameFromPath(repoPath) })
.first(),
).toBeVisible({ timeout: 30_000 });
}
function createWorkspaceButton(page: Page, repoPath: string) {
return page.getByRole("button", {
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
});
}
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
await page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectNameFromPath(repoPath) })
.first()
.hover();
}
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
const button = createWorkspaceButton(page, repoPath);
await revealWorkspaceButton(page, repoPath);
await expect(button).toBeVisible({ timeout: 30_000 });
await expect(button).toBeEnabled({ timeout: 30_000 });
await button.click();
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
await expect
.poll(
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
{ timeout: 30_000 },
)
.not.toBeNull();
const workspaceId =
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
if (!workspaceId) {
throw new Error(`Expected a workspace route but found ${page.url()}`);
}
return workspaceId;
}
function workspaceSetupDialog(page: Page) {
return page.getByTestId("workspace-setup-dialog");
}
export async function createChatAgentFromWorkspaceSetup(
page: Page,
input: { message: string },
): Promise<void> {
const messageInput = page.getByRole("textbox", { name: "Message agent..." }).first();
await expect(messageInput).toBeVisible({ timeout: 15_000 });
await messageInput.fill(input.message);
await messageInput.press("Enter");
}
/**
* @deprecated The new workspace screen no longer has a standalone terminal button.
* Use the daemon API to create a workspace, then open a terminal from the launcher.
*/
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
await workspaceSetupDialog(page)
.getByRole("button", { name: /^Terminal Create the workspace/i })
.click();
}
export async function waitForWorkspaceSetupDialogToClose(
page: Page,
timeoutMs = 45_000,
): Promise<void> {
const dialog = workspaceSetupDialog(page);
try {
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
} catch (error) {
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
throw new Error(
dialogText
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
{ cause: error },
);
}
}
export async function expectSetupPanel(page: Page): Promise<void> {
// If the setup panel is already visible (auto-opened), we're done.
const panel = page.getByTestId("workspace-setup-panel");
if (await panel.isVisible().catch(() => false)) {
return;
}
// Otherwise open it manually via workspace header actions menu.
// Use the specific testID to avoid matching the sidebar kebab which shares
// the same "Workspace actions" accessibility label.
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
await expect(actionsButton).toBeVisible({ timeout: 10_000 });
await actionsButton.click();
const showSetup = page.getByTestId("workspace-header-show-setup");
await expect(showSetup).toBeVisible({ timeout: 5_000 });
await showSetup.click();
await expect(panel).toBeVisible({ timeout: 30_000 });
}
export async function expectSetupStatus(
page: Page,
status: "Running" | "Completed" | "Failed",
): Promise<void> {
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
timeout: 30_000,
});
}
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
timeout: 30_000,
});
}
export async function expectNoSetupMessage(page: Page): Promise<void> {
await expect(
page.getByText("No setup commands ran for this workspace.", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
}
export async function createWorkspaceThroughDaemon(
client: WorkspaceSetupDaemonClient,
input: { cwd: string; worktreeSlug: string },
): Promise<{ id: string; name: string }> {
const result = await client.createPaseoWorktree(input);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
}
return {
id: String(result.workspace.id),
name: result.workspace.name,
};
}
export async function findWorktreeWorkspaceForProject(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{
id: string;
name: string;
projectRootPath: string;
workspaceDirectory: string;
}> {
const payload = await client.fetchWorkspaces();
const normalizedRepoPath = realpathSync(repoPath);
const workspace =
payload.entries.find(
(entry) =>
entry.projectRootPath === normalizedRepoPath &&
entry.workspaceDirectory !== normalizedRepoPath,
) ?? null;
if (!workspace) {
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
}
return {
id: String(workspace.id),
name: workspace.name,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
};
}
export async function fetchWorkspaceById(
client: WorkspaceSetupDaemonClient,
workspaceId: string,
): Promise<{
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}> {
const payload = await client.fetchWorkspaces();
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return workspace;
}
export async function waitForWorkspaceSetupProgress(
client: WorkspaceSetupDaemonClient,
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
timeoutMs = 30_000,
): Promise<WorkspaceSetupProgressPayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
}, timeoutMs);
const unsubscribe = client.subscribeRawMessages((message) => {
if (message.type !== "workspace_setup_progress") {
return;
}
if (!predicate(message.payload)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(message.payload);
});
});
}

View File

@@ -22,6 +22,28 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
});
}
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page);
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
}
export async function expectOnlyWorkspaceAgentTabsVisible(
page: Page,
expectedAgentIds: string[],
): Promise<void> {
const expected = new Set(expectedAgentIds.map((id) => `workspace-tab-agent_${id}`));
const visible = await getVisibleWorkspaceAgentTabIds(page);
const unexpected = visible.filter((id) => !expected.has(id));
expect(unexpected).toEqual([]);
expect(visible.length).toBe(expected.size);
for (const expectedId of expectedAgentIds) {
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({
timeout: 30_000,
});
}
}
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
if (!(await toggle.isVisible().catch(() => false))) {

View File

@@ -6,6 +6,17 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
/**
* Wait for the sidebar to show at least one project row, indicating that the
* WebSocket connection is up and workspace hydration has completed.
*/
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
await page
.locator('[data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout });
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
@@ -35,6 +46,29 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
return page.locator(ids.join(",")).first();
}
export async function expectSidebarWorkspaceSelected(input: {
page: Page;
serverId: string;
workspaceId: string;
selected?: boolean;
}): Promise<void> {
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
const expected = input.selected === false ? "false" : "true";
const hasDataSelected = await row.getAttribute("data-selected");
if (hasDataSelected !== null) {
await expect(row).toHaveAttribute("data-selected", expected, {
timeout: 30_000,
});
return;
}
await expect(row).toHaveAttribute("aria-selected", expected, {
timeout: 30_000,
});
}
export async function switchWorkspaceViaSidebar(input: {
page: Page;
serverId: string;
@@ -50,6 +84,21 @@ export async function switchWorkspaceViaSidebar(input: {
});
}
/**
* Wait for a workspace's sidebar row to appear, confirming the workspace
* descriptor has been hydrated into the session store.
*/
export async function waitForWorkspaceInSidebar(
page: Page,
input: { serverId: string; workspaceId: string },
): Promise<void> {
const candidates = candidateWorkspaceIds(input.workspaceId);
const selector = candidates
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
.join(",");
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
}
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string },

View File

@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -10,10 +10,15 @@ type TempRepo = {
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
options?: {
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
@@ -22,7 +27,24 @@ export const createTempGitRepo = async (
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
if (options?.paseoConfig) {
await writeFile(
path.join(repoPath, "paseo.json"),
JSON.stringify(options.paseoConfig, null, 2),
);
}
for (const file of options?.files ?? []) {
const filePath = path.join(repoPath, file.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, file.content);
}
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
if (options?.paseoConfig) {
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
}
for (const file of options?.files ?? []) {
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
if (withRemote) {

View File

@@ -0,0 +1,258 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertTerminalTileVisible,
assertSingleNewTabButton,
clickNewTabButton,
pressNewTabShortcut,
clickNewChat,
clickTerminal,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: TerminalPerfDaemonClient;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
seedClient = await connectTerminalClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
workspaceId = String(result.workspace.id);
});
test.afterAll(async () => {
if (seedClient) await seedClient.close();
if (tempRepo) await tempRepo.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
// Tab Creation Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await pressNewTabShortcut(page);
// Should show the composer directly (no launcher panel)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
// Blur the composer so the second shortcut isn't swallowed by the focused input
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.());
await pressNewTabShortcut(page);
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
// Draft composer should appear (the agent message input)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
const tabsAfter = await getTabTestIds(page);
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
});
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspaceId);
await clickTerminal(page);
// Terminal surface should appear
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
const tabsAfter = await getTabTestIds(page);
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Title Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Terminal title propagation", () => {
// OSC title escape sequence propagation is inherently flaky — the terminal
// must process the sequence, emit a title change event, and the tab bar
// must re-render before the assertion deadline. Allow retries.
test.describe.configure({ retries: 2 });
let client: TerminalPerfDaemonClient;
test.beforeAll(async () => {
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) await client.close();
});
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "title-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminal
.first()
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "debounce-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminal
.first()
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
}
await terminal
.first()
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// No-Flash Transition Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
const counts = snapshots.map((s) => s.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspaceId);
const terminal = page.locator('[data-testid="terminal-surface"]');
const elapsed = await measureTileTransition(
page,
() => clickTerminal(page),
terminal.first(),
20_000,
);
// Terminal surface should appear within a reasonable budget.
// Note: terminal creation involves a server round-trip, so we allow more time
// than a pure in-memory transition, but it should still be well under 5 seconds.
expect(elapsed).toBeLessThan(5_000);
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
// is that no blank/flash frame appears (tested above).
expect(elapsed).toBeLessThan(3_000);
});
});

View File

@@ -1,5 +1,6 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
@@ -11,8 +12,11 @@ import {
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
waitForWorkspaceInSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
@@ -21,7 +25,7 @@ test.describe("New workspace flow", () => {
const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
@@ -56,11 +60,17 @@ test.describe("New workspace flow", () => {
localWorkspaceIds.add(firstWorkspace.workspaceId);
localWorkspaceIds.add(secondWorkspace.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
subtitle: firstWorkspace.projectDisplayName,
});
await switchWorkspaceViaSidebar({
@@ -68,9 +78,13 @@ test.describe("New workspace flow", () => {
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(secondRepo.path),
subtitle: secondWorkspace.projectDisplayName,
});
await switchWorkspaceViaSidebar({
@@ -80,7 +94,7 @@ test.describe("New workspace flow", () => {
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
subtitle: firstWorkspace.projectDisplayName,
});
} finally {
await secondRepo.cleanup();
@@ -88,6 +102,87 @@ test.describe("New workspace flow", () => {
}
});
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const repo = await createTempGitRepo("workspace-nav-same-project-");
try {
const rootWorkspace = await openProjectViaDaemon(client, repo.path);
const worktreeWorkspace = await createWorktreeViaDaemon(client, {
cwd: repo.path,
slug: `nav-${Date.now()}`,
});
localWorkspaceIds.add(rootWorkspace.workspaceId);
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
subtitle: rootWorkspace.projectDisplayName,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: rootWorkspace.workspaceId,
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: worktreeWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: worktreeWorkspace.workspaceName,
subtitle: worktreeWorkspace.projectDisplayName,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: worktreeWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: rootWorkspace.workspaceId,
selected: false,
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
subtitle: rootWorkspace.projectDisplayName,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: rootWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: worktreeWorkspace.workspaceId,
selected: false,
});
} finally {
await repo.cleanup();
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
page,
}) => {
@@ -102,11 +197,17 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: workspaceLabelFromPath(tempRepo.path),
subtitle: openedProject.projectDisplayName,
});
await clickNewWorkspaceButton(page, {
@@ -139,10 +240,13 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName,
});
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
has: page.getByText("New Agent", { exact: true }),
});
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
// Workspace setup may auto-open a setup tab that steals focus,
// hiding the agent panel (display:none removes it from the
// accessibility tree). Click the agent tab to ensure it's active.
await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });

View File

@@ -0,0 +1,181 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
function getSeededServerId(): 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 getSeededDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
return port;
}
async function openHostPage(page: Page, serverId: string) {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
const serverId = getSeededServerId();
const port = getSeededDaemonPort();
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
// Label renders as a title with a pencil edit affordance; the input is hidden until edit.
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
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-label-card").getByText(TEST_HOST_LABEL, { exact: true }),
).toBeVisible();
// Desktop detail pane shows a ScreenHeader with the host label.
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
const connectionsCard = page.getByTestId("host-page-connections-card");
await expect(connectionsCard).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).toBeVisible();
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
const serverId = getSeededServerId();
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await page.getByTestId("host-page-label-edit-button").click();
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
// Pair-device now lives behind a row that only the local host sees
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
// not appear. The daemon-lifecycle card is still local-host only.
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
page,
}) => {
const serverId = getSeededServerId();
await gotoAppShell(page);
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
const serverId = getSeededServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
// the desktop bootstrap flow so only the sidebar's status query runs.
await page.addInitScript((localServerId) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
);
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localServerId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
}, serverId);
await gotoAppShell(page);
await openSettings(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15000 });
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
await expect(hostEntries.first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
});
});

View File

@@ -0,0 +1,178 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
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 clickSidebarSection(page: Page, label: string) {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await sidebar.getByRole("button", { name: label, exact: true }).click();
}
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await clickSidebarSection(page, "Diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
await clickSidebarSection(page, "About");
await expect(page).toHaveURL(/\/settings\/about$/);
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
await clickSidebarSection(page, "General");
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
});
test("the + Add host button opens the add-host method modal", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const backRow = page.getByTestId("settings-back-to-workspace");
await expect(backRow).toBeVisible();
await backRow.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
});
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
async function openCompactSettingsRoot(page: Page) {
await gotoAppShell(page);
// Wait for bootstrap to settle on a host route so storeReady is true before
// we navigate into the protected settings stack. A direct page.goto("/settings")
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
// Drive navigation the same way a user would: open the mobile drawer and tap
// the Settings footer icon. This preserves the in-app router state instead of
// triggering a full reload through Stack.Protected.
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(sidebarSettingsButton).toBeVisible();
await sidebarSettingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page);
// Sidebar rows are present.
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
).toBeVisible();
await expect(
page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true }),
).toBeVisible();
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
).toBeVisible();
// Section detail content is NOT rendered at the root.
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.getByTestId("host-page-label-card")).toHaveCount(0);
// Root shows the menu header, not a back button.
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
});
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await openCompactSettingsRoot(page);
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
// Sidebar is no longer visible — we are on a detail screen.
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
// only visible instances.)
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("back from a section detail returns to the /settings list", async ({ page }) => {
await openCompactSettingsRoot(page);
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "About", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/about$/);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await openCompactSettingsRoot(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
});
test("back from a host detail returns to the /settings list", async ({ page }) => {
const serverId = getServerId();
await openCompactSettingsRoot(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
});
});

View File

@@ -0,0 +1,182 @@
import { execSync } from "node:child_process";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
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 getWorkspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function setGitHubRemote(repoPath: string): void {
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
cwd: repoPath,
stdio: "ignore",
});
}
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
},
};
}
async function openProjectViaDaemon(
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
cwd: string,
): Promise<{ id: string; name: 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,
};
}
async function openWorkspaceFromSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
return row;
}
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
const row = page
.getByRole("button", {
name: new RegExp(escapeRegex(projectName), "i"),
})
.first();
await expect(row).toBeVisible({ timeout: 30_000 });
return row;
}
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
return row;
}
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: "test-owner/test-repo" })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText(path.basename(repo.path));
} finally {
await client.close();
await repo.cleanup();
}
});
test("project shows workspace under it", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
await waitForSidebarWorkspace(page, workspace.id);
} finally {
await client.close();
await repo.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const project = await createTempDirectory("sidebar-directory-");
try {
await openProjectViaDaemon(client, project.path);
await gotoAppShell(page);
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
await expect(projectRow).toContainText(path.basename(project.path));
} finally {
await client.close();
await project.cleanup();
}
});
test("workspace header shows correct title and subtitle", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
await openWorkspaceFromSidebar(page, workspace.id);
await expectWorkspaceHeader(page, {
title: workspace.name,
subtitle: "test-owner/test-repo",
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("git project shows branch name in workspace row", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-branch-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
expect(workspace.name).toBe("main");
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -20,10 +20,15 @@ const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
// Seed the workspace in the daemon so the app can resolve the path
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
workspaceId = seedResult.workspace.id;
});
test.afterAll(async () => {
@@ -45,7 +50,7 @@ test.describe("Terminal wire performance", () => {
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await navigateToTerminal(page, { workspaceId, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
@@ -104,7 +109,7 @@ test.describe("Terminal wire performance", () => {
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await navigateToTerminal(page, { workspaceId, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state

View File

@@ -0,0 +1,129 @@
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-main-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
// Use sidebar navigation to avoid Expo Router hydration issues
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-worktree-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `workspace-cwd-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
// Use sidebar navigation to avoid Expo Router hydration issues
// with direct URL navigation to the 2nd+ workspace.
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,195 @@
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
} from "./helpers/workspace-lifecycle";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace lifecycle", () => {
// The first test after a spec-file switch can intermittently fail because
// the shared daemon still holds stale sessions from the previous spec.
// One retry is enough for the daemon to stabilize.
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-chat-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-shell-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-chat-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-chat-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-shell-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-shell-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, worktreePath);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
});

View File

@@ -0,0 +1,164 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import {
archiveLocalWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
getVisibleWorkspaceAgentTabIds,
expectOnlyWorkspaceAgentTabsVisible,
waitForWorkspaceTabsVisible,
} from "./helpers/workspace-tabs";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "./helpers/workspace-ui";
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
try {
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
workspaceIds.add(firstWorkspace.workspaceId);
workspaceIds.add(secondWorkspace.workspaceId);
const firstAgent = await createIdleAgent(archiveClient, {
cwd: firstRepo.path,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(archiveClient, {
cwd: secondRepo.path,
title: `workspace-nav-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
selected: false,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: firstWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectWorkspaceTabHidden(page, secondAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${firstAgent.id}`,
]);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
selected: false,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
]);
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
]);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await secondRepo.cleanup();
await firstRepo.cleanup();
}
});
});

View File

@@ -0,0 +1,106 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
findWorktreeWorkspaceForProject,
openHomeWithProject,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace setup runtime authority", () => {
test.describe.configure({ retries: 1 });
test("worktree workspace is created in its own directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-chat-");
try {
await client.openProject(repo.path);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-chat-${Date.now()}`,
});
const workspaceId = String(workspace.id);
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
// Navigate to the workspace via sidebar
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
} finally {
await client.close();
await repo.cleanup();
}
});
test("first terminal opens in the created workspace directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-terminal-");
try {
await client.openProject(repo.path);
// Create workspace via daemon API since the new workspace screen
// no longer has a standalone terminal button
const worktreeSlug = `setup-terminal-${Date.now()}`;
const result = await client.createPaseoWorktree({
cwd: repo.path,
worktreeSlug,
});
if (!result.workspace || result.error) {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
// Navigate to the worktree workspace via sidebar click (direct URL
// navigation for freshly created worktree workspaces can race with
// Expo Router hydration, so we use the sidebar which is authoritative).
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
// Verify terminal is listed under the worktree directory, not the original repo
await expect
.poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
timeout: 30_000,
})
.toBe(true);
expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,345 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { waitForTerminalContent } from "./helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
test.describe("Workspace setup streaming", () => {
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-open-", {
paseoConfig: {
worktree: {
setup: [
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-open-${Date.now()}`,
});
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupPanel(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-ui-flow-", {
paseoConfig: {
worktree: {
setup: [
"sh -c 'echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'",
],
},
},
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
// Wait for setup completion via daemon (setup snapshots are per-session,
// so the browser session won't receive progress events).
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-ui-flow-${Date.now()}`,
});
await completed;
// Navigate to workspace and verify it's usable
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
timeout: 30_000,
});
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-success",
});
const initialPayload = await initialRunning;
const runningPayload = await runningWithOutput;
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
await repo.cleanup();
}
});
test("streams a failed setup snapshot when setup fails", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-failure-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-failure",
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
} finally {
await client.close();
await repo.cleanup();
}
});
test("emits a completed empty snapshot when no setup commands exist", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
payload.detail.commands.length === 0 &&
payload.detail.log === "",
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-none",
});
const completedPayload = await completed;
expect(completedPayload.error).toBeNull();
expect(completedPayload.detail.commands).toEqual([]);
expect(completedPayload.detail.log).toBe("");
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches script terminals after setup completes", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
scripts: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
// Wait for setup completion via daemon (setup snapshots are per-session)
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-svc-${Date.now()}`,
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
// Wait for the script terminal tab to appear in the tabs bar.
// The tab title shows the command, not the script name.
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the script terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
const terminalSurface = page.getByTestId("terminal-surface").first();
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
// Wait for terminal to fully attach (loading overlay gone)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
// Verify the terminal output contains "listening on" via xterm buffer API.
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches workspace scripts after setup completes", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
scripts: {
editor: {
command: "npm run dev",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const result = await client.createPaseoWorktree({
cwd: repo.path,
worktreeSlug: "workspace-setup-scripts",
});
if (!result.workspace) {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
await completed;
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
})
.toMatchObject({
id: expect.any(String),
name: "editor",
});
} finally {
await client.close();
await repo.cleanup();
}
});
});

View File

@@ -0,0 +1,40 @@
# NOTE: Maestro requires a config section for every flow file, even when used
# via `runFlow`. Keep this aligned with the dev build application id.
appId: sh.paseo
---
- launchApp:
clearState: true
- runFlow: launch.yaml
- extendedWaitUntil:
visible:
id: "welcome-screen"
timeout: 30000
- tapOn:
id: "welcome-direct-connection"
- extendedWaitUntil:
visible:
id: "add-host-modal"
timeout: 10000
- tapOn:
id: "direct-host-input"
- eraseText
- inputText: "127.0.0.1:6767"
- assertVisible:
id: "direct-host-input"
text: "127.0.0.1:6767"
- tapOn:
id: "direct-host-submit"
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 60000
- assertVisible:
id: "message-input-attach-button"

View File

@@ -0,0 +1,50 @@
appId: sh.paseo
---
- runFlow: flows/land-in-chat.yaml
- tapOn:
id: "message-input-attach-button"
- extendedWaitUntil:
visible:
id: "message-input-attachment-menu"
timeout: 10000
- tapOn:
id: "message-input-attachment-menu-item-image"
- runFlow:
when:
visible: "Allow Full Access"
commands:
- tapOn: "Allow Full Access"
- runFlow:
when:
visible: "Allow Access to All Photos"
commands:
- tapOn: "Allow Access to All Photos"
- extendedWaitUntil:
visible: "Cancel"
timeout: 30000
- tapOn: "Cancel"
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 10000
# If the dropdown Modal left an invisible backdrop mounted behind the picker,
# this tap is swallowed and the attachment menu never reopens.
- tapOn:
id: "message-input-attach-button"
- extendedWaitUntil:
visible:
id: "message-input-attachment-menu"
timeout: 10000
- tapOn:
id: "message-input-attachment-menu-backdrop"

View File

@@ -8,7 +8,15 @@ appId: sh.paseo
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn: "Pair"
# Prove the input received the URL intact. If this fails, iOS keyboard input
# dropped characters and the controlled input path needs a source fix.
- assertVisible:
id: "pair-link-input"
text: ${PAIRING_OFFER_URL}
- tapOn:
id: "pair-link-submit"
- extendedWaitUntil:
visible: "New agent"

View File

@@ -8,7 +8,7 @@ appId: sh.paseo
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn:
point: "75%,36%"
id: "pair-link-submit"
- extendedWaitUntil:
visible: "New agent"

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.56",
"version": "0.1.59",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.56",
"@getpaseo/highlight": "0.1.56",
"@getpaseo/server": "0.1.56",
"@getpaseo/expo-two-way-audio": "0.1.59",
"@getpaseo/highlight": "0.1.59",
"@getpaseo/server": "0.1.59",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -81,8 +81,8 @@
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",

View File

@@ -51,6 +51,17 @@
[contenteditable='true'] {
-webkit-app-region: no-drag !important;
}
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
*:focus {
outline: none;
}
*:focus-visible {
outline: 2px solid #20744A;
outline-offset: 2px;
}
</style>
</head>
<body>

View File

@@ -62,6 +62,7 @@ import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -79,6 +80,7 @@ import {
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
@@ -435,7 +437,6 @@ function AppContainer({
enabled: chromeEnabled,
isMobile: isCompactLayout,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
@@ -460,6 +461,7 @@ function AppContainer({
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
</View>
);
@@ -788,7 +790,6 @@ function FaviconStatusSync() {
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
return (
<Stack
screenOptions={{
@@ -801,7 +802,8 @@ function RootStack() {
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen
@@ -818,6 +820,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" />
</Stack>
);
}

View File

@@ -4,6 +4,7 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -31,6 +32,21 @@ function HostAgentReadyRouteContent() {
}
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
});
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const resolvedWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return resolveWorkspaceIdByExecutionDirectory({
workspaces: state.sessions[serverId]?.workspaces?.values(),
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
});
});
useEffect(() => {
if (redirectedRef.current) {
@@ -42,18 +58,17 @@ function HostAgentReadyRouteContent() {
return;
}
const normalizedCwd = agentCwd?.trim();
if (normalizedCwd) {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -62,14 +77,14 @@ function HostAgentReadyRouteContent() {
if (!serverId || !agentId) {
return;
}
if (agentCwd?.trim()) {
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -87,12 +102,19 @@ function HostAgentReadyRouteContent() {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: sessionWorkspaces?.values(),
workspaceDirectory: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
return;
}
redirectedRef.current = true;
if (cwd) {
if (workspaceId) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
workspaceId,
target: { kind: "agent", agentId },
}) as any,
);
@@ -111,7 +133,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
return null;
}

View File

@@ -1,86 +1,9 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
return (
<HostRouteBootstrapBoundary>
<HostIndexRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostIndexRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
useEffect(() => {
if (preferencesLoading) {
return;
}
if (!serverId) {
return;
}
const rootRoute = buildHostRootRoute(serverId);
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
return;
}
const timer = setTimeout(() => {
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
return;
}
const visibleAgents = sessionAgents
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any,
);
return;
}
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
return;
}
router.replace(buildHostOpenProjectRoute(serverId));
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
return null;
if (!serverId) return null;
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
}

View File

@@ -0,0 +1,21 @@
import { useLocalSearchParams } from "expo-router";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() {
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const displayName = typeof params.name === "string" ? params.name : undefined;
if (!sourceDirectory) {
return null;
}
return (
<NewWorkspaceScreen
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
/>
);
}

View File

@@ -1,10 +1,15 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { buildSettingsHostRoute, buildSettingsRoute } from "@/utils/host-routes";
export default function LegacyHostSettingsRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const href = serverId.length > 0 ? buildSettingsHostRoute(serverId) : buildSettingsRoute();
export default function HostSettingsRoute() {
return (
<HostRouteBootstrapBoundary>
<SettingsScreen />
<Redirect href={href} />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -1,9 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useNavigation } from "@react-navigation/native";
import {
useGlobalSearchParams,
useLocalSearchParams,
useRouter,
useRootNavigationState,
} from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
@@ -32,9 +39,35 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
if (openIntent.kind === "setup") {
return { kind: "setup", workspaceId: openIntent.workspaceId };
}
return { kind: "draft", draftId: openIntent.draftId };
}
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("open")) {
return;
}
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (...args: any[]) => void };
router: { replace: (...args: any[]) => void };
serverId: string;
workspaceId: string;
}) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
input.navigation.setParams({ open: undefined });
stripOpenSearchParamFromBrowserUrl();
}
export default function HostWorkspaceLayout() {
return (
<HostRouteBootstrapBoundary>
@@ -44,6 +77,8 @@ export default function HostWorkspaceLayout() {
}
function HostWorkspaceLayoutContent() {
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
@@ -71,6 +106,13 @@ function HostWorkspaceLayoutContent() {
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation,
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
@@ -88,16 +130,15 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href);
if (url.searchParams.has("open")) {
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
}
clearConsumedOpenIntent({
navigation,
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
if (openValue && !intentConsumed) {
return null;

View File

@@ -4,6 +4,7 @@ import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome";
@@ -32,6 +33,8 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
);
}
const isDesktop = shouldUseDesktopDaemon();
export default function Index() {
const router = useRouter();
const pathname = usePathname();
@@ -52,5 +55,5 @@ export default function Index() {
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}

View File

@@ -5,35 +5,19 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[4],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
headerButtonText: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
body: {
flex: 1,
paddingHorizontal: theme.spacing[6],
@@ -140,63 +124,32 @@ export default function PairScanScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
sourceServerId?: string;
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const returnToSource = useCallback(
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
if (source === "editHost" && targetServerId) {
const settingsServerId = sourceServerId ?? targetServerId;
router.replace({
pathname: buildHostSettingsRoute(settingsServerId),
params: { editHost: targetServerId },
} as any);
return;
}
// settings (default): return to previous screen
try {
router.back();
} catch {
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId));
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source, sourceServerId, targetServerId],
[router, source],
);
const closeToSource = useCallback(() => {
if (source === "editHost" && targetServerId) {
const settingsServerId = sourceServerId ?? targetServerId;
router.replace({
pathname: buildHostSettingsRoute(settingsServerId),
params: { editHost: targetServerId },
} as any);
return;
}
try {
router.back();
} catch {
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId));
return;
}
router.replace("/" as any);
}
}, [router, source, sourceServerId, targetServerId]);
}, [router]);
useEffect(() => {
if (isWeb) return;
@@ -220,15 +173,6 @@ export default function PairScanScreen() {
const offerPayload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(offerPayload);
if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null;
Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
return;
}
const { client, hostname } = await connectToDaemon(
{
id: "probe",
@@ -242,7 +186,7 @@ export default function PairScanScreen() {
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
returnToSource(profile.serverId);
navigateToPairedHost(profile.serverId);
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host";
@@ -251,18 +195,13 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
);
if (isWeb) {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<BackHeader title="Scan QR" onBack={() => router.back()} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
@@ -282,12 +221,7 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<BackHeader title="Scan QR" onBack={closeToSource} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
{!granted ? (

View File

@@ -1,27 +0,0 @@
import { useEffect, useMemo } from "react";
import { useRouter } from "expo-router";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useHosts } from "@/runtime/host-runtime";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const daemons = useHosts();
const targetServerId = useMemo(() => {
return daemons[0]?.serverId ?? null;
}, [daemons]);
useEffect(() => {
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId));
}, [router, targetServerId]);
if (!targetServerId) {
return <DraftAgentScreen />;
}
return null;
}

View File

@@ -0,0 +1,11 @@
import { useLocalSearchParams } from "expo-router";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
return <SettingsScreen view={{ kind: "section", section }} />;
}

View File

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

View File

@@ -0,0 +1,14 @@
import { Redirect } from "expo-router";
import { useIsCompactFormFactor } from "@/constants/layout";
import SettingsScreen from "@/screens/settings-screen";
import { buildSettingsSectionRoute } from "@/utils/host-routes";
export default function SettingsIndexRoute() {
const isCompactLayout = useIsCompactFormFactor();
if (!isCompactLayout) {
return <Redirect href={buildSettingsSectionRoute("general")} />;
}
return <SettingsScreen view={{ kind: "root" }} />;
}

View File

@@ -1,3 +1,5 @@
import type { GitHubSearchItem } from "@server/shared/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
export interface AttachmentMetadata {
@@ -15,6 +17,11 @@ export interface AttachmentMetadata {
createdAt: number;
}
export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource =
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }

View File

@@ -14,8 +14,39 @@ import {
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
function handleEscKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
const top = escStack[escStack.length - 1];
if (!top) return;
event.stopPropagation();
event.preventDefault();
top();
}
function pushEscHandler(handler: EscHandler): () => void {
escStack.push(handler);
if (!escListenerAttached && typeof window !== "undefined") {
window.addEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = true;
}
return () => {
const index = escStack.lastIndexOf(handler);
if (index !== -1) escStack.splice(index, 1);
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
window.removeEventListener("keydown", handleEscKeyDown, true);
escListenerAttached = false;
}
};
}
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
...StyleSheet.absoluteFillObject,
@@ -36,22 +67,34 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.surface2,
overflow: "hidden",
},
header: {
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
headerTitleGroup: {
flex: 1,
gap: theme.spacing[2],
minWidth: 0,
},
title: {
color: theme.colors.foreground,
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -75,14 +118,27 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[3],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
alignItems: "flex-start",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
bottomSheetContent: {
padding: theme.spacing[6],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
flex: 1,
padding: theme.spacing[6],
gap: theme.spacing[4],
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
padding: theme.spacing[6],
gap: theme.spacing[4],
},
}));
function SheetBackground({ style }: BottomSheetBackgroundProps) {
@@ -103,22 +159,35 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
export interface AdaptiveModalSheetProps {
title: string;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
visible: boolean;
onClose: () => void;
children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
scrollable?: boolean;
}
export function AdaptiveModalSheet({
title,
subtitle,
visible,
onClose,
children,
headerActions,
snapPoints,
stackBehavior,
testID,
desktopMaxWidth,
onFilesDropped,
scrollable = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
@@ -157,6 +226,11 @@ export function AdaptiveModalSheet({
[],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
}, [visible, isMobile, onClose]);
if (isMobile) {
return (
<BottomSheetModal
@@ -172,38 +246,50 @@ export function AdaptiveModalSheet({
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
</BottomSheetModal>
);
}
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
const cardInner = (
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
@@ -212,6 +298,25 @@ export function AdaptiveModalSheet({
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
</>
);
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
) : (
cardInner
)}
</View>
</View>
);

View File

@@ -70,7 +70,9 @@ export function AddHostMethodModal({
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
accessibilityLabel="Direct connection"
testID="add-host-method-direct"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
@@ -80,7 +82,12 @@ export function AddHostMethodModal({
</Pressable>
{isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<Pressable
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
accessibilityLabel="Scan QR code"
>
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
@@ -92,7 +99,9 @@ export function AddHostMethodModal({
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
accessibilityLabel="Paste pairing link"
testID="add-host-method-pair-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>

View File

@@ -132,7 +132,6 @@ function buildConnectionFailureCopy(
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
@@ -142,42 +141,41 @@ export interface AddHostModalProps {
}) => void;
}
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [endpointRaw, setEndpointRaw] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
endpointRawRef.current = "";
hostInputRef.current?.clear();
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRaw.trim();
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
@@ -206,15 +204,6 @@ export function AddHostModal({
endpoint,
});
await client.close().catch(() => undefined);
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Wrong daemon", message);
}
return;
}
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
@@ -240,16 +229,7 @@ export function AddHostModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
return (
<AdaptiveModalSheet
@@ -264,8 +244,12 @@ export function AddHostModal({
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
value={endpointRaw}
onChangeText={setEndpointRaw}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={(next) => {
endpointRawRef.current = next;
}}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -289,6 +273,7 @@ export function AddHostModal({
onPress={() => void handleSave()}
disabled={isSaving}
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}
</Button>

View File

@@ -24,19 +24,20 @@ describe("submitAgentInput", () => {
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " hello world ",
attachments: [],
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setAttachments,
setSendError,
setIsProcessing,
});
@@ -44,10 +45,10 @@ describe("submitAgentInput", () => {
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "hello world",
imageAttachments: undefined,
attachments: [],
});
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setAttachments).toHaveBeenCalledWith([]);
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
@@ -63,21 +64,21 @@ describe("submitAgentInput", () => {
const submitMessage = vi.fn();
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " queued message ",
imageAttachments: [{ id: "img-1" }],
attachments: [{ id: "img-1" }],
isAgentRunning: true,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setAttachments,
setSendError,
setIsProcessing,
}),
@@ -85,11 +86,11 @@ describe("submitAgentInput", () => {
expect(queueMessage).toHaveBeenCalledWith({
message: "queued message",
imageAttachments: [{ id: "img-1" }],
attachments: [{ id: "img-1" }],
});
expect(submitMessage).not.toHaveBeenCalled();
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setAttachments).toHaveBeenCalledWith([]);
expect(setSendError).not.toHaveBeenCalled();
expect(setIsProcessing).not.toHaveBeenCalled();
expect(clearDraft).not.toHaveBeenCalled();
@@ -103,23 +104,23 @@ describe("submitAgentInput", () => {
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const onSubmitError = vi.fn();
const imageAttachments = [{ id: "img-1" }];
const attachments = [{ id: "img-1" }];
await expect(
submitAgentInput({
message: " hello world ",
imageAttachments,
attachments,
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setAttachments,
setSendError,
setIsProcessing,
onSubmitError,
@@ -129,12 +130,46 @@ describe("submitAgentInput", () => {
expect(onSubmitError).toHaveBeenCalledWith(submitError);
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
expect(setAttachments).toHaveBeenNthCalledWith(2, attachments);
expect(setSendError).toHaveBeenNthCalledWith(1, null);
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(clearDraft).not.toHaveBeenCalled();
});
it("submits when empty submit is explicitly allowed", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " ",
attachments: [],
allowEmptySubmit: true,
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
}),
).resolves.toBe("submitted");
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "",
attachments: [],
});
expect(clearDraft).toHaveBeenCalledWith("sent");
});
});

View File

@@ -1,28 +1,35 @@
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TImage> {
export interface AgentInputSubmitActionInput<TAttachment> {
message: string;
imageAttachments?: TImage[];
attachments: TAttachment[];
hasExternalContent?: boolean;
allowEmptySubmit?: boolean;
forceSend?: boolean;
isAgentRunning: boolean;
canSubmit: boolean;
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
queueMessage: (input: { message: string; attachments: TAttachment[] }) => void;
submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise<void>;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
setUserInput: (text: string) => void;
setSelectedImages: (images: TImage[]) => void;
setAttachments: (attachments: TAttachment[]) => void;
setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void;
}
export async function submitAgentInput<TImage>(
input: AgentInputSubmitActionInput<TImage>,
export async function submitAgentInput<TAttachment>(
input: AgentInputSubmitActionInput<TAttachment>,
): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim();
const imageAttachments = input.imageAttachments;
const attachments = input.attachments;
if (!trimmedMessage && !imageAttachments?.length) {
if (
!trimmedMessage &&
attachments.length === 0 &&
!input.hasExternalContent &&
!input.allowEmptySubmit
) {
return "noop";
}
@@ -31,27 +38,27 @@ export async function submitAgentInput<TImage>(
}
if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, imageAttachments });
input.queueMessage({ message: trimmedMessage, attachments });
input.setUserInput("");
input.setSelectedImages([]);
input.setAttachments([]);
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput("");
input.setSelectedImages([]);
input.setAttachments([]);
input.setSendError(null);
input.setIsProcessing(true);
try {
await input.submitMessage({ message: trimmedMessage, imageAttachments });
await input.submitMessage({ message: trimmedMessage, attachments });
input.clearDraft("sent");
input.setIsProcessing(false);
return "submitted";
} catch (error) {
input.onSubmitError?.(error);
input.setUserInput(trimmedMessage);
input.setSelectedImages(imageAttachments ?? []);
input.setAttachments(attachments);
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false);
return "failed";

View File

@@ -17,6 +17,9 @@ import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
@@ -131,6 +134,7 @@ function SessionRow({
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
return (
<Pressable
@@ -146,6 +150,9 @@ function SessionRow({
>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
@@ -232,12 +239,21 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return;
}
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
@@ -247,7 +263,18 @@ export function AgentList({
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent);
const isRunning = agent.status === "running" || agent.status === "initializing";
if (isRunning) {
setActionAgent(agent);
return;
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) {
setActionAgent(agent);
return;
}
void client.archiveAgent(agent.id);
}, []);
const handleCloseActionSheet = useCallback(() => {
@@ -351,7 +378,9 @@ export function AgentList({
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
{isActionDaemonUnavailable
? "Host offline"
: "This agent is still running. Archiving it will stop the agent."}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -435,6 +464,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerIconWrap: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
rowMetaRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -879,7 +879,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(serverId);
} = useProvidersSnapshot(serverId, agent?.cwd);
const snapshotModels = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {

View File

@@ -8,8 +8,8 @@ import {
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export type StreamRenderSegments = {
historyVirtualized: StreamItem[];

View File

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

View File

@@ -66,6 +66,7 @@ import {
} from "./use-bottom-anchor-controller";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
@@ -136,10 +137,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
@@ -179,12 +183,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route);
if (workspaceId) {
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route);
}
return;
}

View File

@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
export interface Artifact {
id: string;
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
const webScrollbarStyle = useWebScrollbarStyle();
if (!artifact) {
return null;
}
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
{/* Content */}
<ScrollView
style={styles.contentScroll}
style={[styles.contentScroll, webScrollbarStyle]}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>

View File

@@ -0,0 +1,223 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import { AttachmentLightbox } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
colors: {
surface1: "#111",
surface2: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#444",
},
};
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "img-1",
fileName: "img-1.png",
byteSize: 42,
createdAt: 1,
};
return {
theme,
imageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
};
});
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("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
X: createIcon("X"),
};
});
vi.mock("expo-image", () => ({
Image: (props: Record<string, unknown>) => {
const source = props.source as { uri?: string } | string | undefined;
const uri = typeof source === "string" ? source : source?.uri;
return React.createElement("div", {
"data-testid": props.testID,
"data-source": uri,
"data-style": JSON.stringify(props.style ?? null),
role: "img",
});
},
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
const Modal = ({
visible = true,
children,
}: {
visible?: boolean;
children?: React.ReactNode;
}) =>
visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null;
return { ...actual, Modal };
});
vi.mock("@/attachments/use-attachment-preview-url", () => ({
useAttachmentPreviewUrl: (metadata: AttachmentMetadata | null) =>
useAttachmentPreviewUrlMock(metadata),
}));
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("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function render(element: React.ReactElement) {
act(() => {
root?.render(element);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
describe("AttachmentLightbox", () => {
it("renders nothing when metadata is null", () => {
render(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
});
it("renders the image when metadata is provided", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
const image = queryByTestId("attachment-lightbox-image");
expect(image).not.toBeNull();
expect(image?.getAttribute("data-source")).toBe("blob:preview");
});
it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
const image = queryByTestId("attachment-lightbox-image");
const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as {
position?: string;
top?: number;
left?: number;
right?: number;
bottom?: number;
} | null;
expect(style?.position).toBe("absolute");
expect(style?.top).toBe(0);
expect(style?.left).toBe(0);
expect(style?.right).toBe(0);
expect(style?.bottom).toBe(0);
});
it("calls onClose when the backdrop is pressed", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
const backdrop = queryByTestId("attachment-lightbox-backdrop");
expect(backdrop).not.toBeNull();
click(backdrop!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when the close button is pressed", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
const closeButton = document.querySelector(
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
);
expect(closeButton).not.toBeNull();
click(closeButton!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
act(() => {
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Image as ExpoImage } from "expo-image";
import { X } from "lucide-react-native";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isWeb } from "@/constants/platform";
interface AttachmentLightboxProps {
metadata: AttachmentMetadata | null;
onClose: () => void;
}
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const url = useAttachmentPreviewUrl(metadata);
const [errored, setErrored] = useState(false);
useEffect(() => {
setErrored(false);
}, [metadata?.id]);
useEffect(() => {
if (!isWeb || !metadata) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
window.addEventListener("keydown", handleKeydown);
return () => {
window.removeEventListener("keydown", handleKeydown);
};
}, [metadata, onClose]);
if (!metadata) {
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<View style={styles.root}>
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel="Dismiss image"
onPress={onClose}
style={styles.backdrop}
/>
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn't load image</Text>
) : (
<Pressable onPress={() => {}} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={{ uri: url }}
contentFit="contain"
onError={() => setErrored(true)}
style={imageFillStyle}
/>
</Pressable>
)}
</View>
<Pressable
testID="attachment-lightbox-close"
accessibilityRole="button"
accessibilityLabel="Close image"
hitSlop={8}
onPress={onClose}
style={[
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
]}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
</Modal>
);
}
const imageFillStyle = {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
} as const;
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.9)",
},
contentLayer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: "box-none",
},
imageArea: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
pointerEvents: "box-none",
},
imagePressable: {
flex: 1,
width: "100%",
alignSelf: "center",
maxWidth: 960,
maxHeight: 640,
},
errorText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
closeButton: {
position: "absolute",
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
}));

View File

@@ -0,0 +1,87 @@
import { type ReactNode, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
interface AttachmentPillProps {
onOpen: () => void;
onRemove: () => void;
openAccessibilityLabel: string;
removeAccessibilityLabel: string;
testID?: string;
children: ReactNode;
}
export function AttachmentPill({
onOpen,
onRemove,
openAccessibilityLabel,
removeAccessibilityLabel,
testID,
children,
}: AttachmentPillProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isBodyHovered, setIsBodyHovered] = useState(false);
const [isCloseHovered, setIsCloseHovered] = useState(false);
const alwaysShow = isNative || isCompact;
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
return (
<View style={styles.wrapper}>
<Pressable
testID={testID}
onPress={onOpen}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
>
{children}
</Pressable>
<Pressable
onPress={onRemove}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={removeAccessibilityLabel}
style={[styles.closeButton, !showRemove && styles.closeButtonHidden]}
>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
wrapper: {
position: "relative",
},
body: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
closeButtonHidden: {
opacity: 0,
pointerEvents: "none",
},
}));

View File

@@ -1,5 +1,5 @@
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Pressable, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ScreenTitle } from "@/components/headers/screen-title";
interface BranchSwitcherProps {
currentBranchName: string | null;
@@ -43,12 +44,15 @@ export function BranchSwitcher({
queryClient,
});
const titleContent = (
<>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
</>
);
if (!currentBranchName) {
return (
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
);
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
}
return (
@@ -63,10 +67,7 @@ export function BranchSwitcher({
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
</Pressable>
<Combobox
@@ -100,15 +101,6 @@ export function BranchSwitcher({
}
const styles = StyleSheet.create((theme) => ({
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
flexShrink: 1,
},
branchSwitcherTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -117,7 +109,10 @@ const styles = StyleSheet.create((theme) => ({
xs: -theme.spacing[2],
md: 0,
},
paddingVertical: theme.spacing[1],
paddingVertical: {
xs: 0,
md: theme.spacing[1],
},
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,

View File

@@ -67,8 +67,8 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
});
it("builds an explicit trigger label for the selected provider and model", () => {
it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
});
});

View File

@@ -533,10 +533,22 @@ export function CombinedModelSelector({
return { kind: "provider", providerId, providerLabel: label };
}, [allProviderModels, providerDefinitions]);
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 };
}
return { kind: "all" };
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
setView(singleProviderView ?? { kind: "all" });
setView(computeInitialView());
if (open) {
onOpen?.();
} else {
@@ -544,26 +556,24 @@ export function CombinedModelSelector({
onClose?.();
}
},
[onOpen, onClose, singleProviderView],
[onOpen, onClose, computeInitialView],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView(singleProviderView ?? { kind: "all" });
setSearchQuery("");
},
[onSelect, singleProviderView],
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedProviderLabel = useMemo(
() => resolveProviderLabel(providerDefinitions, selectedProvider),
[providerDefinitions, selectedProvider],
);
const selectedModelLabel = useMemo(() => {
if (!selectedModel) {
return isLoading ? "Loading..." : "Select model";
}
const models = allProviderModels.get(selectedProvider);
if (!models) {
return isLoading ? "Loading..." : "Select model";
@@ -586,8 +596,8 @@ export function CombinedModelSelector({
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]);
return buildSelectedTriggerLabel(selectedModelLabel);
}, [selectedModelLabel]);
useEffect(() => {
if (platformIsWeb) {
@@ -788,11 +798,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
level2Header: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
level2Header: {},
backButton: {
flexDirection: "row",
alignItems: "center",

View File

@@ -13,7 +13,7 @@ export function resolveProviderLabel(
);
}
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}

View File

@@ -0,0 +1,30 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
export type ImageAttachment = AttachmentMetadata;
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
images: ImageAttachment[];
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const reviewAttachments: AgentAttachment[] = [];
for (const attachment of attachments) {
if (attachment.kind === "image") {
images.push(attachment.metadata);
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
}
}
return {
images,
attachments: reviewAttachments,
};
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
import { resolveStatusControlMode } from "./composer.status-controls";
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {

View File

@@ -0,0 +1,887 @@
import React, { useState } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
const {
theme,
imageMetadata,
issueItem,
prItem,
mockClient,
pickImagesMock,
persistAttachmentFromBlobMock,
deleteAttachmentsMock,
encodeImagesMock,
openExternalUrlMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
} = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400", medium: "500" },
shadow: { md: {} },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#888",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
border: "#555",
borderAccent: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff453a",
palette: {
green: { 500: "#30d158", 600: "#24b14c", 800: "#126024" },
red: { 500: "#ff453a", 600: "#d92d20" },
zinc: { 600: "#52525b" },
},
},
};
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "img-1",
fileName: "img-1.png",
byteSize: 42,
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
url: "https://github.com/acme/paseo/issues/101",
state: "open",
body: "Issue body",
labels: ["composer"],
baseRefName: null,
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
state: "open",
body: "PR body",
labels: ["composer"],
baseRefName: "main",
headRefName: "composer-attachments",
};
const mockClient = {
isConnected: true,
searchGitHub: vi.fn(async () => ({ items: [issueItem, prItem] })),
sendAgentMessage: vi.fn(async () => {}),
cancelAgent: vi.fn(async () => {}),
};
const setQueuedMessagesMock = vi.fn();
const mockSessionState: {
sessions: Record<
string,
{
agents: Map<string, { status: string; lastUsage: null }>;
queuedMessages: Map<string, unknown[]>;
agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>;
}
>;
setQueuedMessages: ReturnType<typeof vi.fn>;
setAgentStreamTail?: ReturnType<typeof vi.fn>;
setAgentStreamHead?: ReturnType<typeof vi.fn>;
} = {
sessions: {
server: {
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
queuedMessages: new Map(),
agentStreamHead: new Map(),
agentStreamTail: new Map(),
},
},
setQueuedMessages: setQueuedMessagesMock,
};
const setAgentStreamTailMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
session.agentStreamTail = updater(session.agentStreamTail);
},
);
const setAgentStreamHeadMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
session.agentStreamHead = updater(session.agentStreamHead);
},
);
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
return {
theme,
imageMetadata,
issueItem,
prItem,
mockClient,
pickImagesMock: vi.fn(),
persistAttachmentFromBlobMock: vi.fn(async () => imageMetadata),
deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}),
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
};
});
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("@/constants/layout", () => ({
FOOTER_HEIGHT: 72,
MAX_CONTENT_WIDTH: 900,
useIsCompactFormFactor: () => false,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
Square: createIcon("Square"),
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
CircleDot: createIcon("CircleDot"),
GitPullRequest: createIcon("GitPullRequest"),
X: createIcon("X"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
Plus: createIcon("Plus"),
Paperclip: createIcon("Paperclip"),
Github: createIcon("Github"),
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Keyframe: class Keyframe {
duration() {
return this;
}
withCallback() {
return this;
}
},
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withTiming: (value: unknown) => value,
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
useHostRuntimeAgentDirectoryStatus: () => "ready",
}));
vi.mock("@/stores/session-store", () => {
const useSessionStore = (selector: (state: typeof mockSessionState) => unknown) =>
selector(mockSessionState);
useSessionStore.getState = () => mockSessionState;
return { useSessionStore };
});
vi.mock("@/hooks/use-image-attachment-picker", () => ({
useImageAttachmentPicker: () => ({ pickImages: pickImagesMock }),
}));
vi.mock("@/attachments/service", () => ({
persistAttachmentFromBlob: persistAttachmentFromBlobMock,
persistAttachmentFromFileUri: vi.fn(async () => imageMetadata),
deleteAttachments: deleteAttachmentsMock,
}));
vi.mock("@/attachments/use-attachment-preview-url", () => ({
useAttachmentPreviewUrl: () => "blob:preview",
}));
vi.mock("expo-image", () => ({
Image: (props: Record<string, unknown>) => {
const source = props.source as { uri?: string } | string | undefined;
const uri = typeof source === "string" ? source : source?.uri;
return React.createElement("div", {
"data-testid": props.testID,
"data-source": uri,
role: "img",
});
},
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
const Modal = ({
visible = true,
children,
}: {
visible?: boolean;
children?: React.ReactNode;
}) =>
visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null;
return { ...actual, Modal };
});
vi.mock("@/utils/encode-images", () => ({
encodeImages: encodeImagesMock,
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: openExternalUrlMock,
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
}));
vi.mock("@/hooks/use-agent-autocomplete", () => ({
useAgentAutocomplete: () => ({
isVisible: false,
options: [],
selectedIndex: -1,
isLoading: false,
errorMessage: null,
loadingText: "",
emptyText: "",
onSelectOption: vi.fn(),
onKeyPress: () => false,
}),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
useKeyboardActionHandler: () => {},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: {} }),
}));
vi.mock("@/contexts/voice-context", () => ({
useVoiceOptional: () => null,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/scroll-jank-investigation", () => ({
markScrollInvestigationRender: vi.fn(),
markScrollInvestigationEvent: vi.fn(),
}));
vi.mock("@/components/agent-status-bar", () => ({
AgentStatusBar: () => null,
DraftAgentStatusBar: () => null,
}));
vi.mock("@/components/context-window-meter", () => ({
ContextWindowMeter: () => null,
}));
vi.mock("@/components/composer.status-controls", () => ({
resolveStatusControlMode: () => "agent",
}));
vi.mock("@/components/ui/autocomplete", () => ({
Autocomplete: () => null,
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebElementScrollbar: () => null,
}));
vi.mock("@/hooks/use-web-scrollbar-style", () => ({
useWebScrollbarStyle: () => undefined,
}));
vi.mock("@/hooks/use-dictation", () => ({
useDictation: () => ({
isRecording: false,
isProcessing: false,
partialTranscript: "",
volume: 0,
duration: 0,
error: null,
status: "idle",
startDictation: vi.fn(),
cancelDictation: vi.fn(),
confirmDictation: vi.fn(),
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
}));
vi.mock("@/utils/server-info-capabilities", () => ({
resolveVoiceUnavailableMessage: () => null,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({
asChild,
children,
onPress,
accessibilityLabel,
disabled,
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
onPress?: () => void;
accessibilityLabel?: string;
disabled?: boolean;
}) =>
asChild ? (
<>{children}</>
) : (
<button type="button" aria-label={accessibilityLabel} disabled={disabled} onClick={onPress}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/dropdown-menu", () => {
const DropdownContext = React.createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = React.useState(false);
return (
<DropdownContext.Provider value={{ open, setOpen }}>{children}</DropdownContext.Provider>
);
},
DropdownMenuTrigger: ({
children,
testID,
accessibilityLabel,
disabled,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
disabled?: boolean;
}) => {
const menu = React.useContext(DropdownContext);
return (
<button
type="button"
data-testid={testID}
aria-label={accessibilityLabel}
disabled={disabled}
onClick={() => menu?.setOpen(true)}
>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: menu?.open ?? false })
: children}
</button>
);
},
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => {
const menu = React.useContext(DropdownContext);
return menu?.open ? <div data-testid={testID}>{children}</div> : null;
},
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
};
});
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
open,
options,
renderOption,
anchorRef,
}: {
open?: boolean;
options: Array<{ id: string; label: string; description?: string }>;
renderOption?: (input: {
option: { id: string; label: string; description?: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
anchorRef: React.RefObject<unknown>;
}) =>
open ? (
<div
data-testid="composer-github-combobox"
data-anchor={anchorRef.current ? "attached" : "missing"}
>
{options.map((option) =>
renderOption ? (
renderOption({ option, selected: false, active: false, onPress: vi.fn() })
) : (
<button type="button" key={option.id}>
{option.label}
</button>
),
)}
</div>
) : null,
ComboboxItem: ({
label,
selected,
onPress,
testID,
}: {
label: string;
selected?: boolean;
onPress: () => void;
testID?: string;
}) => (
<button
type="button"
data-testid={testID}
data-selected={selected ? "true" : "false"}
onClick={onPress}
>
{label}
</button>
),
}));
vi.mock("./dictation-controls", () => ({
DictationOverlay: () => null,
}));
vi.mock("./realtime-voice-overlay", () => ({
RealtimeVoiceOverlay: () => null,
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
let latestAttachments: ComposerAttachment[] = [];
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("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
vi.stubGlobal("Blob", dom.window.Blob);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
latestAttachments = [];
mockClient.searchGitHub.mockClear();
mockClient.sendAgentMessage.mockClear();
mockClient.cancelAgent.mockClear();
pickImagesMock.mockReset();
persistAttachmentFromBlobMock.mockClear();
deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear();
openExternalUrlMock.mockClear();
setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear();
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container = null;
queryClient = null;
vi.unstubAllGlobals();
});
function imageAttachment(id: string): AttachmentMetadata {
return {
...imageMetadata,
id,
storageKey: id,
fileName: `${id}.png`,
};
}
function ComposerHarness({
initialText = "",
initialAttachments = [],
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
latestAttachments = attachments;
return (
<QueryClientProvider client={queryClient!}>
<Composer
agentId="agent"
serverId="server"
isPaneFocused
value={text}
onChangeText={setText}
attachments={attachments}
onChangeAttachments={(updater) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
return next;
});
}}
cwd="/repo"
clearDraft={vi.fn()}
/>
</QueryClientProvider>
);
}
function renderComposer(
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
) {
act(() => {
root?.render(<ComposerHarness {...input} />);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
async function flushAsyncWork() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function findByTestId(testID: string): Promise<HTMLElement> {
for (let attempt = 0; attempt < 10; attempt += 1) {
await flushAsyncWork();
const element = queryByTestId(testID);
if (element) {
return element;
}
}
throw new Error(`Missing element with testID ${testID}`);
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
}
describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
expect(queryAllAttachmentMenuItems()).toHaveLength(2);
expect(queryByTestId("message-input-attachment-menu-item-image")?.textContent).toBe(
"Add image",
);
expect(queryByTestId("message-input-attachment-menu-item-github")?.textContent).toBe(
"Add issue or PR",
);
});
it("adds a picked image as a unified composer attachment and renders a pill", async () => {
pickImagesMock.mockResolvedValue([
{
source: { kind: "blob", blob: new Blob(["image"]) },
mimeType: "image/png",
fileName: "img-1.png",
},
]);
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-image")!);
await flushAsyncWork();
expect(persistAttachmentFromBlobMock).toHaveBeenCalledWith({
blob: expect.any(Blob),
mimeType: "image/png",
fileName: "img-1.png",
});
expect(latestAttachments).toEqual([{ kind: "image", metadata: imageMetadata }]);
expect(queryByTestId("composer-image-attachment-pill")).not.toBeNull();
});
it("opens the GitHub combobox from the Plus menu anchored at the Plus button", async () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const combobox = await findByTestId("composer-github-combobox");
expect(combobox.dataset.anchor).toBe("attached");
});
it("closes the GitHub combobox after selecting an item", async () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const issueOption = await findByTestId("composer-github-option-issue:101");
click(issueOption);
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(queryByTestId("composer-github-combobox")).toBeNull();
});
it("toggles GitHub search items into and out of unified composer attachments", async () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const issueOption = await findByTestId("composer-github-option-issue:101");
click(issueOption);
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(queryByTestId("composer-github-combobox")).toBeNull();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const selectedIssueOption = await findByTestId("composer-github-option-issue:101");
click(selectedIssueOption);
expect(latestAttachments).toEqual([]);
expect(queryByTestId("composer-github-combobox")).toBeNull();
});
it("submits mixed composer attachments as the expected wire images and attachments", async () => {
const image = imageAttachment("img-2");
renderComposer({
initialText: "send attachments",
initialAttachments: [
{ kind: "image", metadata: image },
{ kind: "github_pr", item: prItem },
],
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"send attachments",
expect.objectContaining({
images: [image],
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
],
}),
);
});
it("submits empty wire arrays when there are no composer attachments", async () => {
renderComposer({ initialText: "plain message" });
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"plain message",
expect.objectContaining({
images: [],
attachments: [],
}),
);
});
it("removes the image attachment when its pill X button is pressed", () => {
const image = imageAttachment("img-remove");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
const removeButton = document.querySelector('[aria-label="Remove image attachment"]');
expect(removeButton).not.toBeNull();
click(removeButton!);
expect(latestAttachments).toEqual([]);
expect(deleteAttachmentsMock).toHaveBeenCalledWith([image]);
});
it("removes a GitHub attachment when its pill X button is pressed", () => {
renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] });
const removeButton = document.querySelector(`[aria-label="Remove issue #${issueItem.number}"]`);
expect(removeButton).not.toBeNull();
click(removeButton!);
expect(latestAttachments).toEqual([]);
});
it("opens the GitHub issue URL when the pill body is pressed", () => {
renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] });
click(queryByTestId("composer-github-attachment-pill")!);
expect(openExternalUrlMock).toHaveBeenCalledWith(issueItem.url);
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
});
it("opens the GitHub PR URL when the pill body is pressed", () => {
renderComposer({ initialAttachments: [{ kind: "github_pr", item: prItem }] });
click(queryByTestId("composer-github-attachment-pill")!);
expect(openExternalUrlMock).toHaveBeenCalledWith(prItem.url);
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("opens the image lightbox when the image pill body is pressed", () => {
const image = imageAttachment("img-body");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
expect(openExternalUrlMock).not.toHaveBeenCalled();
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("closes the image lightbox when its close button is pressed", () => {
const image = imageAttachment("img-close");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
const closeButton = document.querySelector(
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
);
expect(closeButton).not.toBeNull();
click(closeButton!);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
});
it("splits mixed composer attachments only at the submit wire boundary", () => {
const image = imageAttachment("img-3");
expect(
splitComposerAttachmentsForSubmit([
{ kind: "image", metadata: image },
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
]),
).toEqual({
images: [image],
attachments: [
{
type: "github_issue",
mimeType: "application/github-issue",
number: 101,
title: "Fix composer attachments",
url: "https://github.com/acme/paseo/issues/101",
body: "Issue body",
},
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
],
});
});
});

View File

@@ -1,11 +1,21 @@
import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import {
ArrowUp,
Square,
Pencil,
AudioLines,
CircleDot,
GitPullRequest,
Github,
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -21,6 +31,7 @@ import {
type MessagePayload,
type ImageAttachment,
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import { Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
@@ -43,7 +54,7 @@ import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -51,28 +62,53 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
type QueuedMessage = {
id: string;
text: string;
images?: ImageAttachment[];
attachments: ComposerAttachment[];
};
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
type AttachmentListUpdater =
| ComposerAttachment[]
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
interface AgentInputAreaProps {
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
interface ComposerProps {
agentId: string;
serverId: string;
isInputActive: boolean;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
/** When true, the composer can submit even with no text or attachments. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
images: ImageAttachment[];
onChangeImages: (updater: ImageListUpdater) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
@@ -89,23 +125,29 @@ interface AgentInputAreaProps {
onAttentionPromptSend?: () => void;
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
export function Composer({
agentId,
serverId,
isInputActive,
isPaneFocused,
onSubmitMessage,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
isSubmitLoading = false,
blurOnSubmit = false,
value,
onChangeText,
images,
onChangeImages,
attachments,
onChangeAttachments,
cwd,
clearDraft,
autoFocus = false,
onAddImages,
@@ -116,8 +158,9 @@ export function AgentInputArea({
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
inputWrapperStyle,
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
@@ -163,13 +206,17 @@ export function AgentInputArea({
: MOBILE_MESSAGE_PLACEHOLDER;
const userInput = value;
const setUserInput = onChangeText;
const selectedImages = images;
const setSelectedImages = onChangeImages;
const selectedAttachments = attachments;
const setSelectedAttachments = onChangeAttachments;
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
const [githubSearchQuery, setGithubSearchQuery] = useState("");
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
@@ -201,16 +248,19 @@ export function AgentInputArea({
const { pickImages } = useImageAttachmentPicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback(
(images: ImageAttachment[]) => {
setSelectedImages((prev) => [...prev, ...images]);
setSelectedAttachments((prev) => [
...prev,
...images.map((metadata) => ({ kind: "image" as const, metadata })),
]);
},
[setSelectedImages],
[setSelectedAttachments],
);
useEffect(() => {
@@ -233,18 +283,18 @@ export function AgentInputArea({
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => {
async (text: string, attachments: ComposerAttachment[]) => {
onMessageSent?.();
if (onSubmitMessageRef.current) {
await onSubmitMessageRef.current({ text, images });
await onSubmitMessageRef.current({ text, attachments, cwd });
return;
}
if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected");
}
await sendAgentMessageRef.current(agentIdRef.current, text, images);
await sendAgentMessageRef.current(agentIdRef.current, text, attachments);
},
[onMessageSent],
[cwd, onMessageSent],
);
useEffect(() => {
@@ -255,19 +305,20 @@ export function AgentInputArea({
sendAgentMessageRef.current = async (
agentId: string,
text: string,
images?: ImageAttachment[],
attachments: ComposerAttachment[],
) => {
if (!client) {
throw new Error("Host is not connected");
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const clientMessageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: clientMessageId,
text,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
};
// Append to head if streaming (keeps the user message with the current
@@ -291,10 +342,11 @@ export function AgentInputArea({
return updated;
});
}
const imagesData = await encodeImages(images);
const imagesData = await encodeImages(wirePayload.images);
await client.sendAgentMessage(agentId, text, {
messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
onAttentionPromptSend?.();
};
@@ -318,14 +370,14 @@ export function AgentInputArea({
[agentId, serverId, setQueuedMessages],
);
function queueMessage(message: string, imageAttachments?: ImageAttachment[]) {
function queueMessage(message: string, attachments: ComposerAttachment[]) {
const trimmedMessage = message.trim();
if (!trimmedMessage && !imageAttachments?.length) return;
if (!trimmedMessage && attachments.length === 0) return;
const newItem = {
id: generateMessageId(),
text: trimmedMessage,
images: imageAttachments,
attachments,
};
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
@@ -335,32 +387,34 @@ export function AgentInputArea({
});
setUserInput("");
setSelectedImages([]);
setSelectedAttachments([]);
}
async function sendMessageWithContent(
message: string,
imageAttachments?: ImageAttachment[],
attachments: ComposerAttachment[],
forceSend?: boolean,
) {
await submitAgentInput({
message,
imageAttachments,
attachments,
hasExternalContent,
allowEmptySubmit,
forceSend,
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message, imageAttachments }) => {
queueMessage(message, imageAttachments);
queueMessage: ({ message, attachments }) => {
queueMessage(message, attachments);
},
submitMessage: async ({ message, imageAttachments }) => {
await submitMessage(message, imageAttachments);
submitMessage: async ({ message, attachments }) => {
await submitMessage(message, attachments);
},
clearDraft,
setUserInput,
setSelectedImages: (images) => {
setSelectedImages(images);
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
},
setSendError,
setIsProcessing,
@@ -374,10 +428,10 @@ export function AgentInputArea({
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, payload.images, payload.forceSend);
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
}
async function handlePickImage() {
const handlePickImage = useCallback(async () => {
const result = await pickImages();
if (!result?.length) {
return;
@@ -400,19 +454,27 @@ export function AgentInputArea({
});
}),
);
setSelectedImages((prev) => [...prev, ...newImages]);
}
addImages(newImages);
}, [addImages, pickImages]);
function handleRemoveImage(index: number) {
setSelectedImages((prev) => {
function handleRemoveAttachment(index: number) {
setSelectedAttachments((prev) => {
const removed = prev[index];
if (removed) {
void deleteAttachments([removed]);
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
}
return prev.filter((_, i) => i !== index);
});
}
function handleOpenAttachment(attachment: ComposerAttachment) {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
void openExternalUrl(attachment.item.url);
}
useEffect(() => {
if (!isAgentRunning || !isConnected) {
setIsCancellingAgent(false);
@@ -421,7 +483,7 @@ export function AgentInputArea({
const handleKeyboardAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!isInputActive) {
if (!isPaneFocused) {
return false;
}
@@ -461,7 +523,7 @@ export function AgentInputArea({
return false;
}
},
[isInputActive],
[isPaneFocused],
);
useKeyboardActionHandler({
@@ -475,9 +537,9 @@ export function AgentInputArea({
"message-input.voice-toggle",
"message-input.voice-mute-toggle",
],
enabled: isInputActive,
enabled: isPaneFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isInputActive,
isActive: () => isPaneFocused,
handle: handleKeyboardAction,
});
@@ -510,7 +572,7 @@ export function AgentInputArea({
return;
}
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[AgentInputArea] Failed to start voice mode", error);
console.error("[Composer] Failed to start voice mode", error);
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null;
if (message && message.trim().length > 0) {
@@ -525,7 +587,7 @@ export function AgentInputArea({
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedImages(item.images ?? []);
setSelectedAttachments(item.attachments);
}
async function handleSendQueuedNow(id: string) {
@@ -537,7 +599,7 @@ export function AgentInputArea({
// Reuse the regular send path; server-side send atomically interrupts any active run.
try {
await submitMessage(item.text, item.images);
await submitMessage(item.text, item.attachments);
} catch (error) {
updateQueue((current) => [item, ...current]);
setSendError(error instanceof Error ? error.message : "Failed to send message");
@@ -545,10 +607,10 @@ export function AgentInputArea({
}
const handleQueue = useCallback((payload: MessagePayload) => {
queueMessage(payload.text, payload.images);
queueMessage(payload.text, payload.attachments);
}, []);
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete and stop action.
const handleCommandKeyPress = useCallback(
@@ -607,40 +669,49 @@ export function AgentInputArea({
</Tooltip>
) : null;
const rightContent = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent && hasAgent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
]}
>
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={buttonIconSize} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
);
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const rightContent =
showVoiceModeButton || cancelButton ? (
<View style={styles.rightControls}>
{showVoiceModeButton ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
) : null;
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
@@ -659,6 +730,78 @@ export function AgentInputArea({
</View>
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useQuery({
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.searchGitHub({
cwd,
query: githubSearchQueryTrimmed,
limit: 20,
});
},
enabled: isConnected && !!client && cwd.trim().length > 0,
staleTime: 30_000,
});
const githubSearchItems = githubSearchResultsQuery.data?.items ?? [];
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
})),
[githubSearchItems, githubSearchQueryTrimmed],
);
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(
() => [
{
id: "image",
label: "Add image",
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
void handlePickImage();
},
},
{
id: "github",
label: "Add issue or PR",
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
);
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
setSelectedAttachments((current) => {
const matches = (attachment: ComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
const nextAttachment: ComposerAttachment =
item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
return [...current, nextAttachment];
});
setIsGithubPickerOpen(false);
setGithubSearchQuery("");
},
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
@@ -670,6 +813,7 @@ export function AgentInputArea({
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */}
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
@@ -718,25 +862,84 @@ export function AgentInputArea({
</View>
)}
{selectedAttachments.length > 0 ? (
<View style={styles.attachmentPreviewContainer} testID="composer-attachment-pills">
{selectedAttachments.map((attachment, index) => {
if (attachment.kind === "image") {
return (
<AttachmentPill
key={`${attachment.metadata.id}-${index}`}
testID="composer-image-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
);
}
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
return (
<AttachmentPill
key={`${item.kind}:${item.number}`}
testID="composer-github-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
#{item.number} {item.title}
</Text>
</View>
</AttachmentPill>
);
})}
</View>
) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading}
images={selectedImages}
onPickImages={handlePickImage}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => {
attachButtonRef.current = node;
}}
onAddImages={addImages}
onRemoveImage={handleRemoveImage}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isInputActive={isInputActive}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
@@ -757,6 +960,61 @@ export function AgentInputArea({
}
}}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={() => {}}
keepOpenOnSelect
searchable
searchPlaceholder="Search issues and PRs..."
title="Attach issue or PR"
open={isGithubPickerOpen}
onOpenChange={(open) => {
setIsGithubPickerOpen(open);
if (!open) {
setGithubSearchQuery("");
}
}}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."}
renderOption={({ option, active }) => {
const item = githubSearchItems.find((candidate) => {
return `${candidate.kind}:${candidate.number}` === option.id;
});
if (!item) {
return <View key={option.id} />;
}
const selected = selectedAttachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return (
<ComboboxItem
key={option.id}
testID={`composer-github-option-${option.id}`}
label={option.label}
selected={selected}
active={active}
onPress={() => handleToggleGithubItem(item)}
leadingSlot={
item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)
}
/>
);
}}
/>
</View>
</View>
@@ -793,6 +1051,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
messageInputContainer: {
position: "relative",
width: "100%",
gap: theme.spacing[3],
},
autocompletePopover: {
position: "absolute",
@@ -809,11 +1068,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: 28,
@@ -835,6 +1095,41 @@ const styles = StyleSheet.create(((theme: Theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
attachmentPreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface2,
},
githubPillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
githubPillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
githubPillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -8,7 +8,7 @@ type ContextWindowMeterProps = {
usedTokens: number;
};
const SVG_SIZE = 20;
const SVG_SIZE = 16;
const CENTER = SVG_SIZE / 2;
const RADIUS = 7;
const STROKE_WIDTH = 2.25;

View File

@@ -1,4 +1,5 @@
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
interface DiffScrollProps {
children: React.ReactNode;
@@ -14,12 +15,14 @@ export function DiffScroll({
style,
contentContainerStyle,
}: DiffScrollProps) {
const webScrollbarStyle = useWebScrollbarStyle();
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
style={[style, webScrollbarStyle]}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
>

View File

@@ -0,0 +1,35 @@
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
}
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row}>
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
<Text style={styles.deletions}>-{deletions.toLocaleString()}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: 4,
flexShrink: 0,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
}));

View File

@@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { getCodeInsets } from "./code-insets";
import { isWeb } from "@/constants/platform";
@@ -23,6 +24,7 @@ export function DiffViewer({
fillAvailableHeight = false,
}: DiffViewerProps) {
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const webScrollbarStyle = useWebScrollbarStyle();
if (!diffLines.length) {
return (
@@ -38,6 +40,7 @@ export function DiffViewer({
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.verticalContent}
nestedScrollEnabled
@@ -47,6 +50,7 @@ export function DiffViewer({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
>

View File

@@ -167,23 +167,32 @@ export function FileExplorerPane({
if (hasInitializedRef.current) {
return;
}
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
// If the root listing fails (e.g. client not yet connected), we reset the
// flag so the next time requestDirectoryListing is recreated (when client
// becomes available) this effect retries automatically.
hasInitializedRef.current = true;
void requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}).then((succeeded) => {
if (!succeeded) {
hasInitializedRef.current = false;
return;
}
const persistedPaths =
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (persistedPaths) {
for (const path of persistedPaths) {
if (path !== ".") {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
});
}
}
}
}
});
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click)

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
describe("isRenderedMarkdownFile", () => {
it("detects .md files", () => {
expect(isRenderedMarkdownFile("README.md")).toBe(true);
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
});
it("detects .markdown files", () => {
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
});
it("does not treat .mdx files as rendered markdown", () => {
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
});
it("does not treat other text files as rendered markdown", () => {
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
});
});

View File

@@ -0,0 +1,4 @@
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import {
ActivityIndicator,
Image as RNImage,
@@ -12,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
highlightCode,
darkHighlightColors,
@@ -20,7 +22,9 @@ import {
type HighlightStyle,
} from "@getpaseo/highlight";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
<Text selectable style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
@@ -117,19 +121,23 @@ function FilePreviewBody({
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
if (!preview || preview.kind !== "text") {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
}, [preview?.kind, preview?.content, filePath]);
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
@@ -154,6 +162,28 @@ function FilePreviewBody({
}
if (preview.kind === "text") {
if (isMarkdownFile) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
@@ -188,6 +218,7 @@ function FilePreviewBody({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
@@ -264,6 +295,7 @@ export function FilePane({
return { file: payload.file ?? null, error: payload.error ?? null };
},
staleTime: 5_000,
refetchOnMount: true,
});
return (
@@ -328,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
previewCodeScrollContent: {
padding: theme.spacing[4],
},
previewMarkdownScrollContent: {
padding: theme.spacing[4],
},
previewImageScrollContent: {
flexGrow: 1,
padding: theme.spacing[4],

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
import { useRouter } from "expo-router";
import { DiffStat } from "@/components/diff-stat";
import {
View,
Text,
@@ -488,8 +489,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
)}
</View>
<View style={styles.fileHeaderRight}>
<Text style={styles.additions}>+{file.additions}</Text>
<Text style={styles.deletions}>-{file.deletions}</Text>
<DiffStat additions={file.additions} deletions={file.deletions} />
</View>
</Pressable>
</View>

View File

@@ -1,31 +1,35 @@
import type { ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
interface BackHeaderProps {
title?: string;
titleAccessory?: ReactNode;
rightContent?: ReactNode;
onBack?: () => void;
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
<ScreenHeader
left={
<>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</>
}
right={rightContent}
@@ -45,13 +49,4 @@ const styles = StyleSheet.create((theme) => ({
},
borderRadius: theme.borderRadius.lg,
},
title: {
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: {
xs: theme.fontWeight.semibold,
md: "400",
},
color: theme.colors.foreground,
},
}));

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { headerIconSlotStyle } from "./header-toggle-button";
/**
* Non-interactive icon slot sitting at the start of a screen header's left
* cluster. Shares the same padding + border-radius as `HeaderToggleButton` so
* decorative headers (settings sections, host detail) line up with the sidebar
* toggle across screens.
*/
export function HeaderIconBadge({ children }: { children: ReactNode }) {
return <View style={headerIconSlotStyle.slot}>{children}</View>;
}

View File

@@ -51,7 +51,7 @@ export function HeaderToggleButton({
onPress={(e) => {
onPress(e);
}}
style={[styles.button, style]}
style={[headerIconSlotStyle.slot, style]}
>
{typeof children === "function"
? (state: { pressed: boolean; hovered?: boolean }) =>
@@ -68,14 +68,17 @@ export function HeaderToggleButton({
);
}
const styles = StyleSheet.create((theme) => ({
button: {
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
slot: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
const styles = StyleSheet.create((theme) => ({
tooltipRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -83,11 +84,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
left={
<>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{title && <ScreenTitle>{title}</ScreenTitle>}
</>
}
right={rightContent}
@@ -101,15 +98,6 @@ const styles = StyleSheet.create((theme) => ({
left: {
gap: theme.spacing[2],
},
title: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,

View File

@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface ScreenTitleProps {
children: ReactNode;
numberOfLines?: number;
testID?: string;
style?: StyleProp<TextStyle>;
}
/**
* Canonical screen title for use inside `ScreenHeader`. One typography, one
* color, responsive weight. Leading icons are siblings (HeaderToggleButton,
* HeaderIconBadge) — never nested inside this component.
*/
export function ScreenTitle({ children, numberOfLines = 1, testID, style }: ScreenTitleProps) {
return (
<Text style={[styles.text, style]} numberOfLines={numberOfLines} testID={testID}>
{children}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
},
}));

View File

@@ -33,6 +33,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router";
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -46,14 +47,10 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
useIsCompactFormFactor,
} from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
buildSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes";
@@ -220,19 +217,13 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return;
}
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId, closeToAgent]);
router.push(buildSettingsRoute());
}, [closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId]);
router.push(buildSettingsRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
@@ -328,44 +319,6 @@ function HostSwitchOption({
);
}
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles();
const pathname = usePathname();
const isActive = pathname.includes("/sessions");
return (
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
isActive && styles.newAgentButtonActive,
]}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={onPress}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
);
}
function MobileSidebar({
theme,
activeServerId,
@@ -394,6 +347,8 @@ function MobileSidebar({
handleViewMoreNavigate,
}: MobileSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const {
translateX,
backdropOpacity,
@@ -544,11 +499,13 @@ function MobileSidebar({
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -676,6 +633,8 @@ function DesktopSidebar({
handleViewMore,
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -732,11 +691,13 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
{isInitialLoad ? (
@@ -881,46 +842,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarDragArea: {
position: "relative",
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newAgentButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
newAgentButtonTextHovered: {
color: theme.colors.foreground,
},
newAgentButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
newAgentButtonActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",

View File

@@ -0,0 +1,305 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem } from "./message-input";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
shadow: { md: {} },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#888",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
borderAccent: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff453a",
palette: {
green: { 500: "#30d158" },
},
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: false,
isNative: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
ArrowUp: createIcon("ArrowUp"),
Plus: createIcon("Plus"),
Square: createIcon("Square"),
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Keyframe: class Keyframe {
duration() {
return this;
}
withCallback() {
return this;
}
},
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withTiming: (value: unknown) => value,
}));
vi.mock("@/hooks/use-dictation", () => ({
useDictation: () => ({
isRecording: false,
isProcessing: false,
partialTranscript: "",
volume: 0,
duration: 0,
error: null,
status: "idle",
startDictation: vi.fn(),
cancelDictation: vi.fn(),
confirmDictation: vi.fn(),
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
selector({ sessions: {} }),
}));
vi.mock("@/contexts/voice-context", () => ({
useVoiceOptional: () => null,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/server-info-capabilities", () => ({
resolveVoiceUnavailableMessage: () => null,
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebElementScrollbar: () => null,
}));
vi.mock(
"@/hooks/use-web-scrollbar-style",
() => ({
useWebScrollbarStyle: () => undefined,
}),
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
{ virtual: true },
);
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({
asChild,
children,
...props
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
} & Record<string, any>) =>
asChild ? (
<>{children}</>
) : (
<button type="button" aria-label={props.accessibilityLabel}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({
children,
testID,
accessibilityLabel,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
}) => (
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: false })
: children}
</button>
),
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
}));
vi.mock("./dictation-controls", () => ({
DictationOverlay: () => null,
}));
vi.mock("./realtime-voice-overlay", () => ({
RealtimeVoiceOverlay: () => null,
}));
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("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderMessageInput(menuItems: AttachmentMenuItem[]) {
act(() => {
root?.render(
<MessageInput
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={menuItems}
client={{ isConnected: true } as never}
isAgentRunning
onQueue={vi.fn()}
/>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
return document.querySelectorAll(`[aria-label="${label}"]`);
}
describe("MessageInput attachments", () => {
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
const menuItems: AttachmentMenuItem[] = [
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
];
renderMessageInput(menuItems);
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
const attachButton = queryByTestId("message-input-attach-button");
expect(attachButton).not.toBeNull();
click(attachButton!);
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
expect(
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
).toHaveLength(2);
});
it("selecting Attach image invokes the supplied image attachment action", () => {
const attachImage = vi.fn();
renderMessageInput([
{ id: "image", label: "Attach image", onSelect: attachImage },
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
]);
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-image")!);
expect(attachImage).toHaveBeenCalledTimes(1);
});
it("does not render the old queue button", () => {
renderMessageInput([
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
]);
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
});
});

View File

@@ -8,7 +8,6 @@ import {
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
Image,
BackHandler,
} from "react-native";
import {
@@ -21,7 +20,7 @@ import {
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
@@ -36,13 +35,22 @@ import {
filesToImageAttachments,
} from "@/utils/image-attachments-from-files";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { ComposerAttachment } from "@/attachments/types";
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { formatShortcut } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
@@ -53,21 +61,37 @@ export type ImageAttachment = AttachmentMetadata;
export interface MessagePayload {
text: string;
images?: ImageAttachment[];
attachments: ComposerAttachment[];
cwd: string;
/** When true, bypasses queue and sends immediately even if agent is running */
forceSend?: boolean;
}
export interface AttachmentMenuItem {
id: string;
label: string;
onSelect: () => void;
disabled?: boolean;
icon?: React.ReactElement | null;
}
export interface MessageInputProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
/** When true, the submit button stays visible and can submit even with no content. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
isSubmitDisabled?: boolean;
isSubmitLoading?: boolean;
images?: ImageAttachment[];
onPickImages?: () => void;
attachments: ComposerAttachment[];
cwd: string;
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void;
onRemoveImage?: (index: number) => void;
client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean;
@@ -75,8 +99,8 @@ export interface MessageInputProps {
autoFocus?: boolean;
autoFocusKey?: string;
disabled?: boolean;
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
isInputActive?: boolean;
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
isPaneFocused?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
@@ -100,6 +124,8 @@ export interface MessageInputProps {
onSelectionChange?: (selection: { start: number; end: number }) => void;
onFocusChange?: (focused: boolean) => void;
onHeightChange?: (height: number) => void;
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
}
export interface MessageInputRef {
@@ -113,8 +139,10 @@ export interface MessageInputRef {
getNativeElement?: () => HTMLElement | null;
}
const MIN_INPUT_HEIGHT = 30;
const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46;
const MAX_INPUT_HEIGHT = 160;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -181,32 +209,28 @@ function getScrollableAncestorChain(element: HTMLElement | null): string[] {
return results;
}
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
{
value,
onChangeText,
onSubmit,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
onPickImages,
attachments,
cwd,
attachmentMenuItems,
onAttachButtonRef,
onAddImages,
onRemoveImage,
client,
isReadyForDictation,
placeholder = "Message...",
autoFocus = false,
autoFocusKey,
disabled = false,
isInputActive = true,
isPaneFocused = true,
leftContent,
beforeVoiceContent,
rightContent,
@@ -220,6 +244,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
onHeightChange,
inputWrapperStyle,
},
ref,
) {
@@ -232,8 +257,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const sendKeys = useShortcutKeys("message-input-send");
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const queueKeys = useShortcutKeys("message-input-queue");
const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
@@ -357,15 +383,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
sendAfterTranscriptRef.current = false;
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined;
// Respect send behavior setting: when "queue", dictation queues too.
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
onQueue({ text: nextValue, images: imageAttachments });
onQueue({ text: nextValue, attachments, cwd });
onChangeText("");
} else {
onSubmit({
text: nextValue,
images: imageAttachments,
attachments,
cwd,
forceSend: isAgentRunning || undefined,
});
}
@@ -379,7 +405,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
});
}
},
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -427,7 +453,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isInputActive },
autoStopWhenHidden: { isVisible: isPaneFocused },
enableDuration: true,
});
@@ -563,32 +589,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const handleSendMessage = useCallback(() => {
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return;
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
attachments,
cwd,
forceSend: isAgentRunning || undefined,
};
onSubmit(payload);
inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT);
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onSubmit, isAgentRunning, onHeightChange]);
}, [
allowEmptySubmit,
value,
attachments,
cwd,
onSubmit,
isAgentRunning,
onHeightChange,
hasExternalContent,
]);
const handleQueueMessage = useCallback(() => {
if (!onQueue) return;
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
if (!trimmed && attachments.length === 0) return;
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
attachments,
cwd,
};
onQueue(payload);
onChangeText("");
inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT);
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]);
}, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]);
// Default send action: respects the sendBehavior setting.
// When "interrupt" (default), primary action sends immediately (interrupts).
@@ -891,7 +928,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
@@ -923,20 +960,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
handleDefaultSendAction();
}
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const hasAttachments = attachments.length > 0;
const hasRealContent = value.trim().length > 0 || hasAttachments;
const hasSendableContent = hasRealContent || hasExternalContent;
const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton
const defaultSubmitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: defaultActionQueues
? "Queue message"
: isAgentRunning
? "Send and interrupt"
: "Send message";
const submitAccessibilityLabel =
submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel;
const handleInputChange = useCallback(
(nextValue: string) => {
@@ -955,37 +995,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
{images.map((image, index) => (
<Pressable
key={`${image.id}-${index}`}
testID="message-input-image-pill"
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<ImageAttachmentThumbnail image={image} />
{onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !isWeb) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
</View>
)}
</>
)}
</Pressable>
))}
</View>
)}
<Animated.View
ref={inputWrapperRef}
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
>
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<TextInput
@@ -997,10 +1010,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}}
onBlur={() => {
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}}
style={[
@@ -1025,34 +1040,69 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
<Text style={styles.focusHintText} pointerEvents="none">
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
</Text>
) : null}
</View>
{/* Button row */}
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
<View style={styles.leftButtonGroup}>
{onPickImages && (
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
<DropdownMenuTrigger
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityLabel="Add attachment"
accessibilityRole="button"
testID="message-input-attach-button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
{({ hovered }) => (
<View
ref={onAttachButtonRef}
collapsable={false}
style={styles.attachButtonAnchor}
>
<Plus
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</View>
)}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
<Text style={styles.tooltipText}>Add attachment</Text>
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
side="top"
align="start"
offset={8}
minWidth={220}
testID="message-input-attachment-menu"
>
{attachmentMenuItems.map((item) => (
<DropdownMenuItem
key={item.id}
testID={`message-input-attachment-menu-item-${item.id}`}
disabled={item.disabled}
onSelect={item.onSelect}
leading={item.icon ?? null}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{leftContent}
</View>
@@ -1080,13 +1130,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={buttonIconSize} color={theme.colors.foreground} />
) : (
<Mic size={buttonIconSize} color={theme.colors.foreground} />
)}
{({ hovered }) =>
isDictating ? (
<Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : (
<Mic
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -1111,31 +1169,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleAlternateSendAction}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={({ hovered }) => [
styles.queueButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={buttonIconSize} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
{queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
@@ -1153,7 +1186,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
<Text style={styles.tooltipText}>
{submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")}
</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
</TooltipContent>
@@ -1221,55 +1256,17 @@ const styles = StyleSheet.create(((theme: any) => ({
}
: {}),
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imagePill: {
position: "relative",
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(isWeb
? {
cursor: "pointer",
}
: {}),
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface2,
},
removeImageButton: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(isWeb
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
}
: {}),
},
removeImageButtonVisible: {
opacity: 1,
},
textInputScrollWrapper: {
position: "relative",
},
focusHintText: {
position: "absolute",
top: 0,
right: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
opacity: 0.5,
},
textInput: {
width: "100%",
color: theme.colors.foreground,
@@ -1288,6 +1285,7 @@ const styles = StyleSheet.create(((theme: any) => ({
flexDirection: "row",
alignItems: "flex-end",
justifyContent: "space-between",
marginHorizontal: -6,
},
leftButtonGroup: {
minWidth: 0,
@@ -1295,13 +1293,13 @@ const styles = StyleSheet.create(((theme: any) => ({
flexGrow: 1,
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
gap: theme.spacing[0],
},
rightButtonGroup: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
gap: theme.spacing[1],
},
attachButton: {
width: 28,
@@ -1310,6 +1308,12 @@ const styles = StyleSheet.create(((theme: any) => ({
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 28,
height: 28,
@@ -1320,14 +1324,6 @@ const styles = StyleSheet.create(((theme: any) => ({
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
queueButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
sendButton: {
width: 28,
height: 28,
@@ -1335,6 +1331,7 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
@@ -47,7 +47,6 @@ const styles = StyleSheet.create((theme) => ({
export interface PairLinkModalProps {
visible: boolean;
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
@@ -57,39 +56,39 @@ export interface PairLinkModalProps {
}) => void;
}
export function PairLinkModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: PairLinkModalProps) {
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const [offerUrl, setOfferUrl] = useState("");
const offerUrlRef = useRef("");
const inputRef = useRef<TextInput>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
offerUrlRef.current = "";
inputRef.current?.clear();
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = offerUrl.trim();
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)");
return;
@@ -122,15 +121,6 @@ export function PairLinkModal({
return;
}
if (targetServerId && parsedOffer.serverId !== targetServerId) {
const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`;
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Wrong daemon", message);
}
return;
}
try {
setIsSaving(true);
setErrorMessage("");
@@ -159,16 +149,7 @@ export function PairLinkModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
return (
<AdaptiveModalSheet
@@ -182,11 +163,13 @@ export function PairLinkModal({
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<AdaptiveTextInput
ref={inputRef}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
value={offerUrl}
onChangeText={setOfferUrl}
onChangeText={(next) => {
offerUrlRef.current = next;
}}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}

View File

@@ -41,9 +41,9 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id,
);
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
import { AlertCircle, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
import { isWeb } from "@/constants/platform";
import { Fonts } from "@/constants/theme";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { formatTimeAgo } from "@/utils/time";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
interface ProviderDiagnosticSheetProps {
provider: string;
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
}: ProviderDiagnosticSheetProps) {
const { theme } = useUnistyles();
const client = useHostRuntimeClient(serverId);
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo(
() => snapshotEntries?.find((entry) => entry.provider === provider),
[snapshotEntries, provider],
);
const models = providerEntry?.models ?? [];
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
const fetchDiagnostic = useCallback(async () => {
if (!client || !provider) return;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
// clockTick triggers re-computation on timer
}, [providerEntry?.fetchedAt, clockTick]);
setLoading(true);
setDiagnostic(null);
const q = query.trim().toLowerCase();
const filteredModels = q
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
: models;
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
}, [client, provider]);
const fetchDiagnostic = useCallback(
async (options?: { keepCurrent?: boolean }) => {
if (!client || !provider) return;
setLoading(true);
if (!options?.keepCurrent) {
setDiagnostic(null);
}
try {
const result = await client.getProviderDiagnostic(provider as AgentProvider);
setDiagnostic(result.diagnostic);
} catch (err) {
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
} finally {
setLoading(false);
}
},
[client, provider],
);
const handleRefresh = useCallback(() => {
void refresh([provider as AgentProvider]);
void fetchDiagnostic({ keepCurrent: true });
}, [fetchDiagnostic, provider, refresh]);
useEffect(() => {
if (visible) {
fetchDiagnostic();
} else {
setDiagnostic(null);
setQuery("");
}
}, [visible, fetchDiagnostic]);
function renderModelsBody() {
if (models.length === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models</Text>
</View>
);
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyState}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
);
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>No models match your search</Text>
</View>
);
}
return filteredModels.map((model: AgentModelDefinition, index) => (
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
));
}
return (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
scrollable={false}
headerActions={
<Pressable
onPress={handleRefresh}
disabled={refreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
sheetStyles.iconButton,
(hovered || pressed) && sheetStyles.iconButtonHovered,
refreshInFlight ? sheetStyles.disabled : null,
]}
accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
<SpinningRefreshIcon
spinning={refreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
}
>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
{loading && !diagnostic ? (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
style={sheetStyles.codeScroll}
contentContainerStyle={sheetStyles.codeContent}
showsVerticalScrollIndicator={false}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text style={sheetStyles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
)}
</View>
</View>
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
<Text style={sheetStyles.countText}>{models.length}</Text>
{fetchedAtLabel ? (
<>
<Text style={sheetStyles.metaDot}>·</Text>
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
</>
) : null}
</View>
</View>
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
placeholder="Search models"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
// @ts-expect-error - outlineStyle is web-only
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
/>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
) : null}
</View>
</AdaptiveModalSheet>
);
}
const sheetStyles = StyleSheet.create((theme) => ({
loadingContainer: {
section: {
gap: theme.spacing[2],
},
sectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
mutedText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
iconButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
},
codeBlock: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
backgroundColor: theme.colors.surface2,
overflow: "hidden",
maxHeight: 180,
},
codeScroll: {
maxHeight: 180,
},
codeContent: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
codeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
},
codeBlockLoading: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
modelsSection: {
flex: 1,
minHeight: 0,
gap: theme.spacing[2],
},
modelsHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
modelsHeaderMeta: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
metaDot: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
countText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
searchContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
modelsScroll: {
flex: 1,
minHeight: 0,
},
modelsScrollContent: {
paddingBottom: theme.spacing[2],
},
modelRow: {
paddingVertical: theme.spacing[3],
},
modelRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
modelLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
modelId: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontFamily: Fonts.mono,
marginTop: 2,
},
emptyState: {
paddingVertical: theme.spacing[6],
alignItems: "center",
gap: theme.spacing[2],
},
loadingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
scrollContainer: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4],
},
diagnosticText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontFamily: "monospace",
lineHeight: theme.fontSize.sm * 1.6,
},
}));

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