Compare commits

...

108 Commits

Author SHA1 Message Date
Mohamed Boudra
9376de1ef1 Restore terminal toolbar focus handling 2026-06-19 11:54:12 +07:00
Mohamed Boudra
ad30f65549 Use tracked terminal mode for native paste 2026-06-19 11:41:41 +07:00
Mohamed Boudra
ee92843965 Stabilize terminal resize browser test 2026-06-19 11:39:03 +07:00
Mohamed Boudra
e39cbb4643 Stop per-key terminal resize claims 2026-06-19 11:24:38 +07:00
Mohamed Boudra
25349e0fe5 Replay bracketed paste in terminal preamble 2026-06-19 11:08:49 +07:00
Mohamed Boudra
e5322b5b63 Remove dead native terminal input branch 2026-06-19 10:57:14 +07:00
Mohamed Boudra
2b4f133a6c Address mobile terminal review feedback 2026-06-19 10:43:21 +07:00
Mohamed Boudra
ff9991acc2 Improve mobile terminal typing and selection 2026-06-19 10:20:34 +07:00
Matt Cowger
b90baaff71 fix: daemon warns instead of crashing on missing OpenAI speech credentials (#1368)
* fix: daemon warns instead of crashing on missing OpenAI speech credentials

validateOpenAiCredentialRequirements was throwing an error when
OpenAI speech providers were configured without credentials,
causing the daemon to crash before it could start serving health
checks. Changed to log a warning instead — the daemon starts
successfully and reports speech services as unavailable.

Closes #1367

* chore: address Greptile review feedback

- Remove duplicate warning in initializeOpenAiSpeechServices when
  all credentials are missing (already warned by validate)
- Strengthen test assertions: check getListenTarget() after start
- Wrap daemon start/stop in try/finally for proper cleanup on failure
2026-06-19 11:09:39 +08:00
paseo-ai[bot]
d0189f3f65 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 22:51:53 +00:00
Mohamed Boudra
60cf566b79 chore(release): cut 0.1.97 2026-06-19 05:47:43 +07:00
Mohamed Boudra
de5307dfa0 Retry browser test dependency installs 2026-06-19 05:11:52 +07:00
Mohamed Boudra
0c6f008115 Update ACP provider catalog 2026-06-19 05:01:44 +07:00
Mohamed Boudra
1fc565118b Skip Playwright system deps in CI 2026-06-19 04:31:32 +07:00
Mohamed Boudra
112f1ace65 Fix legacy workspace compatibility 2026-06-19 04:14:12 +07:00
Mohamed Boudra
ee3a7a2d28 Bound Playwright browser install in CI 2026-06-19 03:26:33 +07:00
Mohamed Boudra
aff5fe651c Update ACP provider catalog 2026-06-19 01:31:31 +07:00
Mohamed Boudra
05e8d34613 Refresh changelog for 0.1.97 2026-06-19 00:50:17 +07:00
Mohamed Boudra
5d0fbd1d37 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-19 00:48:23 +07:00
Mohamed Boudra
9a09ccb3d6 Fix sidebar project removal
Remove projects through a daemon RPC so the sidebar can delete the project
record after archiving its active workspaces. Reopening a removed directory
recreates the missing project record so local projects keep their derived
basename, and History skips orphaned archived workspace rows.

Fixes #1583.
2026-06-19 00:46:40 +07:00
Myriad-Dreamin
8cc74fb75d Document filename:line:column file link parsing (#1560)
* Reapply app file-open changes on v0.1.87

* fix: resolve relative assistant file links in workspace

* fix: tighten file link and open intent parsing

* Remove line and column from editor open targets

* fix: drop column offsets from file links
2026-06-18 17:27:47 +00:00
V1rus
01a2afa7c5 Fix markdown rendering hang on unmatched backticks (#1585)
* fix: prevent infinite loop on unmatched backtick runs in getInlineCodeRanges

* test: cover unmatched inline backtick runs

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-19 01:04:28 +08:00
Christoph Leiter
931ea97942 fix(app): reveal workspace-jump numbers only for the active modifier (#1580)
Holding the workspace-jump modifier reveals numbered badges in the sidebar for jumping to a workspace (Cmd+1..9). On the desktop app the badges also appeared while holding Option or Control, neither of which performs the jump — only Cmd does on macOS desktop (Ctrl on non-Mac, Alt on web).

The keydown/keyup handler flagged badge visibility for Alt on every runtime and for both Meta and Control on desktop — broader than the active workspace.navigate.index binding. Gate the badges to the single modifier that performs the jump on the current runtime, via a new getWorkspaceIndexJumpModifierKey helper. Adds unit tests for the three runtime cases.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:55:38 +00:00
Mohamed Boudra
f876b80f7a Update changelog for 0.1.97 2026-06-18 23:02:52 +07:00
Mohamed Boudra
4f78d4f679 Update ACP provider catalog 2026-06-18 23:02:36 +07:00
Mohamed Boudra
2064e308a7 Update server tests for current workspace behavior 2026-06-18 23:02:27 +07:00
Mohamed Boudra
c3321ea8ff Show PR numbers without the # and make header buttons icon-only
PR badges in the sidebar rows, hover card, and right-sidebar PR tab now
show the number alone. The Scripts and Open-in-editor header buttons drop
their labels and gain a min height so they keep the same size icon-only.
2026-06-18 22:50:40 +07:00
Mohamed Boudra
3d5059ab05 Keep legacy daemon agents attached to workspaces
Old daemons can report agents by cwd without workspace IDs. Build path-backed workspace rows for that compatibility path and reapply ownership to legacy snapshots so tab reconciliation keeps those agents visible.
2026-06-18 22:41:48 +07:00
Mohamed Boudra
ef87b09146 Update ACP provider catalog to latest registry versions 2026-06-18 20:57:27 +07:00
Mohamed Boudra
d89affb5dc Show workspace names in History titles 2026-06-18 20:52:59 +07:00
Mohamed Boudra
00c57617d5 Fix History restore and diff truncation edge cases 2026-06-18 20:44:57 +07:00
Mohamed Boudra
b3f81c0bf4 Recreate deleted worktrees from their branch on restore
Archiving a Paseo-owned worktree deletes its directory, so reopening its
agent previously dead-ended even after the workspace record came back —
there was no folder to return to.

Restore now recreates the worktree at its original path from the kept
branch (the branch is never deleted on archive), re-points the same
workspace record, and clears archivedAt — preserving the workspaceId so
the agent stays linked. The directory is recreated before the un-archive
so the reconciler can't re-archive it for a missing directory. A deleted
or already-checked-out branch surfaces a typed failure and leaves the
workspace archived.

Gated behind a worktreeRestore capability flag; older hosts get an
explicit "update your host" state instead of a misleading failure.
2026-06-18 20:44:57 +07:00
Mohamed Boudra
31fcde2ea6 Restore an archived workspace when its agent is reopened
Reopening an archived agent previously dead-ended at "workspace not
found": the agent record was un-archived but its workspace stayed
archived and was never sent to the client.

The daemon now un-archives the owning workspace alongside the agent when
its backing directory still exists, and the app auto-fires the restore
ahead of the missing-workspace gate so the user lands back in the
workspace rather than a dead end.

Worktrees whose directory was deleted on archive are not yet restorable
(recreating them from their branch is a follow-up); the workspace record
now persists its base branch as groundwork for that.
2026-06-18 20:44:56 +07:00
Mohamed Boudra
fe15b68e06 Fix per-file diff size gating 2026-06-18 20:44:56 +07:00
Mohamed Boudra
ea2f768d3f Keep connections alive through brief daemon slowdowns (#1581)
* Keep connections alive through brief daemon slowdowns

The active connection's liveness check was a side effect of the
candidate-probe loop: a single 5s latency measurement doubled as the
death timeout, so a daemon that was alive but briefly stalled (e.g. a
busy event loop) was misread as dead and the connection was torn down.

Give DaemonClient its own self-scheduling heartbeat that solely owns
liveness (15s timeout, 2 consecutive misses), and make the probe
read-only on the active connection — it reads the heartbeat's last RTT
instead of pinging. Split the shared ping into a pure measurement path
(measureLatency) and a private liveness path, so nothing outside the
client can drive teardown.

* Don't let a candidate latency timeout count as a liveness failure

The heartbeat (livenessPing) and candidate ranking (measureLatency) share
one in-flight ping slot. If a heartbeat tick lands while a candidate
measurement is still in flight, the heartbeat dedupes onto it — and a
measurement timeout was then recorded as a liveness failure, nudging the
connection one step closer to a spurious teardown.

Tag each ping with whether its timeout may drive a liveness failure
(only the heartbeat sets it) and gate recordLivenessFailure on that flag,
so an adopted measurement probe can never contribute to teardown.
2026-06-18 21:10:08 +08:00
Mohamed Boudra
eb628765d7 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 14:08:33 +07:00
paseo-ai[bot]
61bfc5b631 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 07:05:54 +00:00
Mohamed Boudra
59299ac635 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 14:03:21 +07:00
Mohamed Boudra
df18bbc43a Migrate monorepo to Zod 4 2026-06-18 15:02:42 +08:00
Mohamed Boudra
1250f5cf4f Keep new-workspace composer expanded after submit
The new-workspace composer uses preserve-and-lock: it keeps the typed
text through workspace creation so it survives a failure. But the input
minimized its height on every submit, collapsing a multi-line draft to
one line while the text and loading spinner remained — clipped and ugly.

Gate the height-minimize on the same condition that clears the text, so
the box only collapses when the text actually goes away (agent panel),
not when it's preserved (new-workspace).
2026-06-18 13:41:22 +07:00
Mohamed Boudra
de84c8f179 Align sidebar header rows and tighten button spacing 2026-06-18 13:23:41 +07:00
Mohamed Boudra
7c1870e887 Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 13:04:07 +07:00
Mohamed Boudra
c8717e0c70 Regenerate terminal webview bundle
Picks up the snapshot reflow-after-resize fix from 4cc72a84e, whose
source change to renderTerminalSnapshotToAnsi (scrollback/grid wrap
flags) was never rebuilt into this generated bundle. Pure regeneration
via build-terminal-webview-html.mjs — no hand edits. The esbuild
module-path comment churn is cosmetic, from workspace dependency
resolution paths.
2026-06-18 13:00:05 +07:00
Mohamed Boudra
3163c7d09b Simplify sidebar workspace status indicator
Hide the kind icon entirely when a workspace is idle, and give the
attention state a standalone dot instead of overlaying it on the icon.
Restructure the row into an icon column plus a content column so the
subtitle and PR badge align under the title without hardcoded indents.
2026-06-18 12:56:18 +07:00
Mohamed Boudra
4e899c644a Keep keyboard offsets tied to visibility
Derive app keyboard offsets from one provider-owned shared value so chat content, composer surfaces, and floating panels share the same policy.

Treat progress-zero keyboard samples as closed, even if Android reports a stale nonzero height.
2026-06-18 12:03:44 +07:00
paseo-ai[bot]
afe7eb876c fix: update lockfile signatures and Nix hash [skip ci] 2026-06-18 04:44:09 +00:00
Mohamed Boudra
e4da3693e9 chore(release): cut 0.1.97-beta.3 2026-06-18 11:40:27 +07:00
Mohamed Boudra
a51c39eddf chore: changelog for 0.1.97-beta.3 2026-06-18 11:38:53 +07:00
Mohamed Boudra
b7ab1a1732 Update ACP provider catalog to latest registry versions 2026-06-18 11:37:35 +07:00
Mohamed Boudra
35c2e5de8e Merge branch 'main' of github.com:getpaseo/paseo 2026-06-18 11:29:29 +07:00
Mohamed Boudra
6c0b5c7640 Fix provider snapshot warm reads 2026-06-18 11:26:27 +07:00
paseo-ai[bot]
127b138a91 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-17 10:19:27 +00:00
Mohamed Boudra
6fc52d85cb chore(release): cut 0.1.97-beta.2 2026-06-17 17:15:23 +07:00
Mohamed Boudra
608a950155 chore: changelog for 0.1.97-beta.2 2026-06-17 17:13:53 +07:00
Mohamed Boudra
fc4c2aa367 Decouple metadata prompt contract from overridable style
Metadata prompts (workspace title + branch, commit message, PR) mixed the
functional contract with style guidance in one block, and project-owner
instructions from paseo.json were appended after the defaults with an
"override where they conflict" notice — so custom wording competed with the
built-in style instead of replacing it.

Each prompt is now a non-overridable contract (what to produce, the JSON
shape, correctness/safety rules) plus style slots that paseo.json
instructions replace wholesale. Title and branch style are now separate
keys (metadataGeneration.title vs .branchName); previously branchName
instructions also shaped the title.
2026-06-17 16:48:24 +07:00
Mohamed Boudra
2bc48993a5 Generate shorter, task-shaped workspace titles
Auto-generated titles were full sentences that overflowed the sidebar.
The titling prompt now targets ~4 words, drops generic "do" verbs (Fix,
Add, Diagnose...), and keeps only specific operation verbs, with good and
bad examples.
2026-06-17 15:54:51 +07:00
Mohamed Boudra
e58de8c338 Use the history icon for the sidebar Sessions button 2026-06-17 15:39:52 +07:00
Mohamed Boudra
4a6503f177 Fix New Workspace keyboard positioning 2026-06-17 15:32:55 +07:00
Mohamed Boudra
90d386b88e Restore changelog entries for betas (#1568)
* Restore changelog entries for betas

Beta users check release notes, so each beta now updates an in-place
CHANGELOG.md entry that promotion overwrites with the final stable entry
— no -beta.N heading is left behind. The release machinery already
parses beta headings and syncs them; this restores the policy in the
docs and the release-beta skill, reversing the docs side of 28415b354.

* Spell out in-place beta overwrite in the promotion checklist

The stable checklist said only "update CHANGELOG.md", so a promotion
followed by the letter could stack a fresh entry on top of the
-beta.N block — the stale heading the policy forbids. Make the
promotion path overwrite-in-place explicit, matching the beta checklist.
2026-06-17 15:35:25 +08:00
Mohamed Boudra
0c9802b22b Show prompt titles immediately for new workspaces (#1567)
* Set workspace prompt titles at creation

* Cover existing workspace prompt titles

* Fix rewind E2E prompt selector
2026-06-17 15:30:32 +08:00
Mohamed Boudra
1340a80144 Fix New Workspace PR worktree creation (#1565)
* Fix PR worktree creation from New Workspace

* Use fixture default branch in GitHub E2E clone
2026-06-17 15:15:58 +08:00
xpufx
11abbcc630 fix(desktop): stop AppImage updates hanging on quit and deleting the app (#1485)
* fix(desktop): disable auto-install on quit for AppImage only

electron-updater's AppImageUpdater.doInstall() uses execFileSync with APPIMAGE_EXIT_AFTER_INSTALL when autoInstallOnAppQuit is true. For AppImages, this blocks the old process indefinitely because the new process has no installer step to exit from — the mv already completed the install.

Scoped to AppImage only (process.platform === 'linux' && process.env.APPIMAGE). .deb/.rpm/Windows/macOS keep auto-install-on-quit working as before.

* fix(desktop): keep AppImage filename stable across updates

electron-updater renames a versioned AppImage to a new path on update and
unlinks the old one. That orphaned the previous binary, broke desktop
shortcuts, and dangled the ~/.local/bin/paseo CLI symlink, which points at
$APPIMAGE. Dropping ${version} from the AppImage artifactName makes the
updater overwrite the file in place instead; deb/rpm keep versioned names.

Also documents why AppImage must not auto-install on quit (the blocking
execFileSync gated on APPIMAGE_EXIT_AFTER_INSTALL, honored only by
AppImageLauncher) and extracts that gate into a unit-tested helper.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-17 07:09:35 +00:00
Mohamed Boudra
14743fa0b0 Update Agent MCP redaction smoke test (#1566) 2026-06-17 14:14:14 +08:00
Mohamed Boudra
a61118776a Shrink the Workspaces sidebar label to xs 2026-06-17 11:30:05 +07:00
Mohamed Boudra
7e5d7f7c67 Remember the New Workspace isolation choice
The local/worktree choice was unstored component state and reset on every
remount of the New Workspace screen (e.g. after creating a workspace).
Persist it in the create-form preferences (FormPreferences.isolation).

Also rename the app-layer "backing" to "isolation" to match its UI label.
Isolation is the create-time intent; it stays distinct from workspaceKind,
the git-derived persisted property, and the wire's source.kind.
2026-06-17 09:53:22 +07:00
Mohamed Boudra
69741e0770 Make the agent MCP server stateless to fix session leak
The agent-facing MCP server (/mcp/agents) ran in stateful session mode,
keeping a StreamableHTTPServerTransport plus its full Server and Zod tool
schemas alive per agent connection, keyed by session id. The map was only
pruned on the SDK's onclose/onsessionclosed callbacks, which fire only on a
clean DELETE. Agents that are killed or exit without one never get reaped, so
dead sessions accumulate for the life of the daemon (observed ~77MB across 132
stale transports in a heap snapshot).

Listing and calling tools needs no cross-request state, so each request now
builds a fresh server + transport and tears both down when the response closes.
Per-request cost is ~0.6-0.9ms and ~760KB of short-lived allocation; nothing is
retained between requests. GET and DELETE return 405, which the MCP client
tolerates (it never receives a session id).
2026-06-17 09:18:29 +07:00
paseo-ai[bot]
7b4b27d877 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-17 01:28:24 +00:00
Mohamed Boudra
8c509017ff chore(release): cut 0.1.97-beta.1 2026-06-17 08:24:25 +07:00
Mohamed Boudra
c32b80a2ed Update ACP provider catalog to latest registry versions 2026-06-17 08:21:34 +07:00
Mohamed Boudra
fcb7f8e215 Name agents by their first prompt line instead of an LLM summary (#1563)
* Name agents by their first prompt line instead of an LLM summary

The workspace title already captures the task; the LLM-generated agent
title just restated the same prompt. Drop the generator and keep the
provisional title (first prompt line), so the agent label stays
deterministic and explicit titles still win.

* Remove the no-op agent-title setting and leftover generator plumbing

Deleting the agent-title generator left its whole surface dangling: a
configurable "Agent titles" project setting that no longer did anything,
dead create-path plumbing, generator-only import/MCP deps, and the mock
provider's agent-title parser. Remove all of it, plus a dead test mock
and timing sleeps that guarded the deleted background job. Replace them
with a no-mock lifecycle test asserting the title stays the first prompt
line across create and settle.

Old paseo.json files with metadataGeneration.agentTitle still parse via
schema passthrough — see COMPAT(projectMetadataAgentTitle).
2026-06-17 01:12:23 +08:00
Mohamed Boudra
e202ca5036 Remove a worktree when its last workspace is archived (#1562)
* Remove a worktree when its last workspace is archived

Archiving is now workspace-centric: the UI always archives a workspace,
and a worktree's directory is removed only when its last referencing
workspace is archived (derived from ownership + a single reference
count). The "remove the worktree from disk?" prompt is gone; archiving
warns only when the workspace is dirty or has unpushed commits.

MCP archive_worktree and the CLI keep the explicit lower-level behavior:
archive every workspace backing the directory, then remove it.
Auto-archive-after-merge and create-agent auto-archive respect the
reference count like any other workspace archival.

The policy now lives in two modules - a server archive service and a
client archive hook - with the reference-count check in exactly one
place. The deleteWorktreeFromDisk request field is retained but ignored
for wire back-compat.

* Tidy archive-policy internals and close test gaps

Follow-up polish on the archive-policy consolidation; no behavior change.

Structure: the server archive service is renamed to reflect that it now
owns all archive policy (the file and its types no longer say "worktree"
where they mean "archive"); the client archive input derives the
workspace kind from the canonical type instead of re-listing it; the
not-found target is typed string|null instead of an empty-string
sentinel; a redundant workspace-update emit and the ambiguous
worktrees-root naming are cleaned up. Wire, SDK, and keybinding names are
deliberately left unchanged for back-compat.

Tests: re-home the archiving-state lifecycle and per-workspace snapshot
assertions; pin worktree-kind acceptance through archive_workspace, the
ignored deleteWorktreeFromDisk field, and three-workspace archive-all;
add a Playwright spec for the dirty/unpushed worktree archive flow (the
warning gates it, confirming removes the directory) and the bulk
remove-project confirm branch.

* Fix the hanging worktree-archive risk-warning e2e spec

The spec added in the previous commit hung deterministically, locally and
in CI. Two test-only bugs, no product change.

The archive click opens a synchronous window.confirm(); the spec
registered a passive dialog waiter but only dismissed the dialog after
awaiting the click, so the click never resolved (Playwright won't settle
a click while a dialog is open) - a circular wait. Answer the dialog
inline via page.once("dialog", ...) before the click resolves, matching
the proven worktree-archive spec.

The seed also committed on a never-pushed branch, so aheadOfOrigin was
null and the "1 unpushed commit" warning never appeared. Push the branch
to set its upstream before the local commit, so it reports exactly one
unpushed commit.

Verified: the spec now passes in ~6s (was a 45s timeout); both the
dismiss-gates and accept-removes paths run.
2026-06-17 00:38:56 +08:00
Mohamed Boudra
0fef66283a Fix pending permission MCP listing (#1561)
* Fix pending permission MCP responses

* Tighten MCP permission sanitizer typing
2026-06-16 12:53:23 +00:00
Mohamed Boudra
37de9ea796 Fix workspace-owned Playwright flows 2026-06-16 18:57:00 +07:00
Mohamed Boudra
3cf3a63423 Pass workspace ownership when creating terminals 2026-06-16 17:35:16 +07:00
Mohamed Boudra
10664487fb Add copy-to-clipboard hover action for branch and path in workspace hover card 2026-06-16 17:20:11 +07:00
Mohamed Boudra
be3a1ea6c3 Keep new workspaces recent in status groups 2026-06-16 16:41:07 +07:00
Mohamed Boudra
88b48c9d09 Add keyboard shortcut to create a new workspace
Cmd+N (Ctrl+N off mac) navigates to the new-workspace route through a
global handler keyed on the active host. The sidebar header row surfaces
the shortcut hint on hover, replacing the redundant "+" button that
duplicated the action in the Workspaces section header.
2026-06-16 16:13:03 +07:00
Mohamed Boudra
e77c8e2bf3 Always show workspace hover card with branch and directory
The hover card was previously gated to open only when a PR hint or diff
stat was available. It now opens on every desktop-web workspace hover so
users can see the workspace name, branch, and working directory. The CWD
is shortened using the existing shortenPath helper (/Users/... -> ~).
2026-06-16 15:27:02 +07:00
Mohamed Boudra
f5d39446cc Merge branch 'main' of github.com:getpaseo/paseo 2026-06-16 14:59:42 +07:00
Mohamed Boudra
64b2949f08 Key agent placement by workspace id, not directory
Agent project-placement and the fetch/list grouping still resolved a
workspace from the agent's directory, even though every agent now
carries a workspaceId. Walk workspaceId -> workspace -> project instead:
an agent whose cwd is shared with or nested under another workspace now
groups under its own workspace, not a directory-prefix match. The fetch
path's placement map is keyed by workspace id, closing the
same-directory collision.

resolveWorkspaceIdForPath survives only at the archive-by-path boundary
(auto-archive-on-merge, the MCP archive tool), where a client passes a
bare worktree path with no id; renamed to match that role.
2026-06-16 14:58:33 +07:00
Mohamed Boudra
36a136d811 Add shared trigger components and horizontal desktop workspace arguments
- Add DropdownTrigger and ComboboxTrigger with an overridable chevron slot and a 1 px translateY caret nudge for consistent vertical alignment.

- Convert text-based dropdown/combobox triggers across model selector, agent controls, mode control, settings, and new workspace to use the shared components.

- Lay out New Workspace arguments horizontally on desktop without labels while keeping the mobile vertical stack; align the project icon left edge with the title.
2026-06-16 14:56:07 +07:00
Mohamed Boudra
1812884ecd Include child results in finish notifications (#1558)
* Include child results in finish notifications

* Nest child results in system notifications
2026-06-16 15:15:45 +08:00
Mohamed Boudra
c464c2bcb4 Move tab row pins and default-pin terminal and browser 2026-06-16 14:12:18 +07:00
Mohamed Boudra
9966e49329 Key workspace status and ownership by ID, not directory
Multiple workspaces can share one checkout directory. Agent and terminal
status, sidebar tabs, and ownership now attribute strictly by workspace ID,
so a running or blocked agent in one workspace no longer shows up on its
same-directory siblings.

A one-time startup migration backfills workspace IDs onto legacy agents, and
every agent and terminal is created with one thereafter. Directory lookups
remain only for genuinely folder-derived git facts (branch, diff) and the
archive-by-path adapter.
2026-06-16 13:37:58 +07:00
Mohamed Boudra
e90f398ac0 Use only selectable Cursor ACP models (#1556) 2026-06-16 05:07:24 +00:00
Mohamed Boudra
ba9f7740a8 Fix same-directory workspace ownership 2026-06-15 22:22:31 +07:00
Mohamed Boudra
a4d4d42873 Update workspace model ownership flows 2026-06-15 22:02:18 +07:00
Mohamed Boudra
af0437e6df Update terminal activity E2E for finished attention 2026-06-15 21:07:07 +07:00
Mohamed Boudra
1c9e78616f Reduce daemon churn from terminals and archived agents
Decouple terminal list snapshots from workspace status invalidation, and filter archived stored agents before projection so seeded homes do less work.
2026-06-15 20:07:33 +07:00
Mohamed Boudra
754f6336c0 Remove pinned launchers from workspace rows 2026-06-15 15:02:38 +07:00
Mohamed Boudra
e84a3ff574 Clear terminal attention when the user views the terminal
A finished or input-waiting terminal shows an attention indicator. The
focus heartbeat already asked the daemon to clear it, but the clear was a
no-op: it guarded on activity state === "attention", while the real
tracker encodes attention as state "idle" plus an attentionReason
("finished" / "needs_input"). The state is never literally "attention",
so the indicator only ever cleared when the agent started a new turn.

The worker proxy also had no way to reach the tracker's clearAttention():
it only spoke setActivity, and setActivity("idle") is short-circuited by
the tracker while a "finished" reason is set. Add a clearAttention worker
message that calls the existing manager.clearTerminalAttention(), and
guard both clear paths on attentionReason instead of state.
2026-06-15 14:31:37 +07:00
Mohamed Boudra
713a042dc5 Add pinnable new-tab targets to the tab row and sidebar
Any new-tab target (new agent, terminal, browser, or a terminal profile)
can be pinned as a quick-launch button. Pins are global client app state
(persisted), not daemon config, so the same set shows on every workspace;
profile pins resolve per host and are dropped where that profile is absent.

The sidebar mirror launches via a navigate-then-launch intent store rather
than calling the create handlers directly: terminal creation only works in
the mounted/focused workspace, so the sidebar requests the launch and the
workspace screen consumes it on focus.
2026-06-15 14:09:29 +07:00
Mohamed Boudra
42969e40d5 Stop daemon crashes from terminal-list worker timeouts
getTerminals was the only terminal read that round-tripped the terminal
worker process. Under load the IPC call could hit its 10s timeout, and
the rejection from a fire-and-forget workspace-update emit propagated as
an unhandled rejection — taking down the whole daemon.

The daemon already maintains an authoritative mirror of terminal state
(every other read is served from it), so getTerminals now reads that
mirror locally, preserving the worker's descendant-path aggregation and
absolute-path guard. The getTerminals worker IPC request is removed
entirely, so no terminal read can stall on the worker anymore.

Also adds a defensive .catch to the two fire-and-forget workspace-update
sites, and hoists the duplicated assertAbsolutePath guard into one shared
helper in path-utils.
2026-06-15 14:09:29 +07:00
Mohamed Boudra
a4c4418164 Keep finished terminals visible as attention 2026-06-15 14:06:48 +07:00
Mohamed Boudra
71b5c35d9b Run multiple independent workspaces per directory (#1539)
* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)

Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.

- protocol: add optional workspaceId to agent/terminal records and a
  workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
  creation; carry it through agent-manager, projections, storage,
  loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
  workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
  through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
  mcp-server, workspace-directory, workspace-registry-model, and
  agent-visibility

cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.

Known follow-ups: none.

* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)

- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename

Known follow-ups: none

* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)

- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory

Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage

* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)

- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
  workspace before creating any agent, then stamps the agent with that
  workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
  --worktree mints its own workspace and overrides the ambient
  PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
  workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
  existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
  run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
  --workspace attach, and env attach against an isolated daemon.

Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
  longer relies on it but it is not yet removable.

* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)

- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.

Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.

* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)

- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.

Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.

* fix(protocol): use literal :: separator in terminal subscription key

The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.

* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)

- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
  same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
  workspace-owned drafts, attachments, or file-explorer state. New docs section in
  architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
  right sidebar is shared (a directory change appears in both same-dir workspaces)
  while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
  - MCP child agents created in a new worktree are stamped with the new worktree's
    workspaceId (not the parent's, not unstamped), mirroring the session path so
    workspaceId-scoped archive can find and tear them down.
  - session-store mergeWorkspaces prunes a stale empty-project descriptor when a
    workspace lands in that project, so it stops governing the project's metadata.
  - CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
    --workspace), so worktree resolution never races an existing-workspace select.
    New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
  - Consolidate the four placeholder server_info.features flags (workspaceOwnership,
    workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
    workspaceMultiplicity capability gate.
  - Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
    creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
  - Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
  - Drop the obsolete workspaceOwnership feature assertion from the same-cwd
    isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
  - Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.

Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).

* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)

Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.

Categories fixed:

- Mocks -> real dependencies + persisted state: create.test.ts and
  mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
  and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
  with a fake agent client and assert on the stored agent record (workspaceId,
  parent label).

- Module-internal spying -> injected seam: acp-agent stopped spying on the
  tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
  injection seam to ACPAgentClient/ACPAgentSession (production defaults to
  terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
  recorded children plus observable stream .destroyed state.

- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
  tests now create terminals through the public createTerminal API instead of
  mutating internals.terminalEntries.

- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
  its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
  it then asserted against) and now proves only the daemon behaviors the CLI
  builds on; flag precedence stays covered in the CLI's own run.test.ts.

- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
  expect.poll on the observed snapshot state to remove the race.

- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
  the captureWsSessionFrames workspace.title.set.request assertions, relying on
  the user-visible rename + reload checks already present.

* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects

Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
  When a new workspace is created with an initial agent, optimistically merge it
  with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
  instead of defaulting into the wrong bucket. The empty-workspace path passes
  withInitialAgent: false so a bare workspace keeps its descriptor status.

Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
  The reshaped creation flow requires choosing a backing ("New worktree") before
  the branch / starting-ref picker is reachable. Branch-picker specs now call
  selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
  worktree control to drop aria-disabled (it stays disabled until the checkout
  status query confirms the project is a git repo) before clicking.

Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
  fetch_workspaces response now carries emptyProjects; the toEqual expectation
  includes emptyProjects: [].

Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
  Out-of-band working-tree writes raced the daemon's debounced filesystem
  watcher, so the UI could subscribe before the new file was in the git snapshot.
  Force a checkout refresh (same path as the UI's manual refresh) to make the
  write authoritative before asserting, removing the timing dependency.

* test(app): expect withInitialAgent:false in empty-workspace create call

The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.

* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance

Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.

- Isolation/backing is now a dropdown (Local vs New worktree) matching the
  app's existing dropdown/combobox primitives, replacing the inline
  segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
  is Local and the picker is hidden, so switching backing no longer causes
  the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
  while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
  backing selector and the reserved Base row.

* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns

- Render the three rows (formStack) at the top, under the "New workspace"
  heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
  field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
  columns, fixed label widths, or reserved horizontal space, with the label
  glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
  (ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
  Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
  showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
  remove the now-gone workspace-create-title-input usage and title field.

* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select

- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.

* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles

- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.

* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing

- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.

* feat(app): rename Sessions to History (clock icon, Agent history header)

- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)

* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved

The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.

* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)

- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending

* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)

- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
2026-06-15 14:23:02 +08:00
Yurui Zhou
eb94b70848 feat: hide dotfiles in file explorer (#1516)
* feat: hide dotfiles in file explorer

* refactor: rename to "hidden files" and read flag from store

Rename the explorer dotfile toggle to show-semantics "hidden files"
so copy and code share one cross-platform vocabulary, and stop
prop-drilling the flag now that it lives in the panel store.

- explorerShowHiddenFiles (default true) replaces explorerHideDotFiles;
  i18n keys + translations switched from "dotfiles" to "hidden files"
- imperative helpers read usePanelStore.getState() instead of taking
  the flag as a parameter; flag stays threaded only through the
  reactive tree-row render path
- drop dead usePanelState exposure; collapse migration double-guard

* fix: address Greptile review on hidden-file path matching

- isHiddenExplorerPath no longer treats ".." segments as hidden
  (only "." was exempt before); add tests for ".." and "../sibling"
- assert the showHiddenFiles i18n key alongside hideHiddenFiles

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-15 12:28:17 +08:00
Mohamed Boudra
f4450cda51 fix(app): prevent Android crash when opening Providers settings (#1537)
Closes #1504
2026-06-15 00:15:47 +08:00
范东辉
43e17c1e7c fix: pass appBaseUrl to generateLocalPairingOffer in daemon pair RPC (#1187)
Threads the configured app.baseUrl through daemonRuntimeConfig to generateLocalPairingOffer in the daemon pair RPC path, so `paseo daemon pair` honors a custom app.baseUrl when the daemon is already running.

Fixes #1494
Fixes #1186
2026-06-15 00:06:52 +08:00
yeshan333
a6ec352f07 Use terminateWithTreeKill for ACP agent child process cleanup (#1460)
* fix(server): use terminateWithTreeKill for ACP agent child process cleanup

ACPAgentSession.close() was using child.kill("SIGTERM") which only
signals the direct child process (e.g. npx), leaving grandchild
processes (sh -c, node qodercli) alive as orphans. Over time these
accumulate and consume significant memory (~330MB each).

Replace all direct child.kill("SIGTERM") calls in ACP agent lifecycle
with terminateWithTreeKill(), which recursively walks the process tree
via /proc on Linux and taskkill /T on Windows. This aligns ACP with
OpenCode, Pi, and Codex providers which already use tree-kill.

Affected code paths:
- ACPAgentSession.close() — main child process
- ACPAgentSession.close() — terminal child processes
- ACPAgentSession.killTerminal()
- ACPAgentSession.releaseTerminal()
- terminateChildProcess() helper (used by closeProbe and init failure)

Removes the now-unused waitForChildExit() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(server): address acp tree kill review

* test(server): cover acp release terminal tree kill

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-14 23:55:27 +08:00
Mohamed Boudra
0d76654ba0 Show activity indicators for terminals and their workspaces (#1507)
* feat: show activity indicators for terminals and their workspaces

A terminal surfaces whether the agent running inside it is working, idle,
or waiting for input as a tab dot, and that state rolls up into the
owning workspace status — so an agent run inside a terminal reads the
same as a native agent. Finishing a turn raises a "Terminal finished"
notification that routes back to the workspace terminal tab.

Activity is reported by the agents, not parsed from terminal output:
Claude and Codex command hooks and an OpenCode plugin post coarse state
to a local daemon endpoint, and a source-agnostic per-terminal tracker
maps it to working/idle/attention. Hook installation is opt-in and off
by default because it edits your real agent config files; enable it under
a host's Terminal settings and Paseo installs the hooks globally, runs
them via `paseo hooks <agent> <event>`, and removes its own hooks when
you opt back out. Installation and reporting are idempotent and
fail-open, so a missing or failing hook never breaks the user's shell.

* Fix terminal activity routing and CI test

Test drift: anchor the provider-leak test to the repo root instead of process.cwd() so it passes under the server workspace CI runner.

Also restore auth-first JSON parsing outside the terminal-activity route and route subdirectory terminal activity to the deepest active workspace.

* Fix workspace resolver regression

Code drift: terminal activity routing needs parent-workspace prefix matching, but agent directory and historical fetches keep exact registered workspace semantics, including archived exact matches.

* Narrow terminal/draft workspace subscriptions to the fields they use

terminal-panel and the draft agent tab subscribed to the whole workspace
descriptor via useWorkspace but read only workspaceDirectory (plus projectKind
in TerminalPanel, plus id in the draft tab), so they re-rendered on any field
change of that workspace (status, diffStat, scripts). Switch them to the
existing useWorkspaceDirectory / useWorkspaceFields hooks so they re-render only
when the fields they actually read change. No behavior change.

* Make TerminalActivity.state forward-compatible

A strict z.enum rejects the whole payload when a newer daemon reports a
terminal activity state this client predates, breaking the protocol's
old-client-parses-new-daemon contract (the activity field has no .catch at
its embedding in messages.ts, so the failure bubbles up). Degrade unknown
states to "idle" (no indicator, no notification) via .catch, matching the
enum-with-catch pattern used elsewhere in the protocol. Add a forward-compat
regression test.
2026-06-14 23:29:14 +08:00
Mohamed Boudra
21deb08673 Make workspace IDs opaque, independent of the filesystem path (#1503)
* Make workspace IDs opaque, independent of the filesystem path

Workspace IDs were the resolved checkout/worktree path, so code could
treat an ID as a path: prefix matching, deriving directories from it,
falling back to a path when a lookup missed. IDs are now opaque -
compared only by exact equality and never used as a path. Anything
path-shaped resolves the workspace record first and reads its cwd.

New workspaces get a generated `wks_` ID; existing path-shaped IDs are
read from disk and never regenerated, so there is no migration and no
change to the wire or persisted schemas. Groundwork for running
multiple workspaces in a single directory.

* Keep attachment scope key stable and align refetch test

The attachment scope key keeps `workspace=` instead of `wsid=` so existing
persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch
test no longer asserts a filter, matching refetch fetching one page and
selecting by id client-side.

* Fix live agent updates in directories without a registered workspace

The opaque-ID work removed buildProjectPlacementForCwd's directory
fallback, so an agent running in a folder with no registered workspace
(e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate
then threw "Workspace not found", the error was swallowed by its catch,
and no agent_update was emitted — live model/thinking switches and status
updates silently stopped. Caught by the live-preferences e2e suite.

The fallback builds a directory-scoped project placement keyed by the
path. That key is a project grouping key (non-git projects group by
path), not a workspace id, so it stays within the opaque-id rule.

* Always run server worktree archive, even without a resolved workspace

Archiving bailed out entirely when the workspace was not found in the
client store, so a race or stale state could make "archive" do nothing
server-side with only a console.warn. The server archive is keyed by
worktreePath, which is always available, so it now runs regardless; only
the optimistic client-side updates (keyed by workspace id) are gated on
the workspace being resolved.

* Expect a directory-scoped placement for unregistered agent dirs

This unit test asserted the no-placement behavior reverted in the live
agent-update fix, which had broken live model/thinking switching. Update
it to expect the directory-scoped placement now emitted for an agent in a
directory with no registered workspace.

* Fix opaque workspace routing in app E2E

* Preserve workspace IDs during partial bootstrap

* Fix archive flows for opaque workspace IDs

* Stop treating opaque workspace IDs as filesystem paths

Workspace IDs are opaque (wks_<hex>), but several call sites still passed
the id where a directory was expected, which broke those flows for opaque
IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git
operations, and server archive/reconcile cleanup keyed git-watch and
subscription teardown by id, leaking that state.

Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
2026-06-14 22:03:27 +08:00
itMrBoy
f352072dac Fix coding-agent terminal shortcuts not working on Windows (#1509)
* Fix coding-agent terminal shortcuts not working on Windows

On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did
nothing, and afterwards even a plain New Terminal stopped working until restart.

Three Windows-specific problems combined:

- A failed node-pty conpty spawn completes asynchronously on its conout worker
  thread; the uncaught exception escaped the per-request try/catch and crashed
  the whole terminal worker, severing every terminal. Add uncaughtException /
  unhandledRejection guards so a single bad spawn keeps the worker alive.
- Profile commands were passed to conpty unresolved. conpty's CreateProcess
  ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat
  shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the
  real path and route .cmd/.bat through cmd.exe /c on Windows.
- winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir
  and aren't on PATH. findExecutable() now falls back to those known install
  locations, so all providers and terminal profiles benefit.

Also surface a failed terminal create to the user via a toast instead of
silently dropping the error.

* Address review: simplify winget scan, narrow worker exception guard

- Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and
  map package dirs directly to the root <name>.exe candidate.
- Remove the unhandledRejection handler in the terminal worker (only the
  uncaughtException path is exercised by conpty's async spawn failure) and
  expand the comment to explain the keep-alive trade-off and the absence of a
  worker restart path.

* Refactor executable resolution out of utils

* Serialize terminal create requests in worker

---------

Co-authored-by: danniel <liminfhu@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-14 19:48:42 +08:00
qer
bf8f2510c8 fix(acp): make ACP/Kimi sessions importable again (#1510)
ACP-based providers (Kimi and other custom ACP agents, Copilot, Cursor) were
absent from the import session picker. Two causes, both in the import path:

1. Never queried. The import refactor (51d1d007) gated provider listing on
   capabilities.supportsSessionListing and set it for claude/codex/opencode/pi
   but not for ACP providers, so agent-manager skipped every ACP provider in
   listImportableSessions — the picker showed 'no sessions' even though
   listImportableSessions is fully implemented. Set supportsSessionListing on
   DEFAULT_ACP_CAPABILITIES and COPILOT_CAPABILITIES. Agents that don't support
   session/list still return an empty list at the runtime probe, so this only
   controls whether the daemon queries them.

2. Ignored cwd. listImportableSessions never forwarded the requested cwd to the
   agent's session/list call, so agents returned globally-recent sessions that
   the import limit could truncate before the current directory's sessions were
   reached. Forward options.cwd as the session/list cwd filter (with the
   pagination cursor) so the agent filters by directory at the source.
2026-06-14 14:52:12 +08:00
Mohamed Boudra
72b67f48e3 Eliminate spiky terminal lag under load (#1500)
* Eliminate spiky terminal lag under load

Terminal output stuttered during heavy output and when the daemon was
busy. Two compounding causes, both confirmed by measurement:

- Every 256KB of output, the daemon dropped pending frames and re-sent a
  full cell-grid snapshot (cloned across IPC, stringified to JSON) — a
  10MB build did this ~40 times. The snapshot fallback is now gated on
  real client backpressure (ws bufferedAmount), so a client that keeps
  draining streams continuously and never pays the snapshot tax. This
  also removes the periodic GC hitch the churn caused.
- Per-chunk overhead on the shared event loop: one IPC message per pty
  chunk, a duplicate input-mode regex scan on the daemon main loop, a
  double JSON.stringify per outbound message, and 16KB string realloc
  per chunk. Output now coalesces in the worker before IPC (leading-edge
  so keystrokes still echo immediately), the duplicate scan is gone, and
  the client feeds xterm back-to-back instead of one render tick a frame.

Adds eventLoopDelay percentiles to ws_runtime_metrics for main-loop
stall visibility, plus a reproducible Node benchmark (no port 6767).

Measured: echo p50 7.7ms to 2.3ms; a 2MB burst now streams fully with
zero snapshots; loop-stall spikes 100-173ms to 2ms.

* Guard trace-level check against partial logger stubs

The isLevelEnabled gate added for the emit() perf fix crashed every
session test that injects a hand-rolled logger stub (10 of them omit
isLevelEnabled). Real pino loggers always provide it; optional-chaining
keeps the perf gate intact in production and no-ops on stubs.

* Make recent-output exit-summary test deterministic

The added test self-exited the child immediately after writing 3000
lines, then asserted the newest line was in the exit summary — but the
summary reads the headless xterm buffer, which parses writes
asynchronously, so a loaded CI runner saw a stale buffer (line-2707 not
line-2999). Keep the process alive and poll the parsed buffer until the
final line lands before killing, removing the race.

* Skip ConPTY-fragile worker terminal tests on Windows

The coalescing and input-mode-preamble tests assert byte-contiguous PTY
output and an exact kitty-escape round-trip. Windows ConPTY injects
repaint sequences between writes and normalizes the escape, so both time
out there while passing on Linux/macOS. Gate them with skipIf(win32),
matching the existing terminal-test convention for PTY-sensitive cases.

* Apply terminal barriers immediately when no writes are pending

A barrier op (snapshot/restore/clear) only needs the sentinel-write gate
to wait out plain writes still parsing in xterm's buffer. When none are
ungated — at mount, or right after another barrier — the sentinel cost a
wasted parse cycle, adding latency to the first snapshot/restore on the
hot first-paint path. Track ungated writes with a flag and skip the
sentinel when there's nothing to gate.

* Fix mobile startup saved-host e2e

* Unskip Windows worker terminal coverage

* Tighten worker coalescing assertion

* Reset terminal ungated writes on unmount

* Fix terminal regressions found in adversarial review

Three issues this PR's terminal changes introduced:

- Worker snapshot could duplicate coalesced output. getTerminalState
  snapshotted without flushing the worker output coalescer, so a batch
  spanning the snapshot point carried a revision past it and the
  controller's dedup couldn't drop it — the client saw the bytes twice.
  Flush before snapshotting.
- Relay clients lost backpressure protection. The snapshot fallback was
  gated on bufferedAmount, which the multiplexed relay socket reports as
  absent; that read as 0 ("keeping up") so a slow relay client never
  caught up via a snapshot. Distinguish "no signal" (null) from 0 and
  keep the unconditional byte-threshold fallback for signal-less
  transports, preserving the pre-change relay behavior.
- Recent-output buffer was no longer a hard cap. A single chunk larger
  than the limit was retained whole; slice its tail.

Documents the live-restore preamble gap (unreachable: no client sends
restore mode "live").

* Skip input-mode preamble test on Windows ConPTY

CI confirmed Windows ConPTY normalizes away the kitty keyboard escape
the child writes, so it never reaches the worker's input-mode tracker
and the preamble stays empty — the test times out. ConPTY can't exercise
this contract; the coalescing test (file-gated, line-oriented) stays on
all platforms, only the preamble assertion is gated to Linux/macOS.
2026-06-13 05:38:58 +00:00
paseo-ai[bot]
442a1b9a5a fix: update lockfile signatures and Nix hash [skip ci] 2026-06-13 02:21:51 +00:00
Mohamed Boudra
ef750e0a85 Unify attachment display and enable file uploads on all platforms (#1501)
Attachment pills were drawn by four separate style sets, so files,
images, GitHub items, and reviews rendered at different heights in the
same tray. Move the frame, the two-line label, and the image thumbnail
into shared components so every attachment lines up at one height,
across both the composer and sent messages.

Sent-message images now open the lightbox, and file uploads work on
iOS and Android via expo-document-picker instead of being gated to
desktop.
2026-06-13 10:18:39 +08:00
Mohamed Boudra
a5c4ba4d89 Fix slash commands in imported session previews (#1502) 2026-06-13 10:16:49 +08:00
paseo-ai[bot]
f4687668e1 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-13 01:50:29 +00:00
515 changed files with 43024 additions and 13160 deletions

View File

@@ -1,6 +1,6 @@
---
name: release-beta
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are release candidates on the beta channel — they carry an in-place changelog entry, don't move the website download target, and publish npm only on the beta dist-tag.
user-invocable: true
---
@@ -8,4 +8,4 @@ user-invocable: true
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
Key rules the doc enforces — each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that gets overwritten at promotion (never leave a stale `-beta.N` heading behind), and betas publish npm only with the explicit `beta` dist-tag.

View File

@@ -189,11 +189,23 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install dependencies with retry
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps
@@ -240,11 +252,23 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Install dependencies with retry
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
run: npm run build:app-deps

View File

@@ -1,5 +1,42 @@
# Changelog
## 0.1.97 - 2026-06-18
### Added
- **Simplify workspace model** — run multiple workspaces on the same code without a worktree, each with its own agents, terminals, and status ([#1539](https://github.com/getpaseo/paseo/pull/1539))
- **Reopen archived workspaces from History** — restore a past workspace even after its worktree was removed
- **Terminals show when their agent is working, idle, or waiting for input** ([#1507](https://github.com/getpaseo/paseo/pull/1507))
- **Attach files to agents on mobile** ([#1501](https://github.com/getpaseo/paseo/pull/1501))
- **Hide dotfiles in the file explorer** ([#1516](https://github.com/getpaseo/paseo/pull/1516) by [@yuruiz](https://github.com/yuruiz))
- **Pin terminal, browser, and new-tab buttons to the tab row and sidebar**
- **Create a new workspace with a keyboard shortcut**
### Improved
- Workspace titles come from your first prompt and are shorter ([#1563](https://github.com/getpaseo/paseo/pull/1563))
- Copy a workspace's branch or path from its hover card
- Terminals stay smooth under heavy output ([#1500](https://github.com/getpaseo/paseo/pull/1500))
- Worktrees are removed when their last workspace is archived ([#1562](https://github.com/getpaseo/paseo/pull/1562))
- Finish notifications include subagent results ([#1558](https://github.com/getpaseo/paseo/pull/1558))
- Cursor lists only models you can select ([#1556](https://github.com/getpaseo/paseo/pull/1556))
- ACP provider catalog updated to the latest registry versions
### Fixed
- Brief daemon slowdowns no longer drop your connection
- Linux AppImage updates no longer hang on quit or delete the app ([#1485](https://github.com/getpaseo/paseo/pull/1485) by [@xpufx](https://github.com/xpufx))
- Opening Providers settings no longer crashes on Android ([#1537](https://github.com/getpaseo/paseo/pull/1537))
- Coding-agent terminal shortcuts work on Windows ([#1509](https://github.com/getpaseo/paseo/pull/1509))
- ACP and Kimi sessions can be imported again ([#1510](https://github.com/getpaseo/paseo/pull/1510) by [@wbxl2000](https://github.com/wbxl2000))
- ACP agents shut down without leaving orphaned processes ([#1460](https://github.com/getpaseo/paseo/pull/1460) by [@yeshan333](https://github.com/yeshan333))
- Imported session previews show clean prompts ([#1502](https://github.com/getpaseo/paseo/pull/1502))
- Local pairing offers use the correct app URL ([#1187](https://github.com/getpaseo/paseo/pull/1187) by [@aibaiiqpl](https://github.com/aibaiiqpl))
- The app no longer freezes from repeated provider re-probes
- Removing a project from the sidebar now removes the project itself instead of leaving it behind
- Workspace shortcut numbers no longer appear for the wrong key ([#1580](https://github.com/getpaseo/paseo/pull/1580) by [@cleiter](https://github.com/cleiter))
- Chats no longer hang when a message contains unmatched backticks ([#1585](https://github.com/getpaseo/paseo/pull/1585) by [@thaning0](https://github.com/thaning0))
## 0.1.96 - 2026-06-13
_This release only fixes an Android issue — desktop users don't need to update._

View File

@@ -39,11 +39,13 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
## Quick start

View File

@@ -58,7 +58,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
## The subagents track
@@ -97,6 +97,8 @@ Closing a subagent's tab on one client doesn't affect other clients' layouts. Th
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
```
`{cwd-with-dashes}` is derived from the agent's filesystem `cwd`. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.
Each agent is a single JSON file. Fields relevant to this doc:
| Field | Type | Meaning |

View File

@@ -87,7 +87,7 @@ code imports from `@getpaseo/client`.
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id (path-shaped today and opaque-encoded for routing), not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
@@ -232,7 +232,38 @@ initializing → idle ⇄ running
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id.
## Right-sidebar boundary: directory-backed vs workspace-owned
Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `local_checkout` workspace on the same folder, or several workspaces opened against one checkout). Model B keeps these distinct: they share everything the directory determines, but nothing the workspace owns. The right-sidebar surfaces split cleanly along this line, and the split is enforced purely by **what each piece of state is keyed by**.
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
| Surface | Key | Source |
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**
| State | Key builder / store | Source |
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
One deliberate non-violation: `AgentFileExplorerState.directories`/`files` cache directory listings inside the `workspaceId`-keyed explorer map. Same-`cwd` workspaces therefore keep duplicate caches, but they can never diverge — both fetch the identical directory via `listDirectory(workspaceRoot, …)`. This is duplication, not leakage, and is left as-is.
## Agent providers

View File

@@ -40,29 +40,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
Each agent is stored as a separate JSON file, grouped by project directory.
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
| Field | Type | Description |
| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | UUID, primary key |
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
| `cwd` | `string` | Working directory the agent operates in |
| `workspaceId` | `string?` | Owning workspace id — the single source of ownership. Every agent is stamped with one at create time; legacy cwd-only records are backfilled once by `migrations/backfill-workspace-id.migration.ts` (the only place a cwd→id mapping exists). Runtime code never infers ownership or status from cwd: status is computed per `workspaceId`, and same-cwd siblings are independent. |
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
| `lastError` | `string?` (nullable) | Last error message, if any |
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -126,6 +127,14 @@ Each agent is stored as a separate JSON file, grouped by project directory.
---
## Runtime-only Terminal Sessions
Terminals are live daemon state, not persisted JSON records. A terminal carries a `workspaceId` while it is running; workspace-scoped terminal lists include only terminals with the matching `workspaceId`. Legacy live terminals without an owner remain visible to unscoped terminal reads but contribute to no workspace status.
Terminal activity contributes to the workspace status bucket **per `workspaceId`**: a working terminal drives `running` onto the workspace it carries only. Same-`cwd` siblings are untouched; terminal visibility is likewise `workspaceId`-scoped.
---
## 2. Daemon Configuration
**Path:** `$PASEO_HOME/config.json`
@@ -386,16 +395,25 @@ emptied duplicate.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------ |
| `workspaceId` | `string` | Primary key |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| Field | Type | Description |
| ------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId |
| `cwd` | `string` | Filesystem path |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The worktree's git branch. Separate from `displayName`/`title`; only worktree workspaces set it. A branch rename writes this and never the name. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
blank the whole History screen, but mutation paths should repair or remove the orphaned state rather
than treating it as valid.
---
@@ -430,6 +448,13 @@ These small files are not validated as full Zod schemas but are persisted under
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
### Keying convention: directory-backed vs workspace-owned
Right-sidebar client state splits on whether it is determined by the directory or owned by the workspace (two workspaces can share one `cwd`). The split is enforced by the cache key, so changing a key changes the sharing semantics — see [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned) for the full table.
- **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores.
- **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path.
### Draft Store
**AsyncStorage key:** `paseo-drafts` (version 2)

View File

@@ -60,12 +60,11 @@ quietly relying on:
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is outside
the composer tree — it does not get that transform unless you apply it
yourself.
- **Transforms.** `KeyboardShiftProvider` owns the canonical keyboard shift
SharedValue, and `useKeyboardShiftStyle()` only adapts that value into
translate/padding styles. The composer and chat content must both read that
provider-owned value. A portal'd popover is outside the composer tree — it
does not get that transform unless you apply it yourself.
- **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Content overlays that must sit below sidebars should
use the current `FloatingPanelPortalHost`.
@@ -86,7 +85,8 @@ with Reanimated worklets the result is not always stable.
If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
**slave the popover's transform to the same `KeyboardShiftProvider` SharedValue
the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
@@ -95,7 +95,10 @@ the keyboard by re-measuring on every frame. Instead,
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed.
lockstep, no re-measurement needed. Do not call
`useReanimatedKeyboardAnimation()` directly for app UI offset policy; Android
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover

View File

@@ -3,8 +3,9 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
@@ -13,6 +14,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.

View File

@@ -33,7 +33,7 @@ Rules that apply to both steps:
There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
## Standard release (patch)
@@ -78,7 +78,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
- **Betas carry a changelog entry.** Beta users read release notes, so each beta updates an in-place `CHANGELOG.md` entry (`## X.Y.Z-beta.N`) that `Release Notes Sync` mirrors into the prerelease body on the tag push. The entry is intermediary: promotion overwrites it in place with the final stable entry, so no `-beta.N` heading is ever left behind. See the Changelog policy section.
Use the beta path when you need to:
@@ -260,7 +260,8 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The website download page points to GitHub's latest published **stable** release.
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
- The website only moves when you publish the final stable release tag like `v0.1.41`.
- The download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
## Fixing a failed release build
@@ -313,20 +314,24 @@ Release notes depend on the changelog heading format. The heading **must** be st
```
## X.Y.Z - YYYY-MM-DD
## X.Y.Z-beta.N - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
## Changelog policy
- `CHANGELOG.md` only lists stable releases. Betas are silent.
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
- `CHANGELOG.md` includes stable releases and the current beta line.
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
- Each subsequent beta updates that same top entry in place — bump the heading (`0.1.60-beta.1``0.1.60-beta.2`) and fold in whatever else landed.
- Stable promotion updates that same entry in place one last time: heading to `0.1.60`, date to the promotion day.
- One entry per version line. The `-beta.N` heading is intermediary — overwrite it, never append. Don't leave stale `-beta.N` entries behind and don't create a duplicate entry per beta.
- It always covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
## Changelog ownership
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
- **The agent running the release writes the changelog entry — beta or stable.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
- Draft the entry from the previous-stable-to-`HEAD` diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it. Each beta refreshes the same entry; promotion refreshes it one last time from the full previous-stable-to-`HEAD` diff.
## Changelog voice
@@ -417,24 +422,31 @@ Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sa
## Changelog scope
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
The changelog always covers **previous-stable-to-`HEAD`**, beta and stable alike:
- **Beta release**: the entry covers `previous stable tag → HEAD`. Update the current in-place beta entry; don't start a fresh one per beta.
- **Stable promotion**: the same entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
Betas are checkpoints along the way; the entry is the single record for the jump from one stable version to the next, and beta users read it in the meantime.
## Completion checklist
### Beta release
- [ ] Working tree is clean and the intended commit is on `main`
- [ ] Update the in-place beta entry in `CHANGELOG.md` (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] GitHub `Release Notes Sync` mirrored the beta entry into the prerelease body
### Stable release (or promotion)
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green

112
docs/terminal-activity.md Normal file
View File

@@ -0,0 +1,112 @@
# Terminal Activity Indicators
Paseo surfaces terminal activity as a tab indicator (the same "running" dot used by agents).
## Current state
Terminal activity is source-agnostic plumbing. `TerminalActivityTracker` holds the current per-terminal state and emits transitions to the manager, worker protocol, websocket subscription, app buckets, dots, and notifications.
The tracker defaults to unknown (`null`). Activity production lives outside terminal stream parsing: agent hook commands report coarse activity to the daemon's local `/api/terminal-activity` endpoint.
## Architecture
```
TerminalSession
├── TerminalActivityTracker one per session
│ ├── set(state) records the latest state
│ └── onChange(snapshot, previous) fires only on resolved-state transitions
└── onActivityChange({ activity, previous }) subscribed in TerminalManager
├── emits terminalsChanged terminal list/tab indicators only
└── subscribeTerminalActivity per-transition stream for notification policy
└── subscribeTerminalWorkspaceContributionChanged workspace status rollup only
```
`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes.
Terminal directory snapshots (`terminalsChanged`) and workspace contribution changes are separate concerns. A title-only change produces a terminal list snapshot but never touches workspace descriptors. A transition that changes the derived workspace bucket (e.g. idle -> working, working -> idle, attention cleared) emits both a terminal list snapshot and a server-internal `TerminalWorkspaceContributionChanged` event, which Session consumes to invalidate every active workspace sharing the owning workspace's `cwd`.
### Transitions carry their own history
Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`).
The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification.
Terminal list visibility is `workspaceId`-scoped: a terminal belongs to the workspace that created it, and same-`cwd` sibling workspaces do not see it in their terminal lists. Terminal status routing starts from that owning workspace, uses the owning workspace's `cwd`, then fans the status bucket out to every active workspace with the same `cwd`.
Path-prefix routing is only a legacy fallback for unowned terminal activity contribution. If a live terminal has no `workspaceId`, the daemon resolves the deepest active parent workspace from the terminal `cwd`, then fans status out to active same-`cwd` siblings of that owner. That fallback contributes status, but it does not make the terminal visible in workspace-scoped terminal lists.
## Hook reporting
Terminals receive four environment variables when the daemon creates the shell:
- `PASEO_TERMINAL_ID`
- `PASEO_ACTIVITY_TOKEN`
- `PASEO_TERMINAL_ACTIVITY_URL`
- `PASEO_HOOK_CLI` — absolute path to the current `paseo` CLI executable.
The generated shell command uses `PASEO_HOOK_CLI` to run the current CLI. `paseo hooks <agent> <event>` then reads the terminal id, token, and activity URL, asks the agent hook provider registry to resolve the event to a coarse activity state, and silently posts `{ terminalId, token, state }` to the activity URL. Missing env, unsupported agents/events, malformed hook input, and daemon/network failures are no-ops so agent hooks never break the user's terminal session.
Claude hook mapping:
- `UserPromptSubmit``running`
- `Stop`, `StopFailure`, `SessionEnd``idle`
- `Notification` with `reason` or `matcher` equal to `idle_prompt``needs-input`
Codex hook mapping:
- `UserPromptSubmit``running`
- `PreToolUse`, `PostToolUse``running`
- `PermissionRequest``needs-input`
- `Stop``idle`
OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events:
- `session.status` with `busy` or `retry``running`
- `session.status` with `idle``idle`
- `permission.asked``needs-input`
- `permission.replied``running`
The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running``state: working`, `idle``state: idle`, and `needs-input``state: idle` with `attentionReason: needs_input`. A `working``idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
## Focus clearing
Client heartbeats include the focused terminal id. When a visible client focuses a terminal with an `attentionReason`, the daemon clears the attention and leaves the terminal idle. Plain idle terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` or `attention` back to `done`.
### Agent hook installation
Installing hooks edits the user's real agent config files, so it is opt-in. The daemon setting
`enableTerminalAgentHooks` (persisted under `daemon.enableTerminalAgentHooks`, default `false`)
gates installation. It is surfaced in the app under a host's **Terminals** settings as "Enable
terminal agent hooks" — "Get notifications and status from terminal agents. This installs hooks in
your agent config files." `applyTerminalAgentHookSetting` reconciles the installed hooks with the
setting: at startup it installs only when enabled; toggling the setting live installs on enable and
removes Paseo's marker-matched hooks on disable. `paseo hooks` keeps working regardless — the gate
only controls whether the daemon writes hooks into agent configs, not whether the CLI can post
activity when the env is present.
When enabled, Paseo installs provider hooks globally:
- Claude hooks are written to `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR/settings.json` when that override is set).
- Codex hooks are written to `~/.codex/hooks.json` (or `CODEX_HOME/hooks.json` when that override is set). Codex supports a native `commandWindows`, so each Paseo hook includes both POSIX and Windows commands. Non-managed Codex hooks are trust-gated by Codex; users may see Codex's hook review prompt before the hook runs.
- OpenCode gets a self-contained plugin at `$XDG_CONFIG_HOME/opencode/plugins/paseo-terminal-activity.js` (or `~/.config/opencode/plugins/paseo-terminal-activity.js` when XDG is unset; `OPENCODE_CONFIG_DIR` still wins when set).
Installation is marker-based/idempotent for config hooks and exact-file/idempotent for the OpenCode plugin. Paseo preserves user hooks, removes only its own marker-matched command hooks, and leaves hooks installed across daemon shutdown. Outside a Paseo terminal they are inert because the command or plugin is gated on `PASEO_TERMINAL_ID`.
Provider variation lives in `AGENT_HOOK_PROVIDERS`: provider id, installed events, config install metadata, and runtime event-to-activity resolution. The daemon calls `installRegisteredAgentHooks()` once; the CLI calls `resolveHookActivity(provider, event, input)`. Adding a provider should add one provider entry and register it in `AGENT_HOOK_PROVIDERS`, without editing the generic CLI command or daemon bootstrap.
The installed hook command keeps the config portable and resolves the CLI at runtime:
```sh
[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks claude <event>
```
Codex also receives the Windows equivalent:
```bat
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
```
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.

View File

@@ -0,0 +1,40 @@
# Terminal performance
How terminal output stays low-latency, what the invariants are, and how to measure before/after any change to the pipeline. Read this before touching anything under `packages/server/src/terminal/` or `packages/app/src/terminal/runtime/`.
## The pipeline
```
pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
→ xterm.write (back-to-back; xterm batches internally)
```
Terminal frames share the daemon main event loop with all agent traffic. The `eventLoopDelay` block in the `ws_runtime_metrics` log line (every 30s in `daemon.log`) is the ground truth for "the daemon is busy" — p99/max there directly bound worst-case terminal frame delay.
## Invariants (the easy-to-break ones)
- **Coalescers are leading+trailing throttles.** The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- **Output coalescing happens in the worker, before IPC.** One `process.send` per pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved.
- **Coalesced output carries the LAST chunk's revision.** Snapshot replay dedup (`replayTerminalOutputAfterSnapshot`) skips buffered output with `revision <= replayRevision`; a merged batch with a lower revision would be wrongly skipped (lost output).
- **The input-mode tracker runs once per process boundary, not per hop.** The worker owns the authoritative tracker; the daemon caches the replay preamble from `getTerminalState` responses and `snapshotReady` messages. Do not reintroduce a per-chunk `feed()` on the daemon main loop.
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
## Measuring
- **Node-only benchmark (fast iteration, server pipeline):** `npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`.
- **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1`
`packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst.
- **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`.
## Known remaining contention (follow-up candidates)
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
- Relay-attached clients pay pure-JS tweetnacl encryption + base64 per frame on the daemon main loop (`packages/relay/src/encrypted-channel.ts`).
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.

View File

@@ -1 +1 @@
sha256-Z5t4Ds05M88Dab1xp4sd2Jwd/NeyQNiVJw3nQQoW6Lw=
sha256-lwIf9Z0uwDdNyAFu+L03pVwlQSuasYWIpn1Fsx3zxJw=

5319
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,18 +1,21 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
clickSessionRow,
closeWorkspaceAgentTab,
createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
fetchAgentArchivedAt,
expectWorkspaceTabVisible,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
@@ -68,7 +71,7 @@ test.describe("Archive tab reconciliation", () => {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await reloadWorkspace(passivePage, surviving.workspaceId);
await expectWorkspaceTabHidden(passivePage, archived.id);
} finally {
await passivePage.close();
@@ -94,7 +97,7 @@ test.describe("Archive tab reconciliation", () => {
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await reloadWorkspace(page, surviving.workspaceId);
await expectWorkspaceTabHidden(page, archived.id);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
@@ -105,25 +108,31 @@ test.describe("Archive tab reconciliation", () => {
}
});
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `reopen-control-${randomUUID().slice(0, 8)}`,
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
});
await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]);
await closeWorkspaceAgentTab(page, archived.id);
await archiveAgentFromDaemon(client, archived.id);
await openSessions(page);
await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title);
await expectArchivedAgentFocused(page, archived.id);
await expect
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
.toBeNull();
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
timeout: 30_000,
});
await expectWorkspaceTabVisible(page, archived.id);
});
});

View File

@@ -0,0 +1,131 @@
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
expectWorkspaceBranch,
openChangesPanel,
switchBranchFromChangesPanel,
} from "./helpers/branch-switcher";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { readWorktreeBranchInfo } from "./helpers/workspace";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
async function renameWorkspaceViaSidebar(
page: Page,
input: { workspaceId: string; title: string },
): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${input.workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
const renameItem = page.getByTestId(
`sidebar-workspace-menu-rename-${serverId}:${input.workspaceId}`,
);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `sidebar-workspace-rename-modal-${serverId}:${input.workspaceId}`;
const renameInput = page.getByTestId(`${modalPrefix}-input`);
await expect(renameInput).toBeVisible({ timeout: 10_000 });
await renameInput.fill(input.title);
await page.getByTestId(`${modalPrefix}-submit`).click();
await expect(renameInput).toHaveCount(0, { timeout: 15_000 });
}
test.describe("Branch switcher", () => {
// The first test after a spec-file switch can fail while the shared daemon
// releases stale sessions from the previous spec; one retry stabilizes it.
test.describe.configure({ retries: 1 });
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-switch-",
repo: { branches: ["main", "dev"] },
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
page,
}) => {
test.setTimeout(90_000);
const serverId = getServerId();
const workspace = await seedWorkspace({
repoPrefix: "branch-coherence-",
repo: { branches: ["main", "dev"] },
});
try {
expect(workspace.workspaceName).toBe("main");
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
const customTitle = "Payments Refactor";
await renameWorkspaceViaSidebar(page, {
workspaceId: workspace.workspaceId,
title: customTitle,
});
// The header shows the custom title verbatim (a plain static title), never a
// branch name, and the branch switcher does not live in the header.
const headerTitle = page
.getByTestId("workspace-header-title")
.filter({ visible: true })
.first();
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
await expectNoBranchSwitcherInWorkspaceHeader(page);
// The diff panel's switcher tracks the real branch ("main"), not the title,
// and switching it checks out the real branch on disk.
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
// The custom title is unaffected by the branch switch.
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
await expect
.poll(
async () =>
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
{ timeout: 30_000 },
)
.toBe("dev");
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -244,7 +244,7 @@ test.describe("Composer attachments", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: workspace.workspaceId,
workspaceId: workspace.workspaceId,
});
await openNewWorkspaceComposer(page, {

View File

@@ -0,0 +1,136 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
// user-confirmed archive proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Removing a project raises a browser confirm; accept it so the
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
// Model B makes the project a first-class parent: archiving its last workspace
// must not delete the project. The per-project "+ New workspace" row is gone;
// the empty project keeps its parent row, and creation stays reachable from the
// project row's own new-worktree icon (git projects) and the global button.
test.describe("Empty project persists", () => {
test("archiving the only workspace keeps the project row with creation still reachable", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectNewWorktreeIcon = page.getByTestId(
`sidebar-project-new-worktree-${workspace.projectId}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays as an empty
// project row. Creation is still reachable: the project row keeps its own
// new-worktree icon (revealed on hover) and the global button persists.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
// The empty project survives a reload — it is persisted, not a transient
// artifact of the just-archived workspace still lingering in memory.
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
} finally {
await workspace.cleanup();
}
});
});
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const reopened = await workspace.client.openProject(workspace.repoPath);
expect(reopened.error).toBeNull();
expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
} finally {
await workspace.cleanup();
}
});
});

View File

@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
@@ -23,7 +23,7 @@ async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, () => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
@@ -34,6 +34,14 @@ async function getAvailablePort(): Promise<number> {
});
}
const RESERVED_LOCAL_PORTS = new Set([
// Default developer daemon.
6767,
// OpenCode's default local server port. Some provider probes can spawn it
// during daemon startup, so the E2E daemon must not choose the same port.
61680,
]);
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
@@ -146,6 +154,67 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
});
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readSupervisorPidLock(home: string): Promise<number | null> {
try {
const content = await readFile(path.join(home, "paseo.pid"), "utf8");
const parsed = JSON.parse(content) as { pid?: unknown };
return typeof parsed.pid === "number" ? parsed.pid : null;
} catch {
return null;
}
}
async function stopProcessByPid(pid: number): Promise<void> {
if (!isProcessRunning(pid)) {
return;
}
try {
process.kill(pid, "SIGTERM");
} catch {
return;
}
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return;
}
await sleep(100);
}
if (isProcessRunning(pid)) {
try {
process.kill(pid, "SIGKILL");
} catch {
return;
}
}
}
async function stopCurrentDaemonFromPidLock(): Promise<void> {
if (!paseoHome) {
return;
}
if (process.env.E2E_DAEMON_PORT === "6767") {
throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767.");
}
const pid = await readSupervisorPidLock(paseoHome);
if (pid === null) {
return;
}
await stopProcessByPid(pid);
}
function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim();
if (!trimmed) {
@@ -486,7 +555,7 @@ async function awaitRelayReady(
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
return port;
}
}
@@ -657,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
await stopCurrentDaemonFromPidLock();
daemonProcess = null;
metroProcess = null;
relayProcess = null;
@@ -677,8 +747,8 @@ export default async function globalSetup() {
ensureRelayBuildArtifact(repoRoot);
await loadEnvTestFile(repoRoot);
const port = await getAvailablePort();
const metroPort = await getAvailablePort();
const port = await getAvailablePortExcluding(new Set());
const metroPort = await getAvailablePortExcluding(new Set([port]));
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));

View File

@@ -14,6 +14,7 @@ export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
workspaceId: string;
}
function buildSeededStoragePayload() {
@@ -34,11 +35,16 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
openProject(cwd: string): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
createAgent(options: {
provider: string;
model: string;
modeId: string;
cwd: string;
workspaceId: string;
title: string;
}): Promise<{ id: string }>;
waitForAgentUpsert(
@@ -52,11 +58,16 @@ export async function createIdleAgent(
client: IdleAgentSeedClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: opened.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -71,6 +82,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: opened.workspace.id,
};
}
@@ -81,6 +93,22 @@ export async function archiveAgentFromDaemon(
await client.archiveAgent(agentId);
}
export async function fetchAgentArchivedAt(
client: {
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
},
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgent(agentId);
return result?.agent.archivedAt ?? null;
}
export function getWorktreeRestoreFeature(client: {
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
}): boolean {
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
@@ -133,7 +161,7 @@ export async function openWorkspaceWithAgents(
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
// 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.
@@ -202,7 +230,7 @@ export async function openSessions(page: Page): Promise<void> {
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
@@ -221,6 +249,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
timeout: 30_000,
});
}
export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 });

View File

@@ -0,0 +1,67 @@
import { expect, type Page } from "@playwright/test";
import { escapeRegex } from "./regex";
// The branch switcher lives in the git diff panel's Changes header (right-side
// ExplorerSidebar), not in the workspace header. It renders as a button whose
// accessible name carries the current branch ("Current branch: <name>. Press to
// switch branch."). Matching on the accessible name keeps these helpers tied to
// what a screen reader user hears, and it proves the panel resolved a real
// checkout directory from the opaque workspace id. Scoping to the changes header
// keeps the matcher unambiguous even when the header is shared with diff actions.
function branchSwitcherTrigger(page: Page, branchName: string) {
return page
.getByTestId("changes-header")
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
.filter({ visible: true })
.first();
}
// Opens the right-side explorer and lands on the Changes tab, where the branch
// switcher and diff live. Git checkouts default to the Changes tab when the
// explorer opens, so this is enough to reveal the switcher on desktop and mobile.
export async function openChangesPanel(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible({
timeout: 30_000,
});
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true }).first();
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
await expect(branchSwitcherTrigger(page, branchName)).toBeVisible({ timeout: 30_000 });
}
export async function switchBranchFromChangesPanel(
page: Page,
input: { from: string; to: string },
): Promise<void> {
await branchSwitcherTrigger(page, input.from).click();
const picker = page.getByTestId("combobox-desktop-container");
await expect(picker).toBeVisible({ timeout: 30_000 });
// The branch switcher combobox renders its options as plain text rows with no ARIA
// role, so filter by the visible branch name and click the matching row. Filtering
// first guarantees a single, unambiguous match.
const search = page.getByPlaceholder("Filter branches...");
await expect(search).toBeVisible({ timeout: 30_000 });
await search.fill(input.to);
const option = picker.getByText(input.to, { exact: true });
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(picker).not.toBeVisible({ timeout: 30_000 });
}
// The workspace header title is a plain static title in Model B; the branch
// switcher must never appear there. Asserting on the header testID keeps this
// honest even as the switcher continues to exist inside the Changes panel.
export async function expectNoBranchSwitcherInWorkspaceHeader(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-header-branch-switcher")).toHaveCount(0);
}

View File

@@ -2,7 +2,8 @@ import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { createTempGitRepo } from "./workspace";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
import { gotoAppShell } from "./app";
import { connectWorkspaceSetupClient } from "./workspace-setup";
import { selectWorkspaceInSidebar } from "./sidebar";
import { getServerId } from "./server-id";
import { waitForTabBar } from "./launcher";
@@ -164,9 +165,10 @@ export async function startRunningMockAgent(
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: opened.workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
@@ -188,7 +190,7 @@ export async function openGithubWorkspace(
const client = await connectWorkspaceSetupClient();
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
await openHomeWithProject(page, repoPath);
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, opened.workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { getE2EDaemonPort } from "./daemon-port";
@@ -47,9 +48,18 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
url: resolveDaemonWsUrl(),
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
clientType: "cli",
appVersion: options.appVersion,
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}

View File

@@ -0,0 +1,157 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createRequire } from "node:module";
import { readFile } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import { getE2EDaemonPort } from "./daemon-port";
/**
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
* persisted state reloads and existing clients can reconnect. This exercises the
* post-restart rehydration path (the daemon rebuilding workspace/agent links
* from disk), which is where the worktree-branch regression lives.
*
* The daemon is owned by Playwright's `globalSetup`, which keeps its child
* handle in module scope we can't reach from a spec. Instead we drive it the
* same way an operator would: read the supervisor PID from
* `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its
* worker and releases the lock), wait for the port to free, then re-spawn the
* supervisor with the identical environment globalSetup used. The relay and
* Metro processes are untouched, so we reuse their already-published ports.
*
* This NEVER targets the developer daemon: the port comes from
* `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E
* home globalSetup created.
*/
function getEnvOrThrow(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is not set (expected from Playwright globalSetup).`);
}
return value;
}
async function readSupervisorPid(paseoHome: string): Promise<number> {
const pidPath = path.join(paseoHome, "paseo.pid");
const content = await readFile(pidPath, "utf8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number") {
throw new Error(`Malformed PID lock at ${pidPath}: ${content}`);
}
return parsed.pid;
}
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve(true);
});
socket.setTimeout(1000, () => {
socket.destroy();
resolve(false);
});
socket.on("error", () => resolve(false));
});
}
async function waitUntil(
predicate: () => Promise<boolean> | boolean,
options: { timeoutMs: number; label: string },
): Promise<void> {
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`);
}
function spawnSupervisor(args: {
paseoHome: string;
port: string;
relayPort: string;
metroPort: string;
editorRecordPath: string;
}): ChildProcess {
const serverDir = path.resolve(__dirname, "../../../..", "packages/server");
// Run the supervisor through the resolved tsx CLI under the current node
// binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
// so resolve the CLI module and load it with node.
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
for (const line of data.toString().split("\n")) {
if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`);
}
});
// Detach our handles so the spawned supervisor outlives this spec process and
// is reaped by globalSetup's cleanup (the original process tree), not us.
child.unref();
return child;
}
export async function restartTestDaemon(): Promise<void> {
const port = getE2EDaemonPort();
const paseoHome = getEnvOrThrow("E2E_PASEO_HOME");
const relayPort = getEnvOrThrow("E2E_RELAY_PORT");
const metroPort = getEnvOrThrow("E2E_METRO_PORT");
const editorRecordPath =
process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl");
const pid = await readSupervisorPid(paseoHome);
process.kill(pid, "SIGTERM");
await waitUntil(() => !isPidRunning(pid), {
timeoutMs: 15_000,
label: `supervisor PID ${pid} to exit`,
});
await waitUntil(async () => !(await isPortListening(Number(port))), {
timeoutMs: 15_000,
label: `port ${port} to free`,
});
spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath });
await waitUntil(async () => isPortListening(Number(port)), {
timeoutMs: 30_000,
label: `restarted daemon to listen on port ${port}`,
});
}

View File

@@ -48,11 +48,17 @@ export interface GhRepoFixture {
owner: string;
name: string;
fullName: string;
defaultBranch: string;
prs: GhPrFixture[];
issues: GhIssueFixture[];
cleanup(): Promise<void>;
}
export interface GhDefaultBranchClone {
path: string;
cleanup(): Promise<void>;
}
function gh(args: string[], opts?: { cwd?: string }): string {
return execFileSync("gh", args, {
cwd: opts?.cwd,
@@ -77,8 +83,9 @@ async function seedPr(args: {
authedUrl: string;
fullName: string;
repoName: string;
defaultBranch: string;
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
const { spec, branch, index, basePath, authedUrl, fullName, repoName, defaultBranch } = args;
const createArgs = [
"pr",
@@ -86,7 +93,7 @@ async function seedPr(args: {
"--title",
spec.title,
"--base",
"main",
defaultBranch,
"--head",
branch,
"--body",
@@ -166,10 +173,11 @@ export async function createTempGithubRepo(options: {
const { category, prs = [], issues = [] } = options;
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
const defaultBranch = "main";
// Bootstrap local git repo
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
git(["init", "-b", "main"], basePath);
git(["init", "-b", defaultBranch], basePath);
git(["config", "user.email", "e2e@paseo.test"], basePath);
git(["config", "user.name", "Paseo E2E"], basePath);
git(["config", "commit.gpgsign", "false"], basePath);
@@ -197,7 +205,7 @@ export async function createTempGithubRepo(options: {
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
git(["add", `pr-${i + 1}.txt`], basePath);
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
git(["checkout", "main"], basePath);
git(["checkout", defaultBranch], basePath);
}
if (branches.length > 0) {
@@ -217,6 +225,7 @@ export async function createTempGithubRepo(options: {
authedUrl,
fullName,
repoName,
defaultBranch,
});
localPaths.push(localPath);
prFixtures.push(fixture);
@@ -232,6 +241,7 @@ export async function createTempGithubRepo(options: {
owner,
name: repoName,
fullName,
defaultBranch,
prs: prFixtures,
issues: issueFixtures,
cleanup: async () => {
@@ -247,3 +257,27 @@ export async function createTempGithubRepo(options: {
},
};
}
export async function cloneGithubRepoDefaultBranchOnly(
repo: Pick<GhRepoFixture, "fullName" | "name" | "defaultBranch">,
): Promise<GhDefaultBranchClone> {
const token = gh(["auth", "token"]);
const authedUrl = `https://x-access-token:${token}@github.com/${repo.fullName}.git`;
const clonePath = await mkdtemp(path.join("/tmp", `${repo.name}-${repo.defaultBranch}-only-`));
execFileSync(
"git",
["clone", "--quiet", "--single-branch", "--branch", repo.defaultBranch, authedUrl, clonePath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
git(["config", "user.email", "e2e@paseo.test"], clonePath);
git(["config", "user.name", "Paseo E2E"], clonePath);
git(["config", "commit.gpgsign", "false"], clonePath);
return {
path: clonePath,
cleanup: async () => {
await rm(clonePath, { recursive: true, force: true });
},
};
}

View File

@@ -6,8 +6,8 @@ import { getServerId } from "./server-id";
// ─── Navigation ────────────────────────────────────────────────────────────
/** 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);
export async function gotoWorkspace(page: Page, workspaceId: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), workspaceId);
await page.goto(route);
await waitForTabBar(page);
}

View File

@@ -5,6 +5,7 @@ import { getServerId } from "./server-id";
export interface MockAgentWorkspace {
agentId: string;
workspaceId: string;
cwd: string;
client: SeedDaemonClient;
cleanup(): Promise<void>;
@@ -32,6 +33,7 @@ export async function seedMockAgentWorkspace(
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: options.modeId ?? "load-test",
model: options.model ?? "ten-second-stream",
@@ -40,6 +42,7 @@ export async function seedMockAgentWorkspace(
});
return {
agentId: agent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
@@ -50,8 +53,8 @@ export async function seedMockAgentWorkspace(
}
}
export function buildAgentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
export function buildAgentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
@@ -59,9 +62,9 @@ export function buildAgentRoute(cwd: string, agentId: string): string {
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
export async function openAgentRoute(
page: Page,
input: { cwd: string; agentId: string },
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(buildAgentRoute(input.cwd, input.agentId));
await page.goto(buildAgentRoute(input.workspaceId, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

View File

@@ -3,26 +3,33 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
import { expectWorkspaceHeader } from "./workspace-ui";
type NewWorkspaceDaemonClient = Pick<
InternalDaemonClient,
| "archivePaseoWorktree"
| "archiveWorkspace"
| "checkoutRefresh"
| "close"
| "connect"
| "createPaseoWorktree"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "openProject"
| "patchDaemonConfig"
>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
workspaceDirectory: string;
}
function requireWorkspace(payload: WorkspacePayload) {
@@ -35,6 +42,39 @@ function requireWorkspace(payload: WorkspacePayload) {
return payload.workspace;
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
}
async function fetchWorkspaceById(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<WorkspaceDescriptor | null> {
const payload = await client.fetchWorkspaces();
return payload.entries.find((entry) => entry.id === workspaceId) ?? null;
}
async function waitForWorkspaceDescriptor(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<WorkspaceDescriptor> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const workspace = await fetchWorkspaceById(client, workspaceId);
if (workspace) {
return workspace;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Workspace descriptor not found: ${workspaceId}`);
}
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
@@ -57,24 +97,23 @@ export async function openProjectViaDaemon(
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath));
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
return openedProjectFromWorkspace(workspace);
}
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceId: string,
workspaceDirectory: string,
options?: { scope?: "workspace" | "worktree" },
): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
const payload = await client.archivePaseoWorktree({
worktreePath: workspaceDirectory,
...(options?.scope !== undefined ? { scope: options.scope } : {}),
});
if (payload.error) {
throw new Error(payload.error.message);
}
if (!payload.success) {
throw new Error(`Failed to archive workspace: ${workspaceId}`);
throw new Error(`Failed to archive workspace: ${workspaceDirectory}`);
}
}
@@ -100,12 +139,7 @@ export async function createWorktreeViaDaemon(
worktreeSlug: input.slug,
});
const workspace = requireWorkspace(payload);
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
};
return openedProjectFromWorkspace(workspace);
}
export async function openNewWorkspaceComposer(
@@ -126,7 +160,7 @@ export async function openNewWorkspaceComposer(
}
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
await page.getByTestId("sidebar-new-workspace").click();
await page.getByTestId("sidebar-global-new-workspace").click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
@@ -164,6 +198,61 @@ export async function clickNewWorkspaceButton(
await submitNewWorkspacePrompt(page, input.prompt);
}
export async function selectNewWorkspaceProject(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
// The isolation trigger renders the active isolation's label ("Local" / "New
// worktree"), so asserting its text proves what the screen currently remembers.
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
local: "Local",
worktree: "New worktree",
};
export async function expectWorkspaceIsolationSelected(
page: Page,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByRole("button", { name: "Workspace isolation" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
}
export async function selectWorkspaceIsolation(
page: Page,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByTestId("workspace-create-isolation-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
// "New worktree" is only listed once the checkout status query confirms the
// selected project is a git repo, so wait for the option to appear before
// clicking it.
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
}
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
@@ -241,43 +330,50 @@ export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: {
serverId: string;
client: NewWorkspaceDaemonClient;
previousWorkspaceId: string;
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
},
): 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 deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
break;
}
await page.waitForTimeout(250);
}
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
await expect
.poll(
() => {
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: 60_000 },
)
.not.toBeNull();
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const workspace = await waitForWorkspaceDescriptor(input.client, workspaceId);
if (input.assertSidebarRow !== false) {
const createdWorkspaceRow = page.getByTestId(
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
`sidebar-workspace-row-${input.serverId}:${workspace.id}`,
);
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
}
if (input.assertHeader !== false) {
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(workspaceId),
title: workspace.name,
subtitle: input.projectDisplayName,
});
}
return { workspaceId };
return {
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
};
}
type WebSocketMessage = string | Buffer;

View File

@@ -0,0 +1,45 @@
import { expect, type Page } from "@playwright/test";
import type { PinnedTabTarget } from "../../src/workspace-pins/target";
import { pinnedTargetKey } from "../../src/workspace-pins/target";
// The new-tab dropdown menu items carry no stable web ARIA role for the inline
// pin toggle (it lives inside a reveal-on-hover slot), so the pins flow is
// addressed through the test ids the feature assigns per target key — the same
// escape-hatch convention the sidebar kebab and tab context menus use.
function pinToggle(page: Page, target: PinnedTabTarget) {
return page.getByTestId(`workspace-pin-toggle-${pinnedTargetKey(target)}`);
}
function menuItemFor(page: Page, target: PinnedTabTarget) {
if (target.kind === "draft") {
return page.getByTestId("workspace-new-tab-menu-agent");
}
return page.getByTestId(`workspace-new-tab-menu-${target.kind}`);
}
export function tabRowPin(page: Page, target: PinnedTabTarget) {
return page.getByTestId(`workspace-pinned-target-${pinnedTargetKey(target)}`);
}
export async function openNewTabMenu(page: Page): Promise<void> {
const trigger = page
.getByTestId("workspace-new-tab-menu-trigger")
.filter({ visible: true })
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
}
// The pin toggle is hidden behind reveal-on-hover (opacity + pointerEvents) on
// desktop web, so the menu item must be hovered before the toggle is clickable.
export async function togglePinFromMenu(page: Page, target: PinnedTabTarget): Promise<void> {
await openNewTabMenu(page);
const item = menuItemFor(page, target);
await expect(item).toBeVisible({ timeout: 10_000 });
await item.hover();
const toggle = pinToggle(page, target);
await expect(toggle).toBeVisible({ timeout: 10_000 });
await toggle.click();
await page.keyboard.press("Escape");
}

View File

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

View File

@@ -14,6 +14,7 @@ export interface AgentHandle {
page: Page;
client: SeedDaemonClient;
agentId: string;
workspaceId: string;
cwd: string;
provider: RewindFlowProvider;
}
@@ -61,14 +62,17 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
function agentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
async function openAgent(
page: Page,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await page.goto(agentRoute(input.workspaceId, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
@@ -174,16 +178,18 @@ export async function launchAgent(input: {
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
workspaceId: opened.workspace.id,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
workspaceId: opened.workspace.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
return handle;
}

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import { readFileSync } from "node:fs";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import { connectDaemonClient } from "./daemon-client-loader";
import { createTempDirectory, createTempGitRepo } from "./workspace";
@@ -23,16 +24,58 @@ export interface SeedDaemonClient {
} | null;
error: string | null;
}>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
| {
kind: "worktree";
cwd?: string;
projectId?: string;
action?: "branch-off" | "checkout";
refName?: string;
baseBranch?: string;
githubPrNumber?: number;
worktreeSlug?: string;
};
title?: string;
}): Promise<{
workspace: { id: string; name: string } | null;
error: string | null;
}>;
/**
* Force the daemon to recompute its git snapshot and diff for a checkout,
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
* working-tree write authoritative before asserting on it in the UI, instead
* of racing the filesystem watcher's debounce.
*/
checkoutRefresh(cwd: string): Promise<{ success: boolean; error: unknown }>;
createTerminal(
cwd: string,
name?: string,
requestId?: string,
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
error: string | null;
}>;
listTerminals(
cwd?: string,
requestId?: string,
options?: { workspaceId?: string },
): Promise<{
terminals: Array<{
id: string;
name: string;
cwd: string;
title?: string;
activity?: TerminalActivity | null;
}>;
error?: string | null;
}>;
createAgent(options: {
provider: string;
cwd: string;
workspaceId?: string;
title?: string;
modeId?: string;
model?: string;
@@ -41,7 +84,18 @@ export interface SeedDaemonClient {
initialPrompt?: string;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
entries: Array<{
agent: {
id: string;
provider: string;
cwd: string;
workspaceId?: string;
model: string | null;
currentModeId: string | null;
status: string;
title?: string | null;
};
}>;
}>;
fetchRecentProviderSessions(options: {
cwd: string;
@@ -67,6 +121,12 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
fetchAgent(
agentId: string,
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
getLastServerInfoMessage(): {
features?: { worktreeRestore?: boolean } | null;
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;

View File

@@ -7,12 +7,55 @@ export async function selectWorkspaceInSidebar(page: Page, workspaceId: string):
await row.click();
}
async function openWorkspaceSidebarKebab(page: Page, workspaceId: string) {
const serverId = getServerId();
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
return serverId;
}
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
await expect(
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
).toBeVisible({ timeout: 30_000 });
}
// The workspace row kebab and its menu items carry no web ARIA role, so the sidebar
// suite addresses them by the stable test ids the app assigns per workspace — the same
// convention the rename flow uses. The kebab only reveals on hover.
export async function clickArchiveWorkspaceMenuItem(
page: Page,
workspaceId: string,
): Promise<void> {
const serverId = await openWorkspaceSidebarKebab(page, workspaceId);
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
}
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean worktree archives with no prompt; if the host reports unsynced work the app
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
// either way.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}
export async function expectWorkspaceAbsentFromSidebar(
page: Page,
workspaceId: string,
): Promise<void> {
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`),
).toHaveCount(0, { timeout: 30_000 });
}
export async function openMobileAgentSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu" }).click();
}

View File

@@ -1,3 +1,4 @@
import type { Route } from "@playwright/test";
import { expect, type Page } from "../fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { wsRoutePatternForPort } from "./daemon-port";
@@ -6,6 +7,7 @@ const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
const E2E_KEY = "@paseo:e2e";
const STORAGE_SEED_HTML = "<!doctype html><html><body>storage seed</body></html>";
interface SavedHostInput {
serverId: string;
@@ -88,9 +90,9 @@ class StartupScenario {
return;
}
// Let the shared fixture create its seed nonce, then opt out of that seed for
// the next navigation so this scenario owns the stored host registry.
await this.page.goto("/");
// Create same-origin storage without booting the app. If the default host runtime
// starts first, it can race this scenario's registry override during CI.
await openStorageSeedDocument(this.page);
const nowIso = new Date().toISOString();
const registry = this.savedHosts.map((host) =>
buildStoredHost({
@@ -132,6 +134,21 @@ class StartupScenario {
}
}
async function openStorageSeedDocument(page: Page): Promise<void> {
await page.route(
"**/*",
async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
body: STORAGE_SEED_HTML,
});
},
{ times: 1 },
);
await page.goto("/");
}
class StartupAssertions {
private readonly page: Page;
@@ -140,6 +157,7 @@ class StartupAssertions {
}
async expectsSavedHostShell(input: { label: string }): Promise<this> {
await this.page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
timeout: 15_000,
});

View File

@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import { createTempGitRepo } from "./workspace";
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
@@ -14,6 +15,16 @@ export interface TerminalInstance {
cwd: string;
}
interface CreateTerminalInput {
name: string;
command?: string;
args?: string[];
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class TerminalE2EHarness {
readonly client: SeedDaemonClient;
readonly tempRepo: TempRepo;
@@ -50,14 +61,58 @@ export class TerminalE2EHarness {
await this.tempRepo.cleanup().catch(() => {});
}
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
async createTerminal(input: CreateTerminalInput): Promise<TerminalInstance> {
const options =
input.command || input.args
? {
command: input.command,
args: input.args,
workspaceId: this.workspaceId,
}
: { workspaceId: this.workspaceId };
const result = await this.client.createTerminal(
this.tempRepo.path,
input.name,
undefined,
options,
);
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
return result.terminal;
}
async waitForTerminalActivity(input: {
terminalId: string;
state: TerminalActivityState | null;
attentionReason?: TerminalActivity["attentionReason"] | null;
timeoutMs?: number;
}): Promise<void> {
const timeoutMs = input.timeoutMs ?? 10_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await this.client.listTerminals(this.tempRepo.path, undefined, {
workspaceId: this.workspaceId,
});
const terminal = result.terminals.find((entry) => entry.id === input.terminalId);
const activity = terminal?.activity ?? null;
const attentionMatches =
input.attentionReason === undefined ||
(activity?.attentionReason ?? null) === input.attentionReason;
if ((activity?.state ?? null) === input.state && attentionMatches) {
return;
}
await sleep(50);
}
const attentionSuffix =
input.attentionReason === undefined
? ""
: ` with attention ${input.attentionReason ?? "none"}`;
throw new Error(
`Timed out waiting for terminal ${input.terminalId} activity state ${input.state ?? "unknown"}${attentionSuffix}`,
);
}
async killTerminal(terminalId: string): Promise<void> {
await this.client.killTerminal(terminalId).catch(() => {});
}

View File

@@ -38,7 +38,7 @@ export async function seedLongMockAgentTimeline(
}
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

View File

@@ -261,7 +261,7 @@ export async function navigateToWorkspaceViaSidebar(
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
targetWorkspacePath: workspaceId,
workspaceId,
});
}

View File

@@ -93,6 +93,15 @@ export async function expectFirstTerminalTabContains(page: Page, text: string):
);
}
export async function expectTerminalTabOpen(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true }).first(),
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
}
export async function sampleWorkspaceTabIds(
page: Page,
options: { durationMs?: number; intervalMs?: number } = {},

View File

@@ -18,29 +18,8 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
.waitFor({ state: "visible", timeout });
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
}
function candidateWorkspaceIds(inputPath: string): string[] {
const trimmed = inputPath.replace(/\/+$/, "");
const candidates = new Set<string>([trimmed]);
if (trimmed.startsWith("/var/")) {
candidates.add(`/private${trimmed}`);
}
if (trimmed.startsWith("/private/var/")) {
candidates.add(trimmed.replace(/^\/private/, ""));
}
return Array.from(candidates);
}
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
const ids = candidateWorkspaceIds(workspacePath).map(
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
);
return page.locator(ids.join(",")).first();
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
}
export async function expectSidebarWorkspaceSelected(input: {
@@ -69,13 +48,13 @@ export async function expectSidebarWorkspaceSelected(input: {
export async function switchWorkspaceViaSidebar(input: {
page: Page;
serverId: string;
targetWorkspacePath: string;
workspaceId: string;
}): Promise<void> {
const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath);
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
timeout: 30_000,
});
@@ -89,11 +68,10 @@ 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 });
await workspaceRowLocator(page, input.serverId, input.workspaceId).waitFor({
state: "visible",
timeout: 60_000,
});
}
export async function expectWorkspaceHeader(

View File

@@ -108,7 +108,14 @@ test.describe("Terminal title propagation", () => {
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
const result = await workspace.client.createTerminal(
workspace.repoPath,
"title-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
@@ -138,7 +145,14 @@ test.describe("Terminal title propagation", () => {
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
const result = await workspace.client.createTerminal(
workspace.repoPath,
"debounce-test",
undefined,
{
workspaceId: workspace.workspaceId,
},
);
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;

View File

@@ -0,0 +1,155 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B entry points into the New Workspace screen. The per-project
// "+ New workspace" sidebar row is gone; the surviving entries are the global
// button (universal) and each git project's own new-worktree icon (preselects
// that project). These specs prove the global entry opens the screen, the
// project icon preselects the right project across the reused 'new' screen, and
// non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
}
test.describe("New workspace entry points", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
await client?.close().catch(() => undefined);
});
test("the global new-workspace button opens the New Workspace screen", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-global-button-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
const globalButton = page.getByTestId("sidebar-global-new-workspace");
await expect(globalButton).toBeVisible({ timeout: 30_000 });
await openGlobalNewWorkspaceComposer(page);
// The screen is up: its project picker trigger is the canonical landmark.
await expect(page.getByTestId("new-workspace-project-picker-trigger")).toBeVisible({
timeout: 30_000,
});
} finally {
await seeded.cleanup();
}
});
test("each project's row icon preselects that project, and the reused screen resets a stale manual choice across projects", async ({
page,
}) => {
const projectA: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-a-" });
const projectB: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-b-" });
const projectC: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-preselect-c-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
// Manually override the selection to C from inside A's screen. This stale
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
// Navigate via B's row icon. B must be preselected — the route project wins
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
} finally {
await projectA.cleanup();
await projectB.cleanup();
await projectC.cleanup();
}
});
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({
page,
}) => {
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
const nonGitProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "entry-iso-nongit-",
git: false,
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (its row has no new-worktree icon).
await openGlobalNewWorkspaceComposer(page);
const trigger = page.getByTestId("new-workspace-project-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
// No git checkout means no worktree isolation choice: the Isolation row is
// absent entirely.
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
timeout: 30_000,
});
} finally {
await gitProject.cleanup();
await nonGitProject.cleanup();
}
});
});

View File

@@ -0,0 +1,96 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceIsolation,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not
// remembered." The isolation choice persists in the create-form preferences
// (FormPreferences.isolation), so it must survive the create→reopen remount:
// creating a worktree workspace navigates away from /new and unmounts it, and
// reopening New Workspace has to still show "New worktree".
test.describe("New workspace isolation memory", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
if (client) {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
// First visit: the screen opens on Local, switch it to New worktree and create.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await selectWorkspaceIsolation(page, "worktree");
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the worktree choice must stick.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "worktree");
} finally {
await tempRepo.cleanup();
}
});
});

View File

@@ -25,10 +25,15 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceIsolation,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
import {
cloneGithubRepoDefaultBranchOnly,
createTempGithubRepo,
hasGithubAuth,
} from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id";
import {
expectSidebarWorkspaceSelected,
@@ -36,7 +41,6 @@ import {
switchWorkspaceViaSidebar,
waitForSidebarHydration,
waitForWorkspaceInSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
interface WorkspaceStatusGroupEvent {
@@ -183,7 +187,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").
test.describe("New workspace flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
@@ -193,14 +197,14 @@ test.describe("New workspace flow", () => {
test.afterEach(async () => {
if (client) {
for (const workspaceId of createdWorktreeIds) {
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeIds.clear();
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
@@ -223,7 +227,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
@@ -233,7 +237,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
workspaceId: secondWorkspace.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId,
@@ -247,7 +251,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
@@ -271,7 +275,7 @@ test.describe("New workspace flow", () => {
slug: `nav-${Date.now()}`,
});
localWorkspaceIds.add(rootWorkspace.workspaceId);
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
createdWorktreeDirectories.add(worktreeWorkspace.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -279,7 +283,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
workspaceId: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
@@ -294,7 +298,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: worktreeWorkspace.workspaceId,
workspaceId: worktreeWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: worktreeWorkspace.workspaceName,
@@ -315,7 +319,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
workspaceId: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
@@ -354,7 +358,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -367,12 +371,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
await expect(page).toHaveURL(
@@ -388,7 +393,7 @@ test.describe("New workspace flow", () => {
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expectWorkspaceHeader(page, {
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
title: createdWorkspace.workspaceName,
subtitle: openedProject.projectDisplayName,
});
@@ -428,7 +433,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -455,12 +460,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
await expect(page).toHaveURL(
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
@@ -507,7 +513,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -523,12 +529,13 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
@@ -560,7 +567,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -576,10 +583,11 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expectWorkspaceStatusGroupEvents({
@@ -618,7 +626,7 @@ test.describe("New workspace flow", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
workspaceId: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
@@ -629,6 +637,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
@@ -640,17 +649,18 @@ test.describe("New workspace flow", () => {
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
expect(existsSync(createdWorkspace.workspaceDirectory)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
worktreePath: createdWorkspace.workspaceDirectory,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceDirectory));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {
@@ -673,6 +683,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -697,6 +708,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -726,6 +738,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
@@ -742,4 +755,50 @@ test.describe("New workspace flow", () => {
await ghRepo.cleanup();
}
});
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
page,
}) => {
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
const ghRepo = await createTempGithubRepo({
category: "new-workspace-pr-worktree",
prs: [{ title: "Checkout PR worktree", state: "open" }],
});
const pr = ghRepo.prs[0]!;
const mainCheckout = await cloneGithubRepoDefaultBranchOnly(ghRepo);
try {
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
await submitNewWorkspaceWithoutPrompt(page);
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: worktree.workspaceDirectory,
});
expect(branchInfo.currentBranch).toBe(pr.branch);
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
} finally {
await mainCheckout.cleanup();
await ghRepo.cleanup();
}
});
});

View File

@@ -9,7 +9,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-sheet-stack-",
title: "Provider sheet stack e2e",
initialPrompt: "Prepare provider sheet stack test agent.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
@@ -58,6 +57,12 @@ async function closeTopSheet(page: Page) {
await page.mouse.up();
}
async function closeSheetByHeaderButton(page: Page, testId: string) {
const sheet = page.getByTestId(testId);
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}
async function expectProviderSettingsVisible(page: Page) {
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
@@ -69,14 +74,15 @@ async function exerciseProviderSettingsStack(page: Page) {
await page.getByRole("button", { name: "Add model" }).click();
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "add-custom-model-sheet");
await expect(page.getByPlaceholder("e.g. openai/gpt-5")).not.toBeVisible({ timeout: 10_000 });
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "provider-diagnostic-sheet");
await expectProviderSettingsVisible(page);
await page.getByRole("button", { name: "Refresh", exact: true }).click();
@@ -87,18 +93,20 @@ test.describe("provider settings bottom-sheet stack", () => {
test("provider settings and children close back through the model selector stack", async ({
page,
}) => {
test.setTimeout(180_000);
const session = await openMockAgentAtMobileBreakpoint(page);
try {
await openProviderSettingsFromModelSelector(page);
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page);
await page.getByRole("button", { name: /Open .* settings/ }).click();
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
await exerciseProviderSettingsStack(page);
await closeTopSheet(page);
await closeSheetByHeaderButton(page, "provider-settings-sheet");
await expectModelSelectorVisible(page);
await closeTopSheet(page);

View File

@@ -1,3 +1,4 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
@@ -14,6 +15,14 @@ async function expectUserMessageCount(page: Page, expected: number): Promise<voi
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
function userMessage(page: Page, text: string): Locator {
return page.getByTestId("user-message").filter({ hasText: text });
}
async function expectUserMessageVisible(page: Page, text: string): Promise<void> {
await expect(userMessage(page, text)).toBeVisible();
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
@@ -29,14 +38,14 @@ test.describe("Rewind sheet", () => {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageVisible(page, firstPrompt);
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await expectUserMessageVisible(page, secondPrompt);
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await page.getByText(firstPrompt, { exact: true }).hover();
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
@@ -46,20 +55,20 @@ test.describe("Rewind sheet", () => {
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expectUserMessageVisible(page, replacementPrompt);
await expect(userMessage(page, secondPrompt)).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
@@ -70,7 +79,7 @@ test.describe("Rewind sheet", () => {
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
@@ -80,7 +89,7 @@ test.describe("Rewind sheet", () => {
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await userMessage(page, replacementPrompt).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
@@ -108,9 +117,9 @@ test.describe("Rewind sheet", () => {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageVisible(page, firstPrompt);
await page.getByText(firstPrompt, { exact: true }).hover();
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
@@ -122,7 +131,7 @@ test.describe("Rewind sheet", () => {
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
await page.getByText(firstPrompt, { exact: true }).hover();
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {

View File

@@ -0,0 +1,120 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectExplorerEntryVisible } from "./helpers/file-explorer";
import { expectNoTerminalTabs, clickFirstTerminalTab } from "./helpers/workspace-tabs";
// Model B: two workspaces can back the SAME directory. What follows from that
// split is the contract these specs pin:
// - The right sidebar (file browser / git changes) reads the directory, so it
// is IDENTICAL across same-directory workspaces.
// - Tabs (agents, terminals) are owned by the workspace, so they are
// INDEPENDENT across same-directory workspaces.
// On desktop the explorer is pinned open; on narrow layouts it must be toggled.
// Open it either way, then select the requested tab.
async function openExplorerTab(page: Page, tab: "files" | "changes"): Promise<void> {
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
if (await openButton.isVisible().catch(() => false)) {
await openButton.click();
}
await page.getByTestId(`explorer-tab-${tab}`).click();
}
async function createSecondWorkspaceOnSameDir(
seeded: SeededWorkspace,
title: string,
): Promise<string> {
const created = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create second workspace for ${seeded.projectId}`);
}
// Both workspaces back the same on-disk checkout.
return created.workspace.id;
}
test.describe("Same-directory workspaces", () => {
test.describe.configure({ timeout: 180_000 });
test("the right sidebar is shared: a directory change shows in both same-dir workspaces", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "same-dir-shared-" });
try {
const secondWorkspaceId = await createSecondWorkspaceOnSameDir(seeded, "Second view");
// Seed an uncommitted change directly in the shared checkout. Because the
// file browser and git diff read the directory, both workspaces must see
// it — neither owns the directory state.
await writeFile(
path.join(seeded.workspaceDirectory, "SHARED_CHANGE.md"),
"# shared change\n",
);
// Make the write authoritative on the daemon before the UI reads it. The
// git status/diff is otherwise refreshed by a debounced filesystem watcher,
// and a loaded CI host can lag that debounce past the assertion window —
// the source of this spec's flakiness. Forcing a refresh (the same path as
// the UI's manual refresh) recomputes the snapshot and diff now, so the
// first subscribe on mount already includes SHARED_CHANGE.md.
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
}
// Workspace A: the new file shows in both the file browser and the git
// changes view.
await gotoWorkspace(page, seeded.workspaceId);
await openExplorerTab(page, "files");
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openExplorerTab(page, "changes");
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
// Workspace B (same directory): the SAME change is visible. The right
// sidebar content does not differ between the two views.
await gotoWorkspace(page, secondWorkspaceId);
await openExplorerTab(page, "files");
await expectExplorerEntryVisible(page, "SHARED_CHANGE.md");
await openExplorerTab(page, "changes");
await expect(
page.getByTestId("git-diff-scroll").getByText("SHARED_CHANGE.md", { exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
} finally {
await seeded.cleanup();
}
});
test("workspace state is independent: a terminal opened in A does not appear in B", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "same-dir-independent-" });
try {
const secondWorkspaceId = await createSecondWorkspaceOnSameDir(seeded, "Independent view");
// Open workspace A and materialize a terminal tab.
await gotoWorkspace(page, seeded.workspaceId);
await clickNewTerminal(page);
await clickFirstTerminalTab(page);
// Workspace B shares the directory but owns its own tabs: it has no
// terminal tab, because the terminal belongs to A.
await gotoWorkspace(page, secondWorkspaceId);
await expectNoTerminalTabs(page);
// Back in A, the terminal is still there — B never absorbed it.
await gotoWorkspace(page, seeded.workspaceId);
await clickFirstTerminalTab(page);
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -74,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
await expectSendBehavior(page, "interrupt");
await pressSettingsToggleShortcut(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.workspaceId));
await waitForTabBar(page);
await expectAgentTabActive(page, secondAgent.id);
@@ -105,13 +105,13 @@ test.describe("Settings toggle tab regression", () => {
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: workspace.repoPath,
workspaceId: workspace.workspaceId,
agentId: firstAgent.id,
});
await openAgentRouteAndExpectFocused({
page,
serverId,
workspaceId: workspace.repoPath,
workspaceId: workspace.workspaceId,
agentId: secondAgent.id,
});

View File

@@ -0,0 +1,172 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { seedMockAgentWorkspace } from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
// Model B sidebar shape: every project — git or non-git, single- or
// multi-workspace — renders as the same expandable parent, the deepest sidebar
// level is the workspace row, and tabs/agents/terminals NEVER appear in the
// sidebar. These specs prove all three invariants end to end.
function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
function projectRow(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectKey}`);
}
function projectNewWorktreeIcon(page: Page, projectKey: string) {
return page.getByTestId(`sidebar-project-new-worktree-${projectKey}`);
}
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
const created = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create second workspace for ${seeded.projectId}`);
}
return created.workspace.id;
}
test.describe("Model B sidebar shape", () => {
test.describe.configure({ timeout: 180_000 });
test("git and non-git projects both render as expandable parents; git keeps a per-row new-worktree icon, the global button covers both", async ({
page,
}) => {
const gitProject = await seedWorkspace({ repoPrefix: "model-b-git-" });
const nonGitProject = await seedWorkspace({ repoPrefix: "model-b-nongit-", git: false });
try {
const gitSecondId = await seedSecondWorkspace(gitProject, "Git second");
const nonGitSecondId = await seedSecondWorkspace(nonGitProject, "Non-git second");
await gotoAppShell(page);
await waitForSidebarHydration(page);
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, gitSecondId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, nonGitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
await expect(workspaceRow(page, nonGitSecondId)).toBeVisible({ timeout: 30_000 });
// The per-project "+ New workspace" row is gone. The git project keeps a
// per-row new-worktree icon (revealed on hover); the non-git project has
// none, since worktree creation needs a git checkout.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
timeout: 30_000,
});
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toHaveCount(0);
// The global new-workspace button is the universal entry — present for both
// kinds regardless of their per-row affordance.
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible({
timeout: 30_000,
});
} finally {
await gitProject.cleanup();
await nonGitProject.cleanup();
}
});
test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => {
const mock = await seedMockAgentWorkspace({
repoPrefix: "model-b-leaf-",
title: "Leaf workspace",
});
try {
// Open the workspace and materialize both an agent tab and a terminal tab.
await gotoWorkspace(page, mock.workspaceId);
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
await clickNewTerminal(page);
await expect(
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
// The deepest level inside the sidebar is the workspace row: no tab,
// agent, or terminal element appears as a sidebar descendant.
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
await expect(workspaceRow(page, mock.workspaceId).first()).toBeVisible({ timeout: 30_000 });
await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0);
await expect(sidebar.locator('[data-testid^="sidebar-agent-row-"]')).toHaveCount(0);
await expect(sidebar.locator('[data-testid^="sidebar-terminal-row-"]')).toHaveCount(0);
} finally {
await mock.cleanup();
}
});
test("status grouping shows only workspace rows and moves a single row when its status changes", async ({
page,
}) => {
const idleProject = await seedWorkspace({ repoPrefix: "model-b-status-idle-" });
const activeMock = await seedMockAgentWorkspace({
repoPrefix: "model-b-status-active-",
title: "Working workspace",
initialPrompt: "stay busy",
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
// Switch to status grouping.
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
// The idle workspace lands in the Done bucket; the busy mock-agent workspace
// lands in the Working bucket. Each workspace is bucketed independently.
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-status-group-running")).toBeVisible({
timeout: 60_000,
});
await expect(workspaceRow(page, idleProject.workspaceId).first()).toBeVisible({
timeout: 30_000,
});
await expect(workspaceRow(page, activeMock.workspaceId).first()).toBeVisible({
timeout: 60_000,
});
// Only workspace rows are shown — no tab/agent/terminal leaves leak into
// the status view.
await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0);
// The busy workspace is grouped under Working, the idle one under Done:
// changing one workspace's status moved only that row.
const workingRows = page.getByTestId("sidebar-status-group-rows-running");
const doneRows = page.getByTestId("sidebar-status-group-rows-done");
await expect(
workingRows.getByTestId(`sidebar-workspace-row-${getServerId()}:${activeMock.workspaceId}`),
).toBeVisible({ timeout: 60_000 });
await expect(
doneRows.getByTestId(`sidebar-workspace-row-${getServerId()}:${idleProject.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
// The busy workspace is NOT also sitting in the Done bucket — only its own
// row moved.
await expect(
doneRows.getByTestId(`sidebar-workspace-row-${getServerId()}:${activeMock.workspaceId}`),
).toHaveCount(0);
} finally {
await idleProject.cleanup();
await activeMock.cleanup();
}
});
});

View File

@@ -1,8 +1,6 @@
import { execSync } from "node:child_process";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { captureWsSessionFrames } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
@@ -32,24 +30,16 @@ async function openRenameModal(page: Page, workspaceId: string) {
return input;
}
// In Model B the workspace title is its identity: renaming sets a custom title
// layered over the derived branch/directory name, and reconciliation never
// touches it. The sidebar row shows the title verbatim — no branch mutation.
test.describe("Sidebar workspace rename", () => {
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
page,
}) => {
test("renaming via kebab sets a custom title that survives reload", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
try {
expect(workspace.workspaceName).toBe("main");
const renameRequests = captureWsSessionFrames(
page,
"checkout.rename_branch.request",
(inner) => ({
branch: String(inner.branch ?? ""),
cwd: String(inner.cwd ?? ""),
}),
);
await gotoAppShell(page);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
@@ -57,56 +47,26 @@ test.describe("Sidebar workspace rename", () => {
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await input.fill("Feature Rename 2");
const customTitle = "Payments Refactor";
await input.fill(customTitle);
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// The title is shown exactly as typed — not slugified into a branch name.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"feature-rename-2",
customTitle,
{ timeout: 15_000 },
);
expect(renameRequests.length).toBeGreaterThan(0);
expect(renameRequests.at(-1)).toEqual({
branch: "feature-rename-2",
cwd: workspace.workspaceDirectory,
});
const currentBranchOnDisk = execSync("git branch --show-current", {
cwd: workspace.repoPath,
stdio: "pipe",
})
.toString()
.trim();
expect(currentBranchOnDisk).toBe("feature-rename-2");
} finally {
await workspace.cleanup();
}
});
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-rename-error-",
repo: { branches: ["taken"] },
});
try {
await gotoAppShell(page);
const input = await openRenameModal(page, workspace.workspaceId);
await expect(input).toHaveValue("main");
await input.fill("taken");
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
const errorNode = page.getByTestId(
workspaceRenameModalTestId(workspace.workspaceId, "error"),
);
await expect(errorNode).toBeVisible({ timeout: 15_000 });
await expect(errorNode).toContainText(/already exists|branch/i);
await expect(input).toBeVisible();
// The custom title is backing metadata on the workspace: a full reload
// re-resolves the descriptor from persistence and must not lose it. This
// exercises the same descriptor resolution reconciliation re-runs against,
// so a reconcile pass cannot overwrite the user's title either.
await page.reload();
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
"main",
customTitle,
{ timeout: 30_000 },
);
} finally {
await workspace.cleanup();

View File

@@ -0,0 +1,181 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { test, expect, type Page } from "./fixtures";
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
type HookActivityState = "running" | "idle" | "needs-input";
type TabStatusBucket = "running" | "needs_input" | "attention" | "none";
const TERMINAL_ACTIVITY_REPORTER_SCRIPT = `
const fs = require("node:fs");
const path = require("node:path");
const triggerDir = process.argv[1];
const states = ["running", "idle", "needs-input"];
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForTrigger(state) {
const triggerPath = path.join(triggerDir, state);
while (!fs.existsSync(triggerPath)) {
await sleep(50);
}
}
async function reportActivity(state) {
const response = await fetch(process.env.PASEO_TERMINAL_ACTIVITY_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
terminalId: process.env.PASEO_TERMINAL_ID,
token: process.env.PASEO_ACTIVITY_TOKEN,
state,
}),
});
if (!response.ok) {
throw new Error("Activity report failed: " + response.status);
}
process.stdout.write("PASEO_ACTIVITY_REPORTED:" + state + "\\n");
}
(async () => {
for (const state of states) {
await waitForTrigger(state);
await reportActivity(state);
}
setInterval(() => {}, 1000);
})().catch((error) => {
console.error(error && error.stack ? error.stack : error);
setInterval(() => {}, 1000);
});
`;
function internalActivityState(state: HookActivityState): "working" | "idle" {
if (state === "running") return "working";
return "idle";
}
function tabStatusBucket(state: HookActivityState): TabStatusBucket {
if (state === "running") return "running";
if (state === "needs-input") return "needs_input";
return "attention";
}
function expectedAttentionReason(state: HookActivityState): "finished" | "needs_input" | null {
if (state === "idle") return "finished";
if (state === "needs-input") return "needs_input";
return null;
}
function terminalTab(page: Page, terminalId: string) {
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
}
async function expectTerminalTabStatus(
page: Page,
terminalId: string,
status: TabStatusBucket,
): Promise<void> {
await expect(
terminalTab(page, terminalId).locator(`[data-status-bucket="${status}"]`),
).toBeVisible({
timeout: 15_000,
});
}
async function focusTerminalTab(page: Page, terminalId: string): Promise<void> {
await terminalTab(page, terminalId).click();
}
class ControlledActivityTerminal {
constructor(
readonly terminal: TerminalInstance,
private readonly harness: TerminalE2EHarness,
private readonly triggerDir: string,
) {}
async report(state: HookActivityState): Promise<void> {
await writeFile(join(this.triggerDir, state), "");
await this.harness.waitForTerminalActivity({
terminalId: this.terminal.id,
state: internalActivityState(state),
attentionReason: expectedAttentionReason(state),
timeoutMs: 15_000,
});
}
}
async function createControlledActivityTerminal(
harness: TerminalE2EHarness,
): Promise<ControlledActivityTerminal> {
const triggerDir = join(harness.tempRepo.path, ".activity-triggers");
await mkdir(triggerDir, { recursive: true });
const terminal = await harness.createTerminal({
name: "activity-source",
command: process.execPath,
args: ["-e", TERMINAL_ACTIVITY_REPORTER_SCRIPT, triggerDir],
});
return new ControlledActivityTerminal(terminal, harness, triggerDir);
}
async function withTerminalActivityFixture(
harness: TerminalE2EHarness,
fn: (input: {
activityTerminal: ControlledActivityTerminal;
focusTerminal: TerminalInstance;
}) => Promise<void>,
): Promise<void> {
const activityTerminal = await createControlledActivityTerminal(harness);
const focusTerminal = await harness.createTerminal({ name: "focus-sink" });
try {
await fn({ activityTerminal, focusTerminal });
} finally {
await harness.killTerminal(activityTerminal.terminal.id);
await harness.killTerminal(focusTerminal.id);
}
}
test.describe("Terminal activity indicators", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-activity-indicators-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("terminal activity follows the tab and clears when the terminal is focused", async ({
page,
}) => {
await withTerminalActivityFixture(harness, async ({ activityTerminal, focusTerminal }) => {
await harness.openTerminal(page, { terminalId: activityTerminal.terminal.id });
await harness.openTerminal(page, { terminalId: focusTerminal.id });
await activityTerminal.report("running");
await expectTerminalTabStatus(page, activityTerminal.terminal.id, tabStatusBucket("running"));
await activityTerminal.report("idle");
await expectTerminalTabStatus(page, activityTerminal.terminal.id, tabStatusBucket("idle"));
await activityTerminal.report("needs-input");
await expectTerminalTabStatus(
page,
activityTerminal.terminal.id,
tabStatusBucket("needs-input"),
);
await focusTerminalTab(page, activityTerminal.terminal.id);
await harness.waitForTerminalActivity({
terminalId: activityTerminal.terminal.id,
state: "idle",
attentionReason: null,
timeoutMs: 15_000,
});
await expectTerminalTabStatus(page, activityTerminal.terminal.id, "none");
});
});
});

View File

@@ -139,6 +139,7 @@ async function measureAppBurstEcho(input: {
: await input.harness.client.createAgent({
provider: "mock",
cwd: input.harness.tempRepo.path,
workspaceId: input.harness.workspaceId,
title: "Large WebSocket payload",
modeId: "load-test",
});

View File

@@ -1,14 +1,14 @@
import { randomUUID } from "node:crypto";
import { test, expect, type Page } from "./fixtures";
import { seedWorkspace } from "./helpers/seed-client";
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
import { getServerId } from "./helpers/server-id";
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
@@ -17,8 +17,13 @@ async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string
await expectWorkspaceTabVisible(page, agent.id);
}
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
}
test.describe("Workspace agent tab rename", () => {
test("right-click rename sends update_agent_request and updates the tab label", async ({
test("right-click rename persists the agent title and updates the tab label", async ({
page,
}) => {
test.setTimeout(120_000);
@@ -32,12 +37,6 @@ test.describe("Workspace agent tab rename", () => {
title: initialTitle,
});
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
agentId: String(inner.agentId ?? ""),
name: String(inner.name ?? ""),
requestId: String(inner.requestId ?? ""),
}));
await openAgentInWorkspace(page, agent);
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
@@ -62,12 +61,7 @@ test.describe("Workspace agent tab rename", () => {
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText(renamed, { timeout: 15_000 });
expect(updateFrames.length).toBeGreaterThan(0);
const lastFrame = updateFrames.at(-1)!;
expect(lastFrame.agentId).toBe(agent.id);
expect(lastFrame.name).toBe(renamed);
expect(lastFrame.requestId.length).toBeGreaterThan(0);
await expect.poll(() => fetchAgentTitle(workspace.client, agent.id)).toBe(renamed);
} finally {
await workspace.cleanup();
}

View File

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

View File

@@ -1,6 +1,6 @@
import { expect, test } from "./fixtures";
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
import { captureWsSessionFrames } from "./helpers/rename";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import {
expectTerminalSurfaceVisible,
focusTerminalSurface,
@@ -9,13 +9,25 @@ import {
waitForTerminalContent,
} from "./helpers/terminal-perf";
interface CreateAgentFrame {
initialPrompt: string | null;
interface CreatedAgentCwdAssertion {
workspaceId: string;
cwd: string | null;
}
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
async function fetchSingleAgentForWorkspace(
workspace: SeededWorkspace,
): Promise<CreatedAgentCwdAssertion | null> {
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
.map((entry) => entry.agent)
.filter((agent) => agent.workspaceId === workspace.workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId: workspace.workspaceId,
cwd: agent.cwd,
};
}
test.describe("Workspace cwd correctness", () => {
@@ -36,37 +48,36 @@ test.describe("Workspace cwd correctness", () => {
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
});
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
test.setTimeout(60_000);
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
const config = (inner.config ?? {}) as Record<string, unknown>;
return {
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
cwd: typeof config.cwd === "string" ? config.cwd : null,
};
});
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
await workspace.navigateTo();
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await clickNewChat(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const message = `cwd draft create ${Date.now()}`;
await expect(composer).toBeEditable({ timeout: 15_000 });
await composer.fill(message);
await composer.press("Enter");
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
timeout: 30_000,
});
await expect
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
.toBe(workspace.repoPath);
await expect
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
.toEqual({
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
});
} finally {
await workspace.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({

View File

@@ -0,0 +1,530 @@
import { test, expect } from "./fixtures";
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
import {
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
type WorkspaceIndicator = "attention" | "done" | "failed" | "loading" | "needs_input" | "running";
interface CreatedAgentAssertion {
workspaceId: string;
provider: string;
cwd: string;
modeId: string | null;
model: string | null;
}
async function fetchCreatedAgentForWorkspace(
seeded: SeededWorkspace,
workspaceId: string,
): Promise<CreatedAgentAssertion | null> {
const result = await seeded.client.fetchAgents({ scope: "active" });
const agents = result.entries
.map((entry) => entry.agent)
.filter((agent) => agent.cwd === seeded.repoPath && agent.workspaceId === workspaceId);
if (agents.length !== 1) {
return null;
}
const [agent] = agents;
return {
workspaceId,
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId,
model: agent.model,
};
}
async function fetchWorkspaceName(
client: NewWorkspaceDaemonClient,
workspaceId: string,
): Promise<string | null> {
const result = await client.fetchWorkspaces();
return result.entries.find((entry) => entry.id === workspaceId)?.name ?? null;
}
async function fetchWorkspaceStatuses(
client: NewWorkspaceDaemonClient,
workspaceIds: string[],
): Promise<Record<string, string | null>> {
const result = await client.fetchWorkspaces();
const entriesById = new Map(result.entries.map((entry) => [entry.id, entry.status]));
return Object.fromEntries(
workspaceIds.map((workspaceId) => [workspaceId, entriesById.get(workspaceId) ?? null]),
);
}
async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promise<string | null> {
const result = await seeded.client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.status ?? null;
}
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
timeout: 30_000,
});
}
function statusGroupRows(page: import("@playwright/test").Page, bucket: string) {
return page.getByTestId(`sidebar-status-group-rows-${bucket}`);
}
async function expectWorkspaceRowInStatusBucket(
page: import("@playwright/test").Page,
input: { rowTestId: string; bucket: string },
) {
await expect(statusGroupRows(page, input.bucket).getByTestId(input.rowTestId)).toBeVisible({
timeout: 30_000,
});
}
async function expectWorkspaceRowNotInStatusBuckets(
page: import("@playwright/test").Page,
input: { rowTestId: string; buckets: string[] },
) {
for (const bucket of input.buckets) {
await expect(statusGroupRows(page, bucket).getByTestId(input.rowTestId)).toHaveCount(0, {
timeout: 5_000,
});
}
}
async function expectWorkspaceRowHasOnlyIndicator(
page: import("@playwright/test").Page,
input: { rowTestId: string; indicator: WorkspaceIndicator },
) {
const row = page.getByTestId(input.rowTestId);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of [
"attention",
"done",
"failed",
"loading",
"needs_input",
"running",
] satisfies WorkspaceIndicator[]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: import("@playwright/test").Page,
input: { rowTestId: string; indicator: WorkspaceIndicator },
) {
const row = page.getByTestId(input.rowTestId);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
).toHaveCount(0, { timeout: 5_000 });
}
test.describe("Workspace model regressions", () => {
let client: NewWorkspaceDaemonClient;
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
await client?.close().catch(() => undefined);
});
test("same-directory workspace does not show agents owned by another workspace", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "workspace-legacy-agents-" });
try {
const ownedAgent = await seeded.client.createAgent({
provider: "mock",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Agent owned by original workspace",
modeId: "load-test",
model: "ten-second-stream",
});
const secondWorkspace = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title: "Fresh workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
await gotoWorkspace(page, secondWorkspace.workspace.id);
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toEqual([]);
await gotoWorkspace(page, seeded.workspaceId);
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${ownedAgent.id}`);
} finally {
await seeded.cleanup();
}
});
test("new agent tab in a same-directory workspace picks a default model for the saved provider", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "workspace-new-agent-model-",
});
try {
const secondWorkspace = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title: "Fresh workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
const workspace = secondWorkspace.workspace;
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({
provider: "mock",
providerPreferences: {
mock: { mode: "load-test" },
},
}),
);
});
await gotoWorkspace(page, workspace.id);
await clickNewChat(page);
await expectComposerVisible(page);
await expectComposerEditable(page);
const prompt = `Create agent default model ${Date.now()}`;
await submitMessage(page, prompt);
await expect(page.getByText("No model is available for the selected provider")).toHaveCount(
0,
);
await expect
.poll(() => fetchCreatedAgentForWorkspace(seeded, workspace.id), {
timeout: 10_000,
})
.toEqual({
workspaceId: workspace.id,
provider: "mock",
cwd: seeded.repoPath,
modeId: "load-test",
model: "five-minute-stream",
});
} finally {
await seeded.cleanup();
}
});
test("local same-directory workspace with an initial prompt shows a generated title", async ({
page,
}) => {
const serverId = getServerId();
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "workspace-generated-local-title-",
});
const previousConfig = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({
metadataGeneration: {
providers: [{ provider: "mock", model: "ten-second-stream" }],
},
});
await gotoWorkspace(page, seeded.workspaceId);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: seeded.workspaceId,
projectDisplayName: seeded.projectDisplayName,
});
const createdRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
);
await expect
.poll(() => fetchWorkspaceName(client, createdWorkspace.workspaceId), {
timeout: 30_000,
})
.toBe("Fix login bug");
await expectWorkspaceHeader(page, {
title: "Fix login bug",
subtitle: seeded.projectDisplayName,
});
await expect(createdRow).toContainText("Fix login bug", { timeout: 30_000 });
expect(createdWorkspace.workspaceDirectory).toBe(seeded.workspaceDirectory);
} finally {
await client
.patchDaemonConfig({
metadataGeneration: {
providers: previousConfig.config.metadataGeneration.providers,
},
})
.catch(() => undefined);
await seeded.cleanup();
}
});
test("running agent in one same-directory workspace only shows the loader on its owning row", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({
repoPrefix: "workspace-same-cwd-running-",
});
try {
const secondWorkspace = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title: "Existing sibling workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
const secondWorkspaceId = secondWorkspace.workspace.id;
const runningAgent = await seeded.client.createAgent({
provider: "mock",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Running agent",
modeId: "load-test",
model: "five-minute-stream",
initialPrompt: "stay running",
});
await seeded.client.waitForAgentUpsert(
runningAgent.id,
(snapshot) => snapshot.status === "running",
15_000,
);
await gotoWorkspace(page, seeded.workspaceId);
await waitForSidebarHydration(page);
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "running",
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: seeded.workspaceId,
projectDisplayName: seeded.projectDisplayName,
});
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expect
.poll(() => fetchAgentStatus(seeded, runningAgent.id), { timeout: 10_000 })
.toBe("running");
await expect
.poll(
() =>
fetchWorkspaceStatuses(client, [
seeded.workspaceId,
secondWorkspaceId,
createdWorkspace.workspaceId,
]),
{ timeout: 10_000 },
)
.toEqual({
[seeded.workspaceId]: "running",
[secondWorkspaceId]: "done",
[createdWorkspace.workspaceId]: "done",
});
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: createdRowTestId,
indicator: "running",
});
await switchSidebarToStatusGrouping(page);
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: firstRowTestId,
bucket: "running",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: secondRowTestId,
bucket: "done",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: createdRowTestId,
bucket: "done",
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: secondRowTestId,
buckets: ["running", "needs_input", "attention"],
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: createdRowTestId,
buckets: ["running", "needs_input", "attention"],
});
} finally {
await seeded.cleanup();
}
});
test("pending permission in one same-directory workspace marks only its own row needing input", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({
repoPrefix: "workspace-same-cwd-permission-",
});
try {
const secondWorkspace = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
title: "Permission sibling workspace",
});
if (!secondWorkspace.workspace) {
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
}
const secondWorkspaceId = secondWorkspace.workspace.id;
const agent = await seeded.client.createAgent({
provider: "mock",
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title: "Permission agent",
modeId: "load-test",
model: "ten-second-stream",
});
await seeded.client.sendAgentMessage(agent.id, "Emit synthetic plan approval.");
const parked = await seeded.client.waitForFinish(agent.id, 15_000);
expect(parked.status).toBe("permission");
await gotoWorkspace(page, seeded.workspaceId);
await waitForSidebarHydration(page);
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "needs_input",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "needs_input",
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
client,
previousWorkspaceId: seeded.workspaceId,
projectDisplayName: seeded.projectDisplayName,
});
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
await expect
.poll(
() =>
fetchWorkspaceStatuses(client, [
seeded.workspaceId,
secondWorkspaceId,
createdWorkspace.workspaceId,
]),
{ timeout: 10_000 },
)
.toEqual({
[seeded.workspaceId]: "needs_input",
[secondWorkspaceId]: "done",
[createdWorkspace.workspaceId]: "done",
});
await expectWorkspaceRowHasOnlyIndicator(page, {
rowTestId: firstRowTestId,
indicator: "needs_input",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: secondRowTestId,
indicator: "needs_input",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
rowTestId: createdRowTestId,
indicator: "needs_input",
});
await switchSidebarToStatusGrouping(page);
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: firstRowTestId,
bucket: "needs_input",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: secondRowTestId,
bucket: "done",
});
await expectWorkspaceRowInStatusBucket(page, {
rowTestId: createdRowTestId,
bucket: "done",
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: secondRowTestId,
buckets: ["running", "needs_input", "attention"],
});
await expectWorkspaceRowNotInStatusBuckets(page, {
rowTestId: createdRowTestId,
buckets: ["running", "needs_input", "attention"],
});
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -0,0 +1,537 @@
import { randomUUID } from "node:crypto";
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
import { test, expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { buildSeededHost } from "./helpers/daemon-registry";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "./helpers/new-workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
interface RestartDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: string;
name: string;
status: string;
workspaceDirectory: string;
}>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
agent: {
id: string;
workspaceId?: string;
status: string;
};
}>;
}>;
}
interface RestartDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion: string;
webSocketFactory: NodeWebSocketFactory;
}
interface SeededRestartHome {
paseoHome: string;
cwd: string;
projectId: string;
projectDisplayName: string;
workspaceA: string;
workspaceB: string;
cleanup(): void;
}
interface StartedDaemon {
port: number;
close(): Promise<void>;
}
function nowIso(): string {
return new Date().toISOString();
}
async function seedRestartHome(): Promise<SeededRestartHome> {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-home-"));
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-cwd-"));
const projectsDir = path.join(paseoHome, "projects");
const agentDir = path.join(paseoHome, "agents", projectDirNameFromCwd(cwd));
mkdirSync(projectsDir, { recursive: true });
mkdirSync(agentDir, { recursive: true });
const projectDisplayName = path.basename(cwd);
const project = {
projectId: `prj_restart_${randomUUID().slice(0, 8)}`,
rootPath: cwd,
kind: "non_git",
displayName: projectDisplayName,
customName: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
};
const workspaceA = {
workspaceId: "wks_restart_a",
projectId: project.projectId,
cwd,
kind: "directory",
displayName: "Original restart workspace",
title: null,
branch: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
};
const workspaceB = {
workspaceId: "wks_restart_b",
projectId: project.projectId,
cwd,
kind: "directory",
displayName: "Restart sibling workspace",
title: null,
branch: null,
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
};
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
writeFileSync(
path.join(projectsDir, "workspaces.json"),
JSON.stringify([workspaceA, workspaceB]),
);
writeFileSync(
path.join(agentDir, `${LEGACY_AGENT_ID}.json`),
JSON.stringify({
id: LEGACY_AGENT_ID,
provider: "codex",
cwd,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: "Legacy cwd-only running agent",
labels: {},
lastStatus: "running",
lastModeId: "default",
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
}),
);
return {
paseoHome,
cwd,
projectId: project.projectId,
projectDisplayName,
workspaceA: workspaceA.workspaceId,
workspaceB: workspaceB.workspaceId,
cleanup: () => {
rmSync(paseoHome, { recursive: true, force: true });
rmSync(cwd, { recursive: true, force: true });
},
};
}
function projectDirNameFromCwd(cwd: string): string {
const { root } = path.win32.parse(cwd);
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
const prefix = sanitizedRoot ? `${sanitizedRoot}-` : "";
if (!withoutRoot) {
return sanitizedRoot || "root";
}
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const startedAt = Date.now();
let lastConnectionError: unknown = null;
while (Date.now() - startedAt < 20_000) {
if (child.exitCode !== null) {
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.end();
resolve();
});
socket.setTimeout(1000, () => {
socket.destroy();
reject(new Error(`Connection timed out to daemon port ${port}`));
});
socket.on("error", reject);
});
return;
} catch (error) {
lastConnectionError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error(
`Restart test daemon did not listen on ${port}. Last error: ${
lastConnectionError instanceof Error
? lastConnectionError.message
: String(lastConnectionError)
}`,
);
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 5000);
try {
await once(child, "exit");
} finally {
clearTimeout(timeout);
}
}
async function startRestartDaemon(input: {
paseoHome: string;
origin: string;
}): Promise<StartedDaemon> {
const port = await getAvailablePort();
if (port === 6767 || String(port) === process.env.E2E_DAEMON_PORT) {
return startRestartDaemon(input);
}
const serverDir = path.resolve(__dirname, "../../server");
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: input.paseoHome,
PASEO_SERVER_ID: SERVER_ID,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: input.origin,
PASEO_RELAY_ENABLED: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
stderr = stderr.split("\n").slice(-40).join("\n");
});
try {
await waitForServer(port, child);
} catch (error) {
await stopProcess(child);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
{ cause: error },
);
}
return {
port,
close: () => stopProcess(child),
};
}
async function connectRestartDaemonClient(port: number): Promise<RestartDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor<
RestartDaemonClientConfig,
RestartDaemonClient
>();
const client = new DaemonClient({
url: `ws://127.0.0.1:${port}/ws`,
clientId: `restart-playwright-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJson = JSON.parse(
readFileSync(path.resolve(__dirname, "../package.json"), "utf8"),
) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing app package version");
}
return packageJson.version;
}
async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: number }) {
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.route(
"**/*",
async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
body: "<!doctype html><html><body>storage seed</body></html>",
});
},
{ times: 1 },
);
await page.goto("/");
const host = buildSeededHost({
serverId: input.serverId,
endpoint: `127.0.0.1:${input.port}`,
label: "restart daemon",
nowIso: nowIso(),
});
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{
daemon: host,
preferences: {
serverId: input.serverId,
provider: "codex",
providerPreferences: {
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
},
},
},
);
}
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
const pathname = new URL(page.url()).pathname;
const match = pathname.match(
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
);
if (!match?.[1]) return null;
return decodeWorkspaceIdFromPathSegment(match[1]);
}
async function expectWorkspaceRowHasOnlyIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
if (indicator === input.indicator) {
await expect(locator).toBeVisible({ timeout: 30_000 });
} else {
await expect(locator).toHaveCount(0);
}
}
}
async function expectWorkspaceRowDoesNotShowIndicator(
page: Page,
input: { serverId: string; workspaceId: string; indicator: string },
) {
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
).toHaveCount(0, { timeout: 5_000 });
}
async function expectWorkspaceRowInStatusBucket(
page: Page,
input: { serverId: string; workspaceId: string; bucket: string },
) {
await page.getByTestId("sidebar-grouping-selector").click();
await page.getByTestId("sidebar-grouping-status").click();
await expect(
page
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`),
).toBeVisible({ timeout: 30_000 });
}
async function fetchLegacyAgent(client: RestartDaemonClient) {
const agents = await client.fetchAgents({ scope: "active" });
return agents.entries.find(hasLegacyAgentId)?.agent ?? null;
}
function hasLegacyAgentId(entry: { agent: { id: string } }): boolean {
return entry.agent.id === LEGACY_AGENT_ID;
}
async function fetchWorkspaceStatuses(
client: RestartDaemonClient,
workspaceIds: string[],
): Promise<Record<string, string>> {
const workspaces = await client.fetchWorkspaces();
const wantedWorkspaceIds = new Set(workspaceIds);
const statuses: Record<string, string> = {};
for (const workspace of workspaces.entries) {
if (wantedWorkspaceIds.has(workspace.id)) {
statuses[workspace.id] = workspace.status;
}
}
return statuses;
}
test.describe("Workspace model restart regressions", () => {
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
page,
baseURL,
}) => {
test.setTimeout(90_000);
const seeded = await seedRestartHome();
const origin = new URL(baseURL ?? "http://localhost").origin;
const daemon = await startRestartDaemon({ paseoHome: seeded.paseoHome, origin });
const serverId = SERVER_ID;
const client = await connectRestartDaemonClient(daemon.port);
try {
await seedBrowserForDaemon(page, { serverId, port: daemon.port });
await expect
.poll(() => fetchLegacyAgent(client))
.toMatchObject({
id: LEGACY_AGENT_ID,
workspaceId: seeded.workspaceA,
status: "running",
});
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await waitForSidebarHydration(page);
await expectWorkspaceRowHasOnlyIndicator(page, {
serverId,
workspaceId: seeded.workspaceA,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
indicator: "running",
});
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await submitNewWorkspaceEmpty(page);
await expect
.poll(() => {
const workspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
return workspaceId && workspaceId !== seeded.workspaceA ? workspaceId : null;
})
.not.toBeNull();
const createdWorkspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
if (!createdWorkspaceId) {
throw new Error(`Expected browser to navigate to created workspace, got ${page.url()}`);
}
await expect
.poll(() =>
fetchWorkspaceStatuses(client, [
seeded.workspaceA,
seeded.workspaceB,
createdWorkspaceId,
]),
)
.toEqual({
[seeded.workspaceA]: "running",
[seeded.workspaceB]: "done",
[createdWorkspaceId]: "done",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: seeded.workspaceB,
indicator: "running",
});
await expectWorkspaceRowDoesNotShowIndicator(page, {
serverId,
workspaceId: createdWorkspaceId,
indicator: "running",
});
await expectWorkspaceRowInStatusBucket(page, {
serverId,
workspaceId: seeded.workspaceA,
bucket: "running",
});
await expectWorkspaceRowInStatusBucket(page, {
serverId,
workspaceId: seeded.workspaceB,
bucket: "done",
});
await expectWorkspaceRowInStatusBucket(page, {
serverId,
workspaceId: createdWorkspaceId,
bucket: "done",
});
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toEqual([]);
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
await expect
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
seeded.cleanup();
}
});
});

View File

@@ -0,0 +1,226 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { gotoWorkspace } from "./helpers/launcher";
import {
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { expectExplorerEntryVisible } from "./helpers/file-explorer";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its isolation (local checkout or
// worktree) is a CHOICE at creation, and creation NEVER dedupes by
// directory. These specs drive the real creation UI (workspace-create-* test
// IDs) to prove a single directory can back any number of workspaces.
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
// On desktop the file explorer is pinned open; on narrower layouts it must be
// toggled first. Open it either way, then select the Files tab.
async function openFilesTab(page: Page): Promise<void> {
const openButton = page.getByRole("button", { name: "Open explorer" }).first();
if (await openButton.isVisible().catch(() => false)) {
await openButton.click();
}
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
}
async function createWorkspaceViaUi(
page: Page,
input: {
project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to
// touch, the isolation is implicitly local.
isolation: "local" | "worktree" | null;
previousWorkspaceId: string;
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, input.project);
if (input.isolation !== null) {
await selectWorkspaceIsolation(page, input.isolation);
}
await submitNewWorkspaceEmpty(page);
return assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client: input.client,
previousWorkspaceId: input.previousWorkspaceId,
projectDisplayName: input.project.projectDisplayName,
assertSidebarRow: false,
assertHeader: false,
});
}
test.describe("Workspace multiplicity creation flow", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
});
test.afterEach(async () => {
await client?.close().catch(() => undefined);
});
test("two Local workspaces share one git checkout and both are independently selectable", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-local-git-",
});
try {
const project = {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
};
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000,
});
const second = await createWorkspaceViaUi(page, {
project,
isolation: "local",
previousWorkspaceId: seeded.workspaceId,
client,
});
// A second workspace was minted on the SAME checkout — creation did not
// dedupe the directory away.
expect(second.workspaceId).not.toBe(seeded.workspaceId);
expect(second.workspaceDirectory).toBe(seeded.workspaceDirectory);
// Both rows live under the same project and are distinct.
const firstRow = page.getByTestId(workspaceRowTestId(seeded.workspaceId));
const secondRow = page.getByTestId(workspaceRowTestId(second.workspaceId));
await expect(firstRow).toBeVisible({ timeout: 30_000 });
await expect(secondRow).toBeVisible({ timeout: 30_000 });
await expect(secondRow).toContainText(second.workspaceName);
// Selecting the second workspace shows the shared checkout's files.
await gotoWorkspace(page, second.workspaceId);
await openFilesTab(page);
await expectExplorerEntryVisible(page, "README.md");
// Selecting the first workspace shows the SAME shared directory data.
await gotoWorkspace(page, seeded.workspaceId);
await openFilesTab(page);
await expectExplorerEntryVisible(page, "README.md");
} finally {
await seeded.cleanup();
}
});
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-worktree-",
});
try {
const project = {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
};
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000,
});
const worktree = await createWorkspaceViaUi(page, {
project,
isolation: "worktree",
previousWorkspaceId: seeded.workspaceId,
client,
});
// The worktree row appears, pointing at a directory distinct from the
// local checkout.
const worktreeRow = page.getByTestId(workspaceRowTestId(worktree.workspaceId));
await expect(worktreeRow).toBeVisible({ timeout: 30_000 });
expect(worktree.workspaceId).not.toBe(seeded.workspaceId);
expect(worktree.workspaceDirectory).not.toBe(seeded.workspaceDirectory);
// The daemon descriptor confirms the worktree kind (○ row).
const descriptor = (await client.fetchWorkspaces()).entries.find(
(entry) => entry.id === worktree.workspaceId,
);
expect(descriptor?.workspaceKind).toBe("worktree");
await client
.archivePaseoWorktree({ worktreePath: worktree.workspaceDirectory })
.catch(() => undefined);
} finally {
await seeded.cleanup();
}
});
test("two Local workspaces appear under the same non-git project", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-local-nongit-",
git: false,
});
try {
const project = {
projectKey: seeded.projectId,
projectDisplayName: seeded.projectDisplayName,
};
await gotoAppShell(page);
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000,
});
const second = await createWorkspaceViaUi(page, {
project,
// Non-git project: no Isolation control, isolation is implicitly local.
isolation: null,
previousWorkspaceId: seeded.workspaceId,
client,
});
expect(second.workspaceId).not.toBe(seeded.workspaceId);
expect(second.workspaceDirectory).toBe(seeded.workspaceDirectory);
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
timeout: 30_000,
});
const secondRow = page.getByTestId(workspaceRowTestId(second.workspaceId));
await expect(secondRow).toBeVisible({ timeout: 30_000 });
await expect(secondRow).toContainText(second.workspaceName);
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -92,7 +92,7 @@ async function openWorkspaceThroughApp(
await switchWorkspaceViaSidebar({
page,
serverId: input.serverId,
targetWorkspacePath: input.workspace.workspaceId,
workspaceId: input.workspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceLocation(page, input);
@@ -209,7 +209,7 @@ test.describe("Workspace navigation regression", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
@@ -365,7 +365,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
@@ -397,7 +397,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
workspaceId: secondWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -461,7 +461,7 @@ test.describe("Workspace navigation regression", () => {
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {

View File

@@ -21,7 +21,7 @@ test.describe("Workspace pane mounting", () => {
title: `pane-remount-${Date.now()}`,
});
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },

View File

@@ -0,0 +1,47 @@
import { test, expect } from "./fixtures";
import { gotoWorkspace } from "./helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { togglePinFromMenu, tabRowPin } from "./helpers/pins";
import { expectTerminalTabOpen } from "./helpers/workspace-tabs";
import type { PinnedTabTarget } from "../src/workspace-pins/target";
const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" };
const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" };
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "workspace-pins-e2e-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test.describe("Pinned tab targets", () => {
test("pinning a target from the dropdown adds its quick-launch button to the tab row, unpinning removes it", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await expect(tabRowPin(page, DRAFT_TARGET)).toHaveCount(0);
await togglePinFromMenu(page, DRAFT_TARGET);
await expect(tabRowPin(page, DRAFT_TARGET)).toBeVisible({ timeout: 10_000 });
await togglePinFromMenu(page, DRAFT_TARGET);
await expect(tabRowPin(page, DRAFT_TARGET)).toHaveCount(0, { timeout: 10_000 });
});
test("clicking the pinned quick-launch button in the tab row opens a terminal tab", async ({
page,
}) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspace.workspaceId);
await expect(tabRowPin(page, TERMINAL_TARGET)).toBeVisible({ timeout: 10_000 });
await tabRowPin(page, TERMINAL_TARGET).click();
await expectTerminalTabOpen(page);
});
});

View File

@@ -1,42 +1,67 @@
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
async function fetchTerminalTitle(
workspace: SeededWorkspace,
terminalId: string,
): Promise<string | null> {
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
const terminal = result.terminals.find((entry) => entry.id === terminalId);
return terminal?.title ?? null;
}
async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<string> {
await expect
.poll(
async () => {
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
return result.terminals.map((entry) => entry.id);
},
{ timeout: 30_000 },
)
.toHaveLength(1);
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
workspaceId: workspace.workspaceId,
});
const terminal = result.terminals[0];
if (!terminal) {
throw new Error("Expected one created terminal");
}
return terminal.id;
}
test.describe("Workspace terminal tab rename", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
test("right-click rename persists the terminal title and updates the tab label", async ({
page,
}) => {
test.setTimeout(60_000);
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
terminalId: String(inner.terminalId ?? ""),
title: String(inner.title ?? ""),
requestId: String(inner.requestId ?? ""),
}));
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
let terminalId: string | null = null;
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewTerminal(page);
terminalId = await waitForCreatedTerminalId(workspace);
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
await expect(tab).toBeVisible({ timeout: 15_000 });
await tab.click({ button: "right" });
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
timeout: 10_000,
});
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
await expect(renameItem).toBeVisible({ timeout: 10_000 });
await renameItem.click();
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
const input = renameModalInput(page, modalPrefix);
await expect(input).toBeVisible({ timeout: 10_000 });
@@ -45,12 +70,14 @@ test.describe("Workspace terminal tab rename", () => {
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
expect(renameFrames.length).toBeGreaterThan(0);
const lastFrame = renameFrames.at(-1)!;
expect(lastFrame.terminalId).toBe(terminal.id);
expect(lastFrame.title).toBe("My Renamed Terminal");
expect(lastFrame.requestId.length).toBeGreaterThan(0);
});
await expect
.poll(() => fetchTerminalTitle(workspace, terminalId!))
.toBe("My Renamed Terminal");
} finally {
if (terminalId) {
await workspace.client.killTerminal(terminalId).catch(() => undefined);
}
await workspace.cleanup();
}
});
});

View File

@@ -0,0 +1,140 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Dialog, Page } from "@playwright/test";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
async function seedRiskyWorktree(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
worktreeDirectory: string,
): Promise<void> {
// The daemon only reports unpushed commits when the branch has a configured
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
// worktree branch at its current head first so it tracks origin with 0 ahead,
// then add the local commit below that becomes the single unpushed commit.
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreeDirectory,
stdio: "pipe",
})
.toString()
.trim();
execSync(`git push -u origin ${JSON.stringify(branch)}`, {
cwd: worktreeDirectory,
stdio: "ignore",
});
const committedFile = path.join(worktreeDirectory, "UNPUSHED.md");
await writeFile(committedFile, "# unpushed\n");
execSync(`git add ${JSON.stringify(path.basename(committedFile))}`, {
cwd: worktreeDirectory,
stdio: "ignore",
});
execSync('git commit -m "Add unpushed change"', {
cwd: worktreeDirectory,
stdio: "ignore",
});
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
await writeFile(dirtyFile, "# dirty\n");
const refreshed = await client.checkoutRefresh(worktreeDirectory);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
}
}
// The archive confirmation is a synchronous web `window.confirm()`. The click that
// opens it does not resolve until the dialog is answered, so the handler must
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
// the click waits for an answer that is gated behind that same click.
async function clickArchiveAndAnswerWarning(
page: Page,
workspaceId: string,
answer: "accept" | "dismiss",
): Promise<Dialog> {
let warning: Dialog | undefined;
page.once("dialog", (dialog) => {
warning = dialog;
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
});
await clickArchiveWorkspaceMenuItem(page, workspaceId);
if (!warning) {
throw new Error("Expected an archive confirmation dialog, but none was shown.");
}
return warning;
}
test.describe("Worktree archive risk warning", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ retries: 1, timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("wt-archive-risk-", { withRemote: true });
});
test.afterEach(async () => {
for (const directory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-risk-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await seedRiskyWorktree(client, worktree.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
expect(firstWarning.type()).toBe("confirm");
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(firstWarning.message()).toContain("Uncommitted changes");
expect(firstWarning.message()).toContain("1 unpushed commit");
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
).toBeVisible({ timeout: 10_000 });
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
expect(secondWarning.message()).toContain("Uncommitted changes");
expect(secondWarning.message()).toContain("1 unpushed commit");
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
});
});

View File

@@ -0,0 +1,59 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Worktree archive", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ retries: 1, timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("wt-archive-");
});
test.afterEach(async () => {
for (const directory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
});

View File

@@ -0,0 +1,95 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
import { restartTestDaemon } from "./helpers/daemon-restart";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
test.describe("Worktree restore after daemon restart", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ retries: 0, timeout: 180_000 });
test.beforeEach(async () => {
client = await connectSeedClient();
worktreeClient = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("wt-restart-");
});
test.afterEach(async () => {
for (const directory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({
page,
}) => {
const serverId = getServerId();
// A paseo worktree is cut on its own branch named after the slug, and the
// worktree workspace is displayed under the same name. These are the values
// the History table cells must show after restore — never "main".
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: worktreeSlug,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
title: `restart-restore-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
// Archive through the default production path (no scope): the worktree dir is deleted.
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
// Bounce the isolated test daemon on the SAME home and port so it rebuilds
// all workspace/agent links from persisted state. Then reconnect both clients.
await client.close().catch(() => undefined);
await worktreeClient.close().catch(() => undefined);
await restartTestDaemon();
client = await connectSeedClient();
worktreeClient = await connectNewWorkspaceDaemonClient();
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowArchived(page, agent.title);
// KEY ASSERTION: reproduce the screenshot state. Right after the daemon
// restart, with NO restore and NO row click, the rendered History table cells
// (fed by each agent row's projectPlacement via fetch_agent_history) must read
// the worktree branch and the worktree workspace name — never "main".
const branchCell = page.getByTestId(`agent-row-branch-${serverId}-${agent.id}`);
const workspaceCell = page.getByTestId(`agent-row-workspace-${serverId}-${agent.id}`);
await expect(branchCell).toBeVisible({ timeout: 60_000 });
await expect(branchCell).toHaveText(worktreeSlug, { timeout: 60_000 });
await expect(workspaceCell).toHaveText(worktree.workspaceName, { timeout: 60_000 });
});
});

View File

@@ -0,0 +1,127 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
createIdleAgent,
expectSessionRowArchived,
fetchAgentArchivedAt,
openSessions,
} from "./helpers/archive-tab";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Worktree restore", () => {
let client: Awaited<ReturnType<typeof connectSeedClient>>;
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectSeedClient();
worktreeClient = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("wt-restore-");
});
test.afterEach(async () => {
for (const directory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
await client?.close().catch(() => undefined);
await worktreeClient?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await archiveAgentFromDaemon(client, agent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowArchived(page, agent.title);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
// The History list is a cached react-query snapshot, so the cleared Archived
// badge only renders after a cold refetch. Reload to remount the query fresh.
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page);
const row = page
.locator('[data-testid^="agent-row-"]')
.filter({ hasText: agent.title })
.first();
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).not.toContainText("Archived", { timeout: 30_000 });
});
test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(worktreeClient, tempRepo.path);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
});
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
// Archive through the default production path the sidebar uses (no explicit
// scope). With the restore prune fix, this default path frees the kept branch
// so the daemon can re-check-out the worktree on restore.
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowArchived(page, agent.title);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
});
});

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# Common helpers for native-terminal Maestro flows. Each per-flow harness
# sources this file and calls run_flow_with_setup.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
MAESTRO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
SIMULATOR_UDID="${PASEO_MAESTRO_UDID:-47FB40E1-3304-4516-B8BC-D75853EF1B47}"
log() {
echo "[native-terminal-harness] $*" >&2
}
terminal_ids() {
cd "$REPO_ROOT"
npm run --silent cli -- terminal ls --all --json 2>/dev/null \
| node -e 'const data=[]; process.stdin.on("data", c=>data.push(c)); process.stdin.on("end", ()=>{ const list=JSON.parse(data.join("")); for (const terminal of list) console.log(terminal.id); });'
}
send_to_terminal() {
local terminal_id="$1"
shift
cd "$REPO_ROOT"
npm run --silent cli -- terminal send-keys "$terminal_id" "$@"
}
set_simulator_clipboard() {
local text="$1"
if xcrun simctl pbcopy "$SIMULATOR_UDID" <<< "$text" 2>/dev/null; then
log "Set simulator clipboard"
else
log "WARNING: could not set simulator clipboard; flow may fail"
fi
}
read_simulator_clipboard() {
xcrun simctl pbpaste "$SIMULATOR_UDID" 2>/dev/null || true
}
capture_terminal() {
local terminal_id="$1"
cd "$REPO_ROOT"
npm run --silent cli -- terminal capture --scrollback "$terminal_id"
}
write_temp_flow() {
local name="$1"
local flow_dir="${TMPDIR:-/tmp}/paseo-native-terminal-maestro-flows"
mkdir -p "$flow_dir"
local flow_file
flow_file="$(mktemp "$flow_dir/$name.XXXXXX")"
cat >"$flow_file"
printf '%s\n' "$flow_file"
}
maestro_bin() {
local bin
bin="$(command -v maestro || true)"
if [[ -z "$bin" ]]; then
log "ERROR: maestro not found on PATH"
return 1
fi
printf '%s\n' "$bin"
}
run_maestro_flow() {
local flow_file="$1"
local bin
bin="$(maestro_bin)"
local output_dir
output_dir="$(mktemp -d "${TMPDIR:-/tmp}/paseo-native-terminal-maestro-output.XXXXXX")"
log "Running Maestro flow $(basename "$flow_file") with screenshots in $output_dir"
(cd "$output_dir" && "$bin" test --udid "$SIMULATOR_UDID" "$flow_file") >&2
}
run_maestro_flow_allow_failure() {
local flow_file="$1"
local bin
bin="$(maestro_bin)"
local output_dir
output_dir="$(mktemp -d "${TMPDIR:-/tmp}/paseo-native-terminal-maestro-output.XXXXXX")"
log "Running Maestro probe $(basename "$flow_file") with screenshots in $output_dir"
set +e
(cd "$output_dir" && "$bin" test --udid "$SIMULATOR_UDID" "$flow_file") >&2
local status=$?
set -e
return "$status"
}
assert_terminal_surface_visible() {
local flow_file
flow_file="$(write_temp_flow terminal-visible <<YAML
appId: $APP_ID
---
- assertVisible:
id: "terminal-virtual-keyboard"
YAML
)"
run_maestro_flow_allow_failure "$flow_file"
}
ensure_terminal_surface_visible() {
if assert_terminal_surface_visible; then
return
fi
log "Terminal surface is not visible; trying to create/open a terminal from the workspace header"
local flow_file
flow_file="$(write_temp_flow ensure-terminal <<YAML
appId: $APP_ID
---
- runFlow:
when:
visible:
id: "sidebar-sessions"
commands:
- tapOn:
id: "sidebar-close"
- waitForAnimationToEnd
- tapOn:
id: "workspace-header-menu-trigger"
- tapOn:
id: "workspace-header-new-terminal"
- extendedWaitUntil:
visible:
id: "terminal-virtual-keyboard"
timeout: 30000
YAML
)"
run_maestro_flow "$flow_file"
}
assert_text_visible() {
local text="$1"
local flow_file
flow_file="$(write_temp_flow text-visible <<YAML
appId: $APP_ID
---
- extendedWaitUntil:
visible: "$text"
timeout: 8000
YAML
)"
run_maestro_flow_allow_failure "$flow_file"
}
visible_terminal_id() {
ensure_terminal_surface_visible
local ids
ids="$(terminal_ids)"
if [[ -z "$ids" ]]; then
log "ERROR: no terminals found via 'paseo terminal ls --all'"
return 1
fi
local terminal_id
while IFS= read -r terminal_id; do
[[ -n "$terminal_id" ]] || continue
local marker="VT_$RANDOM"
log "Probing visible terminal candidate $terminal_id"
send_to_terminal "$terminal_id" "clear" Enter >/dev/null
send_to_terminal "$terminal_id" "echo $marker" Enter >/dev/null
if assert_text_visible "$marker"; then
printf '%s\n' "$terminal_id"
return
fi
done <<< "$ids"
log "ERROR: no listed terminal matched the visible terminal surface"
return 1
}
assert_terminal_output_contains() {
local terminal_id="$1"
local marker="$2"
local output
output="$(capture_terminal "$terminal_id")"
if [[ "$output" == *"$marker"* ]]; then
log "PASS: terminal output contains $marker"
return
fi
log "FAIL: terminal output did not contain $marker"
return 1
}
assert_terminal_output_count_at_least() {
local terminal_id="$1"
local marker="$2"
local minimum="$3"
local output
output="$(capture_terminal "$terminal_id")"
local count
count="$(MARKER="$marker" OUTPUT="$output" node -e 'const marker = process.env.MARKER ?? ""; const output = process.env.OUTPUT ?? ""; process.stdout.write(String(marker ? output.split(marker).length - 1 : 0));')"
if (( count >= minimum )); then
log "PASS: terminal output contains $marker $count times"
return
fi
log "FAIL: terminal output contained $marker $count times, expected at least $minimum"
return 1
}
assert_maestro_input_does_not_reach_terminal() {
local terminal_id="$1"
local marker="$2"
local flow_file
flow_file="$(write_temp_flow no-focus-input-probe <<YAML
appId: $APP_ID
---
- inputText: "$marker"
YAML
)"
if run_maestro_flow_allow_failure "$flow_file"; then
log "Maestro inputText completed; checking that terminal did not receive $marker"
else
log "Maestro inputText had no focused terminal target; checking output anyway"
fi
local output
output="$(capture_terminal "$terminal_id")"
if [[ "$output" == *"$marker"* ]]; then
log "FAIL: terminal received no-focus probe $marker"
return 1
fi
log "PASS: terminal did not receive no-focus probe $marker"
}
# Default per-flow setup: find the visible terminal and optionally seed it.
# Callers can override NATIVE_TERMINAL_ID before sourcing.
: "${NATIVE_TERMINAL_ID:=}"
require_terminal_id() {
if [[ -z "$NATIVE_TERMINAL_ID" ]]; then
NATIVE_TERMINAL_ID="$(visible_terminal_id)"
fi
if [[ -z "$NATIVE_TERMINAL_ID" ]]; then
log "ERROR: no visible active terminal found"
return 1
fi
log "Using terminal $NATIVE_TERMINAL_ID"
}

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding shell history for history-arrows flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo MAESTRO_HISTORY_OK-1" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-history-arrows.yaml"

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding scrollback for scroll-no-focus flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
# Emit a top marker, a screenful of filler, and a bottom marker so scrolling
# up reveals the top marker while the bottom marker is initially visible.
seed_command='{ echo SCROLL_TOP_MARKER; for i in $(seq 1 80); do echo filler-$i; done; echo SCROLL_BOTTOM_MARKER; }'
send_to_terminal "$NATIVE_TERMINAL_ID" "$seed_command" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-scroll-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SCROLL_SHOULD_NOT_TYPE_$RANDOM"

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding selectable text for selection-drag flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo SELECT_DRAG_OK" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-selection-drag-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SELECTION_DRAG_SHOULD_NOT_TYPE_$RANDOM"
log "Reading simulator clipboard"
CLIPBOARD="$(read_simulator_clipboard)"
if [[ -n "$CLIPBOARD" && ("SELECT_DRAG_OK" == "$CLIPBOARD"* || "$CLIPBOARD" == *"SELECT_DRAG_OK"*) ]]; then
log "PASS: Copy wrote selected terminal marker text: $CLIPBOARD"
else
log "FAIL: Copy did not write selected terminal marker text: $CLIPBOARD"
exit 1
fi

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Ensuring terminal is visible for sidebar-swipe flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo SIDEBAR_SWIPE_OK" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-sidebar-swipe-does-not-focus.yaml"
assert_maestro_input_does_not_reach_terminal \
"$NATIVE_TERMINAL_ID" \
"SIDEBAR_SWIPE_SHOULD_NOT_TYPE_$RANDOM"

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Clearing terminal for tap-focus flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
run_maestro_flow "$MAESTRO_DIR/native-terminal-tap-focus-keyboard.yaml"
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_TAP_FOCUS_OK"

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./native-terminal-maestro-common.sh
source "$SCRIPT_DIR/native-terminal-maestro-common.sh"
require_terminal_id
log "Seeding terminal virtual keyboard UX flow"
send_to_terminal "$NATIVE_TERMINAL_ID" "clear" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "printf '\n\n\n\nMAESTRO_COPY_OK\n'" Enter
send_to_terminal "$NATIVE_TERMINAL_ID" "echo MAESTRO_VK_HISTORY_OK" Enter
set_simulator_clipboard "echo MAESTRO_PASTE_OK"
run_maestro_flow "$MAESTRO_DIR/native-terminal-virtual-keyboard-ux.yaml"
assert_terminal_output_count_at_least "$NATIVE_TERMINAL_ID" "MAESTRO_VK_HISTORY_OK" 2
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_TOGGLE_INPUT_OK"
assert_terminal_output_contains "$NATIVE_TERMINAL_ID" "MAESTRO_PASTE_OK"
log "Reading simulator clipboard after Copy"
CLIPBOARD="$(read_simulator_clipboard)"
if [[ -n "$CLIPBOARD" && ("MAESTRO_COPY_OK" == "$CLIPBOARD"* || "$CLIPBOARD" == *"MAESTRO_COPY_OK"*) ]]; then
log "PASS: Copy wrote selected terminal text: $CLIPBOARD"
else
log "FAIL: Copy did not write selected terminal text: $CLIPBOARD"
exit 1
fi

View File

@@ -0,0 +1,29 @@
appId: sh.paseo.debug
---
# Self-contained history/arrows flow.
# Precondition: the harness has run `echo MAESTRO_HISTORY_OK-1` in the
# terminal, so shell history contains the command.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# The seeded command output should already be visible.
- assertVisible:
text: "MAESTRO_HISTORY_OK-1"
# Tap the virtual up arrow to recall the last command.
- tapOn:
id: "terminal-key-up"
# Submit it again.
- tapOn:
id: "terminal-key-enter"
# The command ran a second time, so the marker is still visible.
- extendedWaitUntil:
visible: "MAESTRO_HISTORY_OK-1"
timeout: 15000
- takeScreenshot: native-terminal-history-arrows

View File

@@ -0,0 +1,38 @@
appId: sh.paseo.debug
---
# Self-contained scroll-no-focus flow.
# Precondition: the harness has filled scrollback with a top marker, filler
# lines, and a bottom marker.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# The bottom marker should be visible before scrolling.
- assertVisible:
text: "SCROLL_BOTTOM_MARKER"
# Scroll up in history by dragging downward across the terminal surface.
- swipe:
start: "50%,40%"
end: "50%,80%"
duration: 400
- waitForAnimationToEnd
- swipe:
start: "50%,40%"
end: "50%,80%"
duration: 400
- waitForAnimationToEnd
# Scrolling should reveal the older top marker without focusing the terminal.
- assertVisible:
text: "SCROLL_TOP_MARKER"
- assertVisible:
id: "terminal-follow-bottom"
# The scroll gesture must not show the OS keyboard. The harness also probes with
# Maestro inputText and fails if text reaches the terminal after this gesture.
- assertNotVisible: "return"
- takeScreenshot: native-terminal-scroll-no-focus

View File

@@ -0,0 +1,45 @@
appId: sh.paseo.debug
---
# Self-contained long-press selection flow.
# Precondition: the harness has printed SELECT_DRAG_OK in the terminal.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# Selection starts with long press.
- longPressOn:
point: "1%,21%"
- waitForAnimationToEnd
- assertVisible:
id: "terminal-copy"
enabled: true
# Once selection is active, horizontal drag extends selection instead of opening
# navigation or focusing the OS keyboard.
- swipe:
start: "1%,21%"
end: "100%,21%"
duration: 1200
- waitForAnimationToEnd
# The active-selection drag must not open the sidebar.
- assertNotVisible:
id: "sidebar-sessions"
# The active-selection drag must not show the OS keyboard. The harness also
# probes with Maestro inputText and fails if text reaches the terminal after
# this gesture.
- assertNotVisible: "return"
# Copy remains usable after extending the selection.
- assertVisible:
id: "terminal-copy"
enabled: true
- tapOn:
id: "terminal-copy"
- takeScreenshot: native-terminal-selection-drag
# The harness will read the simulator clipboard and verify SELECT_DRAG_OK.

View File

@@ -0,0 +1,40 @@
appId: sh.paseo.debug
---
# Self-contained sidebar-swipe flow.
# Precondition: the harness has ensured the terminal is visible and the
# sidebar is closed.
- assertVisible:
id: "terminal-virtual-keyboard"
- hideKeyboard
- waitForAnimationToEnd
# Sidebar should start closed.
- assertNotVisible:
id: "sidebar-sessions"
# Horizontal swipe from the terminal surface opens navigation. This is
# intentionally not speed-based and not a right-edge-only gesture.
- swipe:
start: "25%,50%"
end: "80%,50%"
duration: 900
- waitForAnimationToEnd
- assertVisible:
id: "sidebar-sessions"
# If the OS keyboard appears, iOS exposes a lowercase return key. This assertion
# is a UI-level guard; the harness also probes with Maestro inputText and fails
# if text reaches the terminal after the swipe.
- assertNotVisible: "return"
# Close the sidebar to leave a clean state.
- tapOn:
id: "sidebar-close"
- waitForAnimationToEnd
- assertNotVisible:
id: "sidebar-sessions"
- takeScreenshot: native-terminal-sidebar-swipe

View File

@@ -0,0 +1,31 @@
appId: sh.paseo.debug
---
# Self-contained tap-focus flow.
# Precondition: the harness has cleared the visible terminal.
- assertVisible:
id: "terminal-virtual-keyboard"
# First focus and hide the OS keyboard. The regression this covers was a hidden
# keyboard with the native TextInput still focused, making the next focus() a
# no-op.
- tapOn:
id: "terminal-surface"
- waitForAnimationToEnd
- hideKeyboard
- waitForAnimationToEnd
# Tap terminal content again, then type through Maestro's OS-keyboard route.
# Product Paste and CLI terminal input are deliberately not used here.
- tapOn:
id: "terminal-surface"
- waitForAnimationToEnd
- inputText: "printf 'MAESTRO_TAP_FOCUS_OK\\n'"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_TAP_FOCUS_OK"
timeout: 15000
- takeScreenshot: native-terminal-tap-focus

View File

@@ -0,0 +1,109 @@
appId: sh.paseo.debug
---
# Self-contained terminal virtual keyboard UX flow.
# Precondition: the harness has opened the visible terminal, seeded shell history,
# and set the simulator clipboard.
- assertVisible:
id: "terminal-virtual-keyboard"
# Space and Backspace are redundant with the OS keyboard and should not be part
# of the virtual keyboard surface.
- assertNotVisible:
id: "terminal-key-space"
- assertNotVisible:
id: "terminal-key-backspace"
# Copy is selection-only now; it should not be a permanent virtual key.
- assertNotVisible:
id: "terminal-copy"
# Arrow cluster controls are present. Up sits over Down in product layout; this
# flow verifies the actionable controls stay addressable and working.
- assertVisible:
id: "terminal-key-up"
- assertVisible:
id: "terminal-key-left"
- assertVisible:
id: "terminal-key-down"
- assertVisible:
id: "terminal-key-right"
- assertVisible:
id: "terminal-key-enter"
# Up + Enter reruns the seeded shell-history command.
- assertVisible:
text: "MAESTRO_VK_HISTORY_OK"
- tapOn:
id: "terminal-key-up"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_VK_HISTORY_OK"
timeout: 15000
# Keyboard toggle: start hidden, show, hide, show again. After the second show,
# Maestro inputText must reach the PTY/output.
- hideKeyboard
- waitForAnimationToEnd
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- assertNotVisible: "return"
- tapOn:
id: "terminal-keyboard-toggle"
- waitForAnimationToEnd
- inputText: "printf 'MAESTRO_TOGGLE_INPUT_OK\\n'"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_TOGGLE_INPUT_OK"
timeout: 15000
# Paste remains available in the virtual keyboard and sends clipboard text.
- assertVisible:
id: "terminal-paste"
enabled: true
- tapOn:
id: "terminal-paste"
- runFlow:
when:
visible: "Allow Paste"
commands:
- tapOn: "Allow Paste"
- tapOn:
id: "terminal-key-enter"
- extendedWaitUntil:
visible: "MAESTRO_PASTE_OK"
timeout: 15000
# Long-press selection makes Copy appear as a floating action; dragging while
# selected extends the selection without opening navigation or the OS keyboard.
- hideKeyboard
- waitForAnimationToEnd
- assertVisible:
text: "MAESTRO_COPY_OK"
- longPressOn:
point: "1%,28%"
- waitForAnimationToEnd
- assertVisible:
id: "terminal-copy"
enabled: true
- swipe:
start: "1%,28%"
end: "100%,28%"
duration: 1200
- waitForAnimationToEnd
- assertNotVisible:
id: "sidebar-sessions"
- assertNotVisible: "return"
- assertVisible:
id: "terminal-copy"
enabled: true
- tapOn:
id: "terminal-copy"
- takeScreenshot: native-terminal-virtual-keyboard-ux

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.96",
"version": "0.1.97",
"private": true,
"main": "index.ts",
"scripts": {
@@ -66,6 +66,7 @@
"expo-constants": "~18.0.9",
"expo-crypto": "^15.0.8",
"expo-dev-client": "^6.0.15",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
@@ -108,7 +109,7 @@
"react-native-worklets": "~0.8.3",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.6.0",
"zod": "^3.23.8",
"zod": "^4.4.3",
"zustand": "^5.0.9"
},
"devDependencies": {

View File

@@ -75,7 +75,6 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
@@ -276,13 +275,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: workspaceId ?? undefined,
workspaceId: agent.workspaceId,
workspaceRoot,
});
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
@@ -334,10 +329,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
if (workspaceId) {
if (agent.workspaceId) {
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
workspaceId: agent.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}

View File

@@ -63,8 +63,10 @@ import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
@@ -72,6 +74,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
@@ -101,6 +104,7 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
@@ -461,6 +465,7 @@ function AppContainer({
});
useActiveWorktreeNewAction();
useGlobalNewWorkspaceAction();
const content = (
<View style={layoutStyles.surfaceFill}>
@@ -943,9 +948,11 @@ function RootProviders({ children }: { children: ReactNode }) {
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>

View File

@@ -2,11 +2,10 @@ import { useEffect, useRef } from "react";
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
return (
@@ -28,16 +27,16 @@ function HostAgentReadyRouteContent() {
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentCwd = useSessionStore((state) => {
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
if (redirectedRef.current) {
@@ -51,10 +50,9 @@ function HostAgentReadyRouteContent() {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
agentId,
currentPathname: pathname,
});
}
@@ -67,14 +65,14 @@ function HostAgentReadyRouteContent() {
if (!serverId || !agentId) {
return;
}
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -91,21 +89,13 @@ function HostAgentReadyRouteContent() {
if (cancelled || redirectedRef.current) {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
agentId,
workspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
return;
@@ -124,7 +114,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
}, [agentId, client, isConnected, pathname, router, serverId]);
return null;
}

View File

@@ -140,6 +140,7 @@ function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" |
*
* Supported:
* - `filename:linenumber`
* - `filename:linenumber:columnnumber` as a line target
* - `filename:lineStart-lineEnd`
*
* Not supported (by design):

View File

@@ -69,6 +69,11 @@ export function getFileExtension(path: string): string {
return normalizedPath.slice(extensionIndex).toLowerCase();
}
export function getFileTypeLabel(path: string): string | null {
const extension = getFileExtension(path).slice(1);
return extension ? extension.toUpperCase() : null;
}
export function getMimeTypeFromPath(path: string): string {
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
}

View File

@@ -42,6 +42,7 @@ function normalizeCwd(cwd: string): string {
export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const workspaceId = input.workspaceId?.trim();
// workspaceId is opaque; do not parse this key back into a path.
const workspacePart = workspaceId
? `workspace=${encodeScopePart(workspaceId)}`
: `cwd=${encodeScopePart(normalizeCwd(input.cwd))}`;

View File

@@ -15,14 +15,14 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
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 { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useQueryClient } from "@tanstack/react-query";
import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -49,63 +49,6 @@ type FlatListItem =
| { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent };
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
return {
serverId: agent.serverId,
id: agent.id,
provider: agent.provider,
status: agent.status,
createdAt: agent.createdAt,
updatedAt: agent.lastActivityAt,
lastUserMessageAt: null,
lastActivityAt: agent.lastActivityAt,
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: agent.provider,
sessionId: null,
},
title: agent.title,
cwd: agent.cwd,
model: null,
thinkingOptionId: null,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason,
attentionTimestamp: agent.attentionTimestamp,
archivedAt: agent.archivedAt,
labels: agent.labels,
parentAgentId: null,
};
}
function rememberArchivedAgentDetail(agent: AggregatedAgent) {
if (!agent.archivedAt) {
return;
}
useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => {
const existing = previous.get(agent.id);
const next = new Map(previous);
next.set(agent.id, {
...buildHistoricalAgentDetail(agent),
...existing,
archivedAt: existing?.archivedAt ?? agent.archivedAt,
cwd: existing?.cwd ?? agent.cwd,
});
return next;
});
}
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -149,23 +92,6 @@ function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
}
}
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
switch (status) {
case "initializing":
return t("agentList.status.initializing");
case "idle":
return t("agentList.status.idle");
case "running":
return t("agentList.status.running");
case "error":
return t("agentList.status.error");
case "closed":
return t("agentList.status.closed");
default:
return status;
}
}
function SessionBadge({
label,
icon,
@@ -199,6 +125,33 @@ function SessionBadge({
);
}
function WorkspaceTitlePrefix({
visible,
workspaceName,
testID,
iconSize,
color,
}: {
visible: boolean;
workspaceName: string;
testID: string;
iconSize: number;
color: string;
}) {
if (!visible) {
return null;
}
return (
<>
<Text style={styles.workspaceTitleText} numberOfLines={1} testID={testID}>
{workspaceName}
</Text>
<ChevronRight size={iconSize} color={color} />
</>
);
}
function SessionRow({
agent,
isMobile,
@@ -219,8 +172,9 @@ function SessionRow({
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(t, agent.status);
const projectPath = shortenPath(agent.cwd);
const projectName = agent.projectPlacement?.projectName ?? "";
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
@@ -256,6 +210,13 @@ function SessionRow({
>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<WorkspaceTitlePrefix
visible={!isMobile && Boolean(workspaceName)}
workspaceName={workspaceName}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
iconSize={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
/>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
@@ -277,32 +238,54 @@ function SessionRow({
</View>
{isMobile && (
<View style={styles.rowMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{projectPath}
<Text
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{statusLabel}</Text>
<Text
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
>
{workspaceName}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
</View>
{!isMobile && (
<>
<Text style={styles.columnMeta} numberOfLines={1}>
{projectPath}
<View style={styles.rowColumns}>
<Text
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
</>
<Text
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
</View>
)}
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}>
@@ -328,6 +311,7 @@ export function AgentList({
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent();
const queryClient = useQueryClient();
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -344,17 +328,35 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const openAgent = () => {
onAgentSelect?.();
navigateToAgent({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: false,
});
};
onAgentSelect?.();
if (agent.archivedAt) {
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
if (client) {
void client
.refreshAgent(agentId)
.then(() => {
openAgent();
return queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(serverId),
});
})
.catch(() => {});
}
return;
}
rememberArchivedAgentDetail(agent);
navigateToAgent({
serverId,
agentId,
pin: Boolean(agent.archivedAt),
});
openAgent();
},
[isActionSheetVisible, onAgentSelect],
[isActionSheetVisible, onAgentSelect, queryClient],
);
const handleAgentLongPress = useCallback(
@@ -559,18 +561,26 @@ const styles = StyleSheet.create((theme) => ({
rowContent: {
flex: 1,
minWidth: 0,
overflow: "hidden",
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
flexWrap: "nowrap",
gap: theme.spacing[2],
overflow: "hidden",
},
providerIconWrap: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
workspaceTitleText: {
flexShrink: 0,
maxWidth: 220,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
rowMetaRow: {
flexDirection: "row",
alignItems: "center",
@@ -592,6 +602,7 @@ const styles = StyleSheet.create((theme) => ({
},
sessionTitle: {
flexShrink: 1,
minWidth: 0,
fontSize: theme.fontSize.sm,
fontWeight: "400",
color: theme.colors.foreground,
@@ -610,13 +621,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
opacity: 0.7,
},
rowColumns: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[3],
},
columnMeta: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 60,
maxWidth: 200,
marginLeft: theme.spacing[4],
flexShrink: 0,
width: 132,
},
columnMetaFixed: {
fontSize: theme.fontSize.sm,
@@ -628,6 +643,7 @@ const styles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],

View File

@@ -1,9 +1,16 @@
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Image, Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { Theme } from "@/styles/theme";
// Every attachment pill body — image thumbnail or labelled — renders at this
// height so mixed attachment trays line up.
const ATTACHMENT_CONTENT_HEIGHT = 48;
interface AttachmentPillProps {
onOpen: () => void;
@@ -24,7 +31,6 @@ export function AttachmentPill({
testID,
children,
}: AttachmentPillProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isBodyHovered, setIsBodyHovered] = useState(false);
const [isCloseHovered, setIsCloseHovered] = useState(false);
@@ -48,7 +54,7 @@ export function AttachmentPill({
onHoverOut={handleBodyHoverOut}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
style={styles.frame}
>
{children}
</Pressable>
@@ -62,22 +68,127 @@ export function AttachmentPill({
accessibilityLabel={removeAccessibilityLabel}
style={closeButtonStyle}
>
<X size={12} color={theme.colors.foregroundMuted} />
<ThemedX size={12} uniProps={iconForegroundMutedMapping} />
</Pressable>
</View>
);
}
interface AttachmentFrameProps {
onPress?: () => void;
accessibilityLabel?: string;
testID?: string;
children: ReactNode;
}
/** Bare attachment frame for read-only surfaces (sent messages) — no remove button. */
export function AttachmentFrame({
onPress,
accessibilityLabel,
testID,
children,
}: AttachmentFrameProps) {
if (!onPress) {
return (
<View testID={testID} style={styles.frame}>
{children}
</View>
);
}
return (
<Pressable
testID={testID}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.frame}
>
{children}
</Pressable>
);
}
interface AttachmentLabelProps {
icon?: ReactNode;
title: string;
subtitle: string;
}
/** Two-line labelled pill body: attachment name over its type. */
export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps) {
return (
<View style={styles.labelBody}>
{icon ? <View style={styles.labelIcon}>{icon}</View> : null}
<View style={styles.labelTextColumn}>
<Text style={styles.labelTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.labelSubtitle} numberOfLines={1}>
{subtitle}
</Text>
</View>
</View>
);
}
/** Square image preview pill body. */
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
const uri = useAttachmentPreviewUrl(metadata);
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
return <View style={styles.thumbnailPlaceholder} />;
}
return <Image source={source} style={styles.thumbnail} />;
}
const ThemedX = withUnistyles(X);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const styles = StyleSheet.create((theme) => ({
wrapper: {
position: "relative",
},
body: {
frame: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
labelBody: {
height: ATTACHMENT_CONTENT_HEIGHT,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
backgroundColor: theme.colors.surface1,
},
labelIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
labelTextColumn: {
minWidth: 0,
flexShrink: 1,
},
labelTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
labelSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
thumbnail: {
width: ATTACHMENT_CONTENT_HEIGHT,
height: ATTACHMENT_CONTENT_HEIGHT,
},
thumbnailPlaceholder: {
width: ATTACHMENT_CONTENT_HEIGHT,
height: ATTACHMENT_CONTENT_HEIGHT,
backgroundColor: theme.colors.surface1,
},
closeButton: {
position: "absolute",
top: -8,

View File

@@ -1,35 +1,40 @@
import { useCallback, useMemo, useRef } from "react";
import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxProps } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
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;
title: string;
serverId: string;
workspaceId: string;
workspaceDirectory: string | null;
isGitCheckout: boolean;
testID?: string;
}
const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const ThemedGitBranch = withUnistyles(GitBranch);
const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({
currentBranchName,
title,
serverId,
workspaceId,
workspaceDirectory,
isGitCheckout,
testID = "workspace-header-branch-switcher",
}: BranchSwitcherProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -40,6 +45,7 @@ export function BranchSwitcher({
client,
normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
workspaceDirectory,
currentBranchName,
isGitCheckout,
isConnected,
@@ -47,26 +53,19 @@ export function BranchSwitcher({
queryClient,
});
const titleContent = (
<View style={styles.titleRow}>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
</View>
);
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
const triggerStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.branchSwitcherTrigger,
(Boolean(hovered) || pressed) && styles.branchSwitcherTriggerHovered,
styles.trigger,
(Boolean(hovered) || pressed) && styles.triggerHovered,
],
[],
);
const branchLeadingSlot = useMemo(
() => <GitBranch size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
() => <ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />,
[],
);
const renderBranchOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
@@ -83,20 +82,23 @@ export function BranchSwitcher({
);
if (!currentBranchName) {
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
return null;
}
return (
<View ref={anchorRef} collapsable={false}>
<View ref={anchorRef} collapsable={false} style={styles.anchor}>
<Pressable
testID="workspace-header-branch-switcher"
testID={testID}
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
>
{titleContent}
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
<ThemedGitBranch size={14} uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.branchLabel} numberOfLines={1}>
{currentBranchName}
</Text>
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
<Combobox
options={branchOptions}
@@ -120,31 +122,28 @@ export function BranchSwitcher({
}
const styles = StyleSheet.create((theme) => ({
branchSwitcherTrigger: {
anchor: {
flexShrink: 1,
minWidth: 0,
},
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
marginLeft: {
xs: -theme.spacing[2],
md: 0,
},
paddingVertical: {
xs: 0,
md: theme.spacing[1],
},
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
marginLeft: -theme.spacing[2],
borderRadius: theme.borderRadius.md,
flexShrink: 1,
},
branchSwitcherTriggerHovered: {
triggerHovered: {
backgroundColor: theme.colors.surface1,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
minWidth: 0,
overflow: "hidden",
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
}));

View File

@@ -12,14 +12,8 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Search,
Settings,
Star,
} from "lucide-react-native";
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
@@ -784,35 +778,43 @@ export function CombinedModelSelector({
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{renderTrigger ? (
renderTrigger({
{renderTrigger ? (
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
})
) : (
<>
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</>
)}
</Pressable>
})}
</Pressable>
) : (
<ComboboxTrigger
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
testID="combined-model-selector"
>
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
{triggerLabel}
</Text>
</ComboboxTrigger>
)}
<Combobox
options={EMPTY_COMBOBOX_OPTIONS}
value=""

View File

@@ -520,7 +520,6 @@ function SidebarContent({
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
hideHeaderRow={!isMobile}
enabled={isOpen}
/>
)}

View File

@@ -22,6 +22,8 @@ import {
ChevronRight,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
@@ -43,6 +45,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { isWeb } from "@/constants/platform";
@@ -267,7 +270,11 @@ export function FileExplorerPane({
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const toggleExplorerShowHiddenFiles = usePanelStore(
(state) => state.toggleExplorerShowHiddenFiles,
);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
);
@@ -379,6 +386,14 @@ export function FileExplorerPane({
setSortOption(SORT_OPTIONS[nextIndex].value);
}, [sortOption, setSortOption]);
const handleToggleHiddenFiles = useCallback(() => {
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
toggleExplorerShowHiddenFiles();
if (willShow) {
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
}
}, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]);
const refreshExplorer = useCallback(
() =>
refreshExplorerDirectories({
@@ -409,8 +424,8 @@ export function FileExplorerPane({
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
const treeRows = useMemo(
() => resolveTreeRows({ directories, expandedPaths, sortOption }),
[directories, expandedPaths, sortOption],
() => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }),
[directories, expandedPaths, showHiddenFiles, sortOption],
);
const showInitialLoading = resolveShowInitialLoading({
@@ -483,6 +498,7 @@ export function FileExplorerPane({
scrollbar={scrollbar}
renderTreeRow={renderTreeRow}
handleSortCycle={handleSortCycle}
handleToggleHiddenFiles={handleToggleHiddenFiles}
handleRefresh={handleRefresh}
handleBackFromError={handleBackFromError}
handleRetry={handleRetry}
@@ -505,6 +521,7 @@ interface FileExplorerPaneContentProps {
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
handleSortCycle: () => void;
handleToggleHiddenFiles: () => void;
handleRefresh: () => void;
handleBackFromError: () => void;
handleRetry: () => void;
@@ -527,6 +544,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
scrollbar,
renderTreeRow,
handleSortCycle,
handleToggleHiddenFiles,
handleRefresh,
handleBackFromError,
handleRetry,
@@ -534,6 +552,26 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
iconButtonStyle: iconButtonStyleProp,
} = props;
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const hiddenFilesToggleAccessibilityLabel = showHiddenFiles
? t("workspace.fileExplorer.actions.hideHiddenFiles")
: t("workspace.fileExplorer.actions.showHiddenFiles");
const emptyLabel = showHiddenFiles
? t("workspace.fileExplorer.empty.noFiles")
: t("workspace.fileExplorer.empty.noVisibleFiles");
const hiddenFilesToggleStyle = useCallback(
(state: PressableStateCallbackType) => [
iconButtonStyleProp(state),
!showHiddenFiles && styles.iconButtonActive,
],
[showHiddenFiles, iconButtonStyleProp],
);
const hiddenFilesToggleAccessibilityState = useMemo(
() => ({ selected: !showHiddenFiles }),
[showHiddenFiles],
);
if (error) {
return (
<View style={styles.centerState}>
@@ -561,14 +599,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
);
}
if (treeRows.length === 0) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
</View>
);
}
return (
<View style={TREE_PANE_CONTAINER_STYLE}>
<View style={styles.paneHeader} testID="files-pane-header">
@@ -576,45 +606,67 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={styles.headerActions}>
<Pressable
onPress={handleToggleHiddenFiles}
hitSlop={8}
style={hiddenFilesToggleStyle}
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
</View>
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
{scrollbar.overlay}
{treeRows.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{emptyLabel}</Text>
</View>
) : (
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
)}
{treeRows.length > 0 ? scrollbar.overlay : null}
</View>
);
}
@@ -643,12 +695,14 @@ function buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path,
depth,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
path: string;
depth: number;
}): TreeRow[] {
@@ -658,7 +712,10 @@ function buildTreeRows({
}
const rows: TreeRow[] = [];
const entries = sortEntries(directory.entries, sortOption);
const entries = sortEntries(
filterVisibleExplorerEntries(directory.entries, showHiddenFiles),
sortOption,
);
for (const entry of entries) {
rows.push({ entry, depth });
@@ -668,6 +725,7 @@ function buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: entry.path,
depth: depth + 1,
}),
@@ -731,15 +789,24 @@ function resolveTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}): TreeRow[] {
if (!directories.get(".")) {
return [];
}
return buildTreeRows({ directories, expandedPaths, sortOption, path: ".", depth: 0 });
return buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: ".",
depth: 0,
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
@@ -890,12 +957,13 @@ function requestPersistedExpandedPaths({
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
}): void {
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (!persistedPaths) {
return;
}
for (const path of persistedPaths) {
if (path !== ".") {
if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
@@ -919,7 +987,10 @@ async function refreshExplorerDirectories({
if (!hasWorkspaceScope) {
return null;
}
const directoryPaths = Array.from(expandedPaths);
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
@@ -1011,6 +1082,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
treeList: {
flex: 1,
minHeight: 0,
@@ -1154,6 +1230,9 @@ const styles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
iconButtonActive: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: 16,
height: 16,

View File

@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
import { FolderPlus, History, Home, Plus, Search, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
type Dispatch,
@@ -69,6 +69,7 @@ import {
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -112,10 +113,12 @@ interface SidebarSharedProps {
active: boolean;
onPress: () => void;
}) => ReactElement;
newWorkspaceKeys: ShortcutKey[][] | null;
}
interface SidebarLabels {
addProject: string;
newWorkspace: string;
home: string;
settings: string;
switchHost: string;
@@ -242,7 +245,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const handleNewWorkspaceNavigate = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostNewWorkspaceRoute(activeServerId));
router.navigate(buildHostNewWorkspaceRoute(activeServerId));
}, [activeServerId]);
const handleSettingsMobile = useCallback(() => {
@@ -284,9 +287,12 @@ export const LeftSidebar = memo(function LeftSidebar({
[pathname],
);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
newWorkspace: t("sidebar.actions.newWorkspace"),
home: t("sidebar.actions.home"),
settings: t("sidebar.actions.settings"),
switchHost: t("sidebar.host.switchTitle"),
@@ -318,6 +324,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleHostSelect,
renderHostOption,
labels,
newWorkspaceKeys,
};
if (isCompactLayout) {
@@ -596,6 +603,7 @@ function MobileSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
@@ -800,19 +808,25 @@ function MobileSidebar({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderRow}>
<View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow
icon={MessagesSquare}
icon={Plus}
label={labels.newWorkspace}
onPress={handleNewWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
</View>
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspace}
/>
<WorkspacesSectionHeader serverId={activeServerId} />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -894,6 +908,7 @@ function DesktopSidebar({
handleRefresh,
handleHostSelect,
renderHostOption,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
@@ -973,20 +988,26 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<View style={styles.sidebarHeaderRow}>
<View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow
icon={MessagesSquare}
icon={Plus}
label={labels.newWorkspace}
onPress={handleNewWorkspaceNavigate}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
</View>
</View>
<WorkspacesSectionHeader
serverId={activeServerId}
onNewWorkspacePress={handleNewWorkspaceNavigate}
/>
<WorkspacesSectionHeader serverId={activeServerId} />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -1032,13 +1053,7 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({
serverId,
onNewWorkspacePress,
}: {
serverId: string | null;
onNewWorkspacePress: () => void;
}) {
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
@@ -1055,29 +1070,6 @@ function WorkspacesSectionHeader({
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="New workspace"
testID="sidebar-new-workspace"
style={searchButtonStyle}
onPress={onNewWorkspacePress}
>
{({ hovered, pressed }) => (
<Plus
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="New workspace" />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
@@ -1137,22 +1129,36 @@ const staticStyles = RNStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
sidebarHeaderRow: {
position: "relative",
sidebarHeaderGroup: {
paddingTop: theme.spacing[2],
gap: 2,
// Distance from History's bottom edge to the divider. WorkspacesSectionHeader
// uses a slightly smaller paddingTop to balance the action buttons' centering
// offset so the divider reads as visually centered between the two.
paddingBottom: theme.spacing[1.5],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2] + theme.spacing[3],
paddingRight: theme.spacing[4],
paddingTop: theme.spacing[2],
// Align the title with the compact rows' icons and the project icons below
// (listContent + projectRow inner padding both spacing[2]).
paddingLeft: theme.spacing[2] + theme.spacing[2],
// Align the trailing action pill's right edge with the New workspace and
// project row pills (both 8px from the sidebar edge).
paddingRight: theme.spacing[2],
// Less than sidebarHeaderGroup's paddingBottom: the 28px-tall action buttons
// center the title and add their own offset above it, so equal padding reads
// as a larger gap than History's. Trim paddingTop to balance it visually.
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {

View File

@@ -256,6 +256,12 @@ describe("splitHtmlishMarkdown", () => {
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("terminates when an unmatched backtick follows a closed inline code span", () => {
const source = "Use `<details><summary>Example</summary>Body</details>` before `dangling";
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("still parses normal details outside code", () => {
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
{ kind: "markdown", text: "`code`\n" },

View File

@@ -651,8 +651,13 @@ function getInlineCodeRanges(
}
const marker = open[0];
const close = findClosingBacktickRun(source, BACKTICK_RUN_RE.lastIndex, marker, fencedRanges);
const afterOpen = BACKTICK_RUN_RE.lastIndex;
const close = findClosingBacktickRun(source, afterOpen, marker, fencedRanges);
if (!close) {
// Unmatched backtick run — skip past it so the loop doesn't restart from 0.
// findClosingBacktickRun exhausts the global regex, which resets lastIndex
// to 0 when exec() returns null (ECMAScript §22.2.7.2).
BACKTICK_RUN_RE.lastIndex = afterOpen;
continue;
}

View File

@@ -32,6 +32,7 @@ import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
CircleDot,
Info,
CheckCircle,
XCircle,
@@ -41,13 +42,15 @@ import {
Check,
CheckSquare,
Copy,
GitPullRequest,
MessageSquareCode,
TriangleAlertIcon,
Scissors,
MicVocal,
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -101,6 +104,13 @@ import {
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import { getFileTypeLabel } from "@/attachments/file-types";
import {
AttachmentFrame,
AttachmentLabel,
AttachmentThumbnail,
} from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
@@ -160,6 +170,10 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedAttachmentFileText = withUnistyles(FileText);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -363,34 +377,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
imagePreviewSpacing: {
marginBottom: theme.spacing[2],
},
imagePill: {
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface1,
},
structuredAttachmentPill: {
maxWidth: 220,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
structuredAttachmentText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
copyButton: {
alignSelf: "center",
padding: theme.spacing[1],
@@ -417,36 +403,82 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
if (!uri) {
return <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
}
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
const attachmentReviewIcon = (
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentGithubPrIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentGithubIssueIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentFileIcon = (
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
interface UserMessageImagePillProps {
image: UserMessageImageAttachment;
onOpen: (image: UserMessageImageAttachment) => void;
accessibilityLabel: string;
}
function getUserMessageAttachmentLabel(
function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessageImagePillProps) {
const handlePress = useCallback(() => {
onOpen(image);
}, [onOpen, image]);
return (
<AttachmentFrame onPress={handlePress} accessibilityLabel={accessibilityLabel}>
<AttachmentThumbnail metadata={image} />
</AttachmentFrame>
);
}
interface UserMessageAttachmentContent {
icon: ReactNode;
title: string;
subtitle: string;
}
function getUserMessageAttachmentContent(
attachment: AgentAttachment,
t: ReturnType<typeof useTranslation>["t"],
): string {
): UserMessageAttachmentContent {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return count === 1
? t("message.attachments.reviewOne")
: t("message.attachments.reviewMany", { count });
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle:
count === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count }),
};
}
case "github_pr":
return `PR #${attachment.number}`;
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "github_issue":
return `Issue #${attachment.number}`;
return {
icon: attachmentGithubIssueIcon,
title: attachment.title,
subtitle: `Issue #${attachment.number}`,
};
case "text":
return attachment.title ?? t("message.attachments.textAttachment");
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
subtitle: t("message.attachments.text"),
};
case "uploaded_file":
return attachment.fileName;
default:
return "";
return {
icon: attachmentFileIcon,
title: attachment.fileName,
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
};
}
}
@@ -467,6 +499,8 @@ export const UserMessage = memo(function UserMessage({
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const [lightboxMetadata, setLightboxMetadata] = useState<UserMessageImageAttachment | null>(null);
const handleLightboxClose = useCallback(() => setLightboxMetadata(null), []);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
@@ -534,24 +568,31 @@ export const UserMessage = memo(function UserMessage({
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<View key={image.id} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
<UserMessageImagePill
key={image.id}
image={image}
onOpen={setLightboxMetadata}
accessibilityLabel={t("composer.attachments.openImage")}
/>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => (
<View
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment, t)}
</Text>
</View>
))}
{attachments.map((attachment, index) => {
const content = getUserMessageAttachmentContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
subtitle={content.subtitle}
/>
</AttachmentFrame>
);
})}
</View>
) : null}
{hasText ? (
@@ -579,6 +620,7 @@ export const UserMessage = memo(function UserMessage({
</View>
) : null}
</View>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
</View>
);
});

View File

@@ -1,11 +1,11 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Pressable, Text, TextInput, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import {
useAcpProviderCatalog,
type AcpProviderCatalogItem,
@@ -28,6 +28,9 @@ const ThemedPackagePlus = withUnistyles(PackagePlus);
const ThemedSvgXml = withUnistyles(SvgXml);
const ThemedSearch = withUnistyles(Search);
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -145,13 +148,14 @@ export function ProviderCatalogList({
<View style={styles.searchIcon}>
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
</View>
<AdaptiveTextInput
<ThemedTextInput
testID="provider-catalog-search"
value={search}
onChangeText={setSearch}
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
style={styles.searchInput}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
autoCapitalize="none"
autoCorrect={false}
/>
@@ -280,3 +284,5 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
}));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];

View File

@@ -11,11 +11,9 @@ import {
type ViewStyle,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { useMutation } from "@tanstack/react-query";
import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import {
memo,
useCallback,
@@ -47,7 +45,6 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
GitPullRequest,
Settings,
MoreVertical,
@@ -72,14 +69,9 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSessionStore } from "@/stores/session-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from "@/components/ui/context-menu";
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -89,6 +81,7 @@ import {
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -120,18 +113,8 @@ import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
} from "@/workspace/workspace-archive";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
isWeb as platformIsWeb,
isNative as platformIsNative,
@@ -154,7 +137,7 @@ const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedPlus = withUnistyles(Plus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedSettings = withUnistyles(Settings);
@@ -187,40 +170,6 @@ const syncedLoaderColorMapping = (theme: Theme) => ({
: theme.colors.palette.amber[500],
});
function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
uncommittedChangesWithDiff: (diffStat) =>
t("workspace.git.actions.archiveWarning.uncommittedChangesWithDiff", { diffStat }),
addedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.addedLine"
: "workspace.git.actions.archiveWarning.addedLines",
{ count },
),
deletedLine: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.deletedLine"
: "workspace.git.actions.archiveWarning.deletedLines",
{ count },
),
unpushedCommit: (count) =>
t(
count === 1
? "workspace.git.actions.archiveWarning.unpushedCommit"
: "workspace.git.actions.archiveWarning.unpushedCommits",
{ count },
),
};
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
@@ -385,7 +334,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
#{hint.number}
{hint.number}
</Text>
</Pressable>
);
@@ -716,6 +665,7 @@ function WorkspaceRowRightGroup({
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
@@ -996,7 +946,7 @@ function NewWorktreeButton({
loading ? (
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedFolderPlus
<ThemedPlus
size={15}
uniProps={
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
@@ -1534,14 +1484,12 @@ function WorkspaceRowWithMenu({
}) {
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
@@ -1551,7 +1499,7 @@ function WorkspaceRowWithMenu({
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -1560,107 +1508,28 @@ function WorkspaceRowWithMenu({
});
}, [selected, workspace]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) {
return;
}
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
},
getWorktreeArchiveWarningLabels(t),
);
if (!confirmed) {
return;
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
if (!archiveDirectory) {
toast.error(t("sidebar.workspace.toasts.workspacePathUnavailable"));
return;
}
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message =
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed");
toast.error(message);
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) {
return;
}
const confirmed = await confirmDialog({
title: t("sidebar.workspace.confirmations.hideTitle"),
message: t("sidebar.workspace.confirmations.hideMessage", { workspaceName: workspace.name }),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
void hideWorkspaceAfterConfirmation();
}, [hideWorkspaceAfterConfirmation]);
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -1677,31 +1546,20 @@ function WorkspaceRowWithMenu({
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.name]);
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) {
throw new Error(payload.error?.message ?? t("sidebar.workspace.rename.invalidBranchName"));
}
return { targetCwd };
},
onSuccess: async ({ targetCwd }) => {
await invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: workspace.serverId,
cwd: targetCwd,
});
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
@@ -1715,20 +1573,11 @@ function WorkspaceRowWithMenu({
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(slugify(value));
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const validateRenameSlug = useCallback(
(value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? t("sidebar.workspace.rename.invalidBranchName");
},
[t],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
@@ -1746,11 +1595,7 @@ function WorkspaceRowWithMenu({
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
if (isWorktree) {
void archiveWorktreeAfterConfirmation();
} else {
handleArchiveWorkspace();
}
handleArchive();
return true;
},
});
@@ -1769,32 +1614,26 @@ function WorkspaceRowWithMenu({
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={
isWorktree
? t("sidebar.workspace.actions.archiveWorktree")
: t("sidebar.workspace.actions.hideFromSidebar")
}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={
isWorktree
? t("sidebar.workspace.actions.archiving")
: t("sidebar.workspace.actions.hiding")
}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={getWorkspaceArchiveStatus(
isWorktree,
worktreeArchiveStatus,
isHidingWorkspace,
)}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={canCopyBranchName ? handleOpenRename : undefined}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.name}
placeholder="branch-name"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
@@ -1803,242 +1642,6 @@ function WorkspaceRowWithMenu({
);
}
function NonGitProjectRowWithMenuContent({
project,
displayName,
iconDataUri,
workspace,
selected,
onPress,
shortcutNumber,
showShortcutBadge,
drag,
isDragging,
dragHandleProps,
}: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const { t } = useTranslation();
const toast = useToast();
const contextMenu = useContextMenu();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
});
}, [selected, workspace]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: t("sidebar.workspace.confirmations.hideTitle"),
message: t("sidebar.workspace.confirmations.hideMessage", {
workspaceName: workspace.name,
}),
confirmLabel: t("sidebar.workspace.confirmations.hideConfirm"),
cancelLabel: t("sidebar.workspace.confirmations.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
}
setIsArchivingWorkspace(true);
void (async () => {
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
return (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
chevron={null}
onPress={onPress}
serverId={null}
canCreateWorktree={false}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
isArchiving={isArchivingWorkspace}
menuController={contextMenu}
dragHandleProps={dragHandleProps}
/>
<ContextMenuContent
align="start"
width={220}
mobileMode="sheet"
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel={t("sidebar.workspace.actions.hiding")}
destructive
onSelect={handleArchiveWorkspace}
>
{t("sidebar.workspace.actions.hideFromSidebar")}
</ContextMenuItem>
</ContextMenuContent>
</>
);
}
function NonGitProjectRowWithMenu(props: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
return (
<ContextMenu>
<NonGitProjectRowWithMenuContent {...props} />
</ContextMenu>
);
}
function FlattenedProjectRow({
project,
displayName,
iconDataUri,
rowModel,
onPress,
serverId,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber,
showShortcutBadge,
drag,
isDragging,
dragHandleProps,
isProjectActive = false,
onRemoveProject,
removeProjectStatus,
selectionEnabled,
activeWorkspaceSelection,
}: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: "workspace_link" }>;
onPress: () => void;
serverId: string | null;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
selectionEnabled: boolean;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = isWorkspaceSelected({
selection: activeWorkspaceSelection,
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
if (!workspace) {
return null;
}
if (project.projectKind === "directory") {
return (
<NonGitProjectRowWithMenu
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
}
return (
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
chevron={rowModel.chevron}
onPress={onPress}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
menuController={null}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
dragHandleProps={dragHandleProps}
/>
);
}
interface WorkspaceRowItemProps {
workspace: SidebarWorkspaceEntry;
shortcutNumber: number | null;
@@ -2181,6 +1784,7 @@ function ProjectBlock({
displayName,
iconDataUri,
serverId,
canRemoveProject,
selectionEnabled,
showShortcutBadges,
shortcutIndexByWorkspaceKey,
@@ -2201,6 +1805,7 @@ function ProjectBlock({
displayName: string;
iconDataUri: string | null;
serverId: string | null;
canRemoveProject: boolean;
selectionEnabled: boolean;
showShortcutBadges: boolean;
shortcutIndexByWorkspaceKey: Map<string, number>;
@@ -2319,30 +1924,24 @@ function ProjectBlock({
toast.error(t("sidebar.project.toasts.hostDisconnected"));
return;
}
if (!canRemoveProject) {
toast.error(t("sidebar.project.toasts.updateHostToRemove"));
return;
}
setIsRemovingProject(true);
void archiveWorkspacesOptimistically({
client,
workspaces: project.workspaces,
}).then((failures) => {
if (failures.length > 0) {
toast.error(t("sidebar.project.toasts.removeFailed"));
}
setIsRemovingProject(false);
return;
});
void client
.removeProject(project.projectKey)
.catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"),
);
})
.finally(() => {
setIsRemovingProject(false);
});
})();
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
const flattenedRowWorkspaceId =
rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null;
const handleFlattenedRowPress = useCallback(() => {
if (!serverId || !flattenedRowWorkspaceId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, flattenedRowWorkspaceId);
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress]);
}, [isRemovingProject, serverId, displayName, t, toast, project.projectKey, canRemoveProject]);
const handleToggleCollapsed = useCallback(() => {
onToggleCollapsed(project.projectKey);
@@ -2350,68 +1949,43 @@ function ProjectBlock({
return (
<View style={styles.projectBlock}>
{rowModel.kind === "workspace_link" ? (
<FlattenedProjectRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
rowModel={rowModel}
onPress={handleFlattenedRowPress}
serverId={serverId}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={active}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
/>
) : (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
/>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
/>
{!collapsed ? (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
) : null}
</>
)}
{!collapsed && project.workspaces.length > 0 ? (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
) : null}
</View>
);
}
@@ -2425,6 +1999,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.serverId === next.serverId &&
previous.canRemoveProject === next.canRemoveProject &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
@@ -2570,6 +2145,9 @@ function ProjectModeList({
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
const canRemoveProject = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.serverInfo?.features?.projectRemove === true : false,
);
const isWorkspaceRoute = useMemo(
() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)),
@@ -2734,6 +2312,7 @@ function ProjectModeList({
displayName={item.projectName}
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
serverId={serverId}
canRemoveProject={canRemoveProject}
selectionEnabled={selectionEnabled}
showShortcutBadges={showShortcutBadges}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
@@ -2760,6 +2339,7 @@ function ProjectModeList({
onToggleProjectCollapsed,
parentGestureRef,
projectIconByProjectKey,
canRemoveProject,
selectionEnabled,
serverId,
shortcutIndexByWorkspaceKey,

View File

@@ -1,8 +1,17 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { LucideIcon } from "lucide-react-native";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
type SidebarHeaderRowVariant = "header" | "compact";
interface SidebarHeaderRowProps {
icon: LucideIcon;
@@ -12,14 +21,16 @@ interface SidebarHeaderRowProps {
testID?: string;
nativeID?: string;
accessibilityLabel?: string;
/**
* "header" (default): a sidebar-height row with its own bottom separator —
* the lone header at the top of a sidebar (settings "Back to workspace").
* "compact": a workspace-row-height row with no separator, for entries that
* sit in a header group whose wrapper owns the single divider.
*/
variant?: SidebarHeaderRowVariant;
shortcutKeys?: ShortcutKey[][] | null;
}
/**
* Top-of-sidebar header row: a sidebar-height pressable with an icon + label
* and a full-width border separator beneath. Used as the first element of a
* sidebar (workspace "Sessions", settings "Back to workspace"). Owns its own
* separator line so both sidebars converge on the same edge and padding.
*/
export function SidebarHeaderRow({
icon: Icon,
label,
@@ -28,40 +39,46 @@ export function SidebarHeaderRow({
testID,
nativeID,
accessibilityLabel,
variant = "header",
shortcutKeys = null,
}: SidebarHeaderRowProps) {
const { theme } = useUnistyles();
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const containerStyle = useMemo(
() => (variant === "compact" ? styles.containerCompact : styles.container),
[variant],
);
const buttonStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.button,
variant === "compact" && styles.buttonCompact,
(Boolean(hovered) || isActive) && styles.buttonHovered,
],
[isActive],
[isActive, variant],
);
const renderChildren = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => {
const isHighlighted = Boolean(state.hovered) || isActive;
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Icon size={theme.iconSize.md} color={iconColor} />
<ThemedIcon
size={ICON_SIZE.sm}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
{shortcutKeys && Boolean(state.hovered) ? (
<Shortcut chord={shortcutKeys} style={styles.shortcut} />
) : null}
</>
);
},
[
Icon,
isActive,
label,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.iconSize.md,
],
[ThemedIcon, isActive, label, shortcutKeys],
);
return (
<View style={styles.container}>
<View style={containerStyle}>
<Pressable
onPress={onPress}
testID={testID}
@@ -103,23 +120,43 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
userSelect: "none",
},
containerCompact: {
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
userSelect: "none",
},
button: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
// Match the sidebar workspace-row shape (height, padding, radius) so the
// compact header entries sit tight against the workspace list below.
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
// Compact header entries (New workspace / History) sit tighter than the
// workspace-row shape the base button mirrors.
buttonCompact: {
minHeight: 32,
paddingVertical: theme.spacing[1.5],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the workspace list below (base button uses a wider spacing[3]).
paddingHorizontal: theme.spacing[2],
},
buttonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
label: {
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
labelHighlighted: {
color: theme.colors.foreground,
},
shortcut: {
marginLeft: "auto",
},
}));

View File

@@ -35,20 +35,14 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import { confirmDialog } from "@/utils/confirm-dialog";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -191,7 +185,10 @@ function StatusGroupList({
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View style={styles.statusWorkspaceListContainer}>
<View
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.bucket}`}
>
{group.rows.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
@@ -351,14 +348,12 @@ function StatusWorkspaceRowWithMenu({
}) {
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
@@ -368,7 +363,7 @@ function StatusWorkspaceRowWithMenu({
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -380,68 +375,26 @@ function StatusWorkspaceRowWithMenu({
});
}, [selected, workspace]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) return;
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
if (!confirmed) return;
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to archive worktree");
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) return;
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) return;
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error(t("workspace.terminal.hostDisconnected"));
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, t, toast, workspace]);
const handleArchive = useCallback(() => {
if (isArchiving) return;
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
@@ -459,24 +412,10 @@ function StatusWorkspaceRowWithMenu({
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) {
throw new Error(payload.error?.message ?? "Failed to rename branch");
}
return { targetCwd };
},
onSuccess: async ({ targetCwd }) => {
await invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: workspace.serverId,
cwd: targetCwd,
});
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
@@ -484,15 +423,10 @@ function StatusWorkspaceRowWithMenu({
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(slugify(value));
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const validateRenameSlug = useCallback((value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? "Invalid branch name";
}, []);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
@@ -511,19 +445,15 @@ function StatusWorkspaceRowWithMenu({
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
if (isWorktree) {
void archiveWorktreeAfterConfirmation();
} else {
void hideWorkspaceAfterConfirmation();
}
handleArchive();
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = archiveStatus;
} else if (isArchivingWorkspace) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
@@ -537,24 +467,22 @@ function StatusWorkspaceRowWithMenu({
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={workspace.projectKind === "git" ? handleOpenRename : undefined}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.name}
placeholder="branch-name"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}

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