Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e529c3601f fix(server): drop Claude permission skip allowance
Fixes #1582.
2026-06-20 13:45:42 +07:00
Mohamed Boudra 4534754617 Add projects without creating workspaces
Keep the legacy open-project request creating a workspace for old clients; new clients now add projects and create workspaces through separate flows.
2026-06-20 11:56:28 +07:00
paseo-ai[bot] d1481833e6 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-20 04:23:06 +00:00
acea7f3d24 feat: live multi-provider quota panel (#1278)
* feat: live provider quota panel (Claude + Codex)

Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens.

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

* fix: address review feedback - restore quota trigger, guard NaN date

- Re-add triggerFetch() on agent idle/error in agent status subscription
- Guard formatResetsAtLabel against NaN from malformed API date strings

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

* fix: resolve composer conflict and stub subscribe in websocket tests

* feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety

* feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics

* security(quota): fix shell injection vulnerability by migrating exec to execFile

* revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1

* Fix i18n resource test expectation

* Fix quota tooltip empty-provider state

* Preserve zero values in Grok quota

* Fix empty quota fetch state

* Ignore quota frames in relay reconnect tests

* Persist refreshed Codex tokens to source auth file

* fix(quota): read Claude OAuth credentials from the macOS login Keychain

On macOS, Claude Code stores its OAuth credential in the login Keychain
(generic-password item, service "Claude Code-credentials"), not in
~/.claude/.credentials.json — that file usually does not exist there, so the
Claude provider's existsSync check failed and macOS users silently got no
Claude quota. Read it via the macOS `security` CLI (its ACL only trusts
/usr/bin/security to decrypt; a native Keychain read would prompt), and stay
read-only there since Claude Code owns the refresh/persist. Linux and Windows
keep using ~/.claude/.credentials.json unchanged.

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

* Fix quota keychain timeout and local host reconciliation

* Fix terminal notification test quota stub

* Ignore quota frames in terminal notification tests

* fix(quota): bypass Claude token refresh on macOS Keychain-backed logins

* fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1

* fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1

* fix(client): handle and dispatch provider_quota event in DaemonClient

* fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding

* fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs

* fix(desktop): fix concurrently command invocation on Windows in dev.ps1

* fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows

* Add on-demand provider usage views

* Fix Cursor usage billing dates

* Move provider usage renderer coverage to e2e

* Use provider manifest for quota fetchers

* Add provider usage fetch timeouts

* Reshape provider usage fetchers

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: lumingjun <lumingjun@bytedance.com>
2026-06-20 12:19:50 +08:00
Mohamed Boudra 2b0740ff84 Add Claude Ultracode with setting-change notices (#1625)
* Add Claude Ultracode mode notices

* Share provider notice definitions

* Test Claude Ultracode through public turn API
2026-06-20 04:15:04 +00:00
Mohamed Boudra 7af92120fe Detach subagents without archiving them (#1612)
* Add detach action for subagents

* Translate detached subagent connection error

* Address subagent detach review feedback

* Fix subagent integration test import

* Focus detached subagents as tabs
2026-06-19 17:19:05 +08:00
Mohamed Boudra cda66ae5f3 Make provider diagnostics easier to share (#1611)
* Show PATH matches in provider diagnostics

* Add copyable provider command probes

* Fix provider diagnostic path reporting

* Clean up command probe diagnostics
2026-06-19 12:46:39 +08:00
Mohamed BoudraandMatt Cowger f9660c7e89 Clarify PR merge action labels (#1608)
* fix: disambiguate merge PR action labels (squash/merge/rebase)

* Clarify PR merge method labels

* Address merge label review feedback

---------

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-06-19 12:04:52 +08:00
Mohamed Boudra e73b1c4724 Fix hidden workspace file suggestions (#1609) 2026-06-19 11:56:22 +08:00
Matt Cowger ccb8714a71 fix(opencode): trust discovered modes, don't inject hardcoded defaults (#1366)
* fix(opencode): trust discovered modes instead of always injecting defaults

When OpenCode reports its available agents, respect that list exactly
rather than seeding from DEFAULT_MODES first. This means users who have
intentionally disabled modes (e.g. Plan) in their OpenCode config will
no longer see those modes surfaced in Paseo.

DEFAULT_MODES is still used as a fallback when discovery returns nothing
(e.g. OpenCode unreachable or no agents configured).

* test(opencode): update mode discovery expectations

* test(opencode): expect discovered custom modes only
2026-06-19 11:20:26 +08: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
V1rusandMohamed Boudra 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 LeiterandClaude Opus 4.8 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
xpufxandMohamed Boudra 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
251 changed files with 19724 additions and 8401 deletions
+2 -2
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.
+30 -6
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
+37
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._
+5
View File
@@ -21,6 +21,8 @@ Agent-scoped `create_agent` accepts `detached: true` for agents that should stan
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive
@@ -70,6 +72,8 @@ parentAgentId === thisAgent.id AND !archivedAt
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
## Why this shape
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
@@ -77,6 +81,7 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Detach button on track rows** — lets a subagent continue independently without killing its work
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
+5
View File
@@ -410,6 +410,11 @@ Array of workspace records. A workspace is a specific working directory within a
> **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.
---
## 8. Push Token Store
+11 -8
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
+2 -1
View File
@@ -4,7 +4,8 @@ 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. 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"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
- **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).
+19 -2
View File
@@ -54,6 +54,21 @@ Boundary tests should assert observable behavior: cold reads may call provider a
---
## Provider Usage Fetchers
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
---
## ACP Provider Checklist
### 1. Create the provider class
@@ -343,7 +358,7 @@ interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
setMode(modeId: string): Promise<void | AgentProviderNotice>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(
requestId: string,
@@ -355,7 +370,7 @@ interface AgentSession {
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
@@ -363,6 +378,8 @@ interface AgentSession {
}
```
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
+23 -11
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
+1 -1
View File
@@ -1 +1 @@
sha256-XP1Cl2o0yQYufxoWaVeW8U15UxrFgfiOwVCj119KjP0=
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
+2329 -3056
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.97-beta.1",
"version": "0.1.97",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+16 -7
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,
@@ -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);
});
});
+6 -4
View File
@@ -362,11 +362,13 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
const opened = await client.openProject(repo.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
}
return { id: opened.workspace.id };
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
+126 -18
View File
@@ -1,8 +1,10 @@
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
@@ -27,11 +29,76 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
await archiveItem.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", () => {
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();
}
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
await page.getByTestId("sidebar-add-project").click();
const input = page.getByPlaceholder("Type a directory path...");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(projectPath);
await page.keyboard.press("Enter");
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: path.basename(projectPath) })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
}
// Projects are parents in the sidebar. Archiving the last workspace leaves the
// project row in place with a ghost "+ New workspace" child row.
test.describe("Project with no workspaces persists", () => {
test("adding a project starts with only a new-workspace child row", async ({ page }) => {
const repo = await createTempGitRepo("empty-project-add-");
const client = await connectSeedClient();
let projectId: string | null = null;
try {
await gotoAppShell(page);
await waitForNoProjectsInSidebar(page);
projectId = await addProjectFromPicker(page, repo.path);
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(path.basename(repo.path));
await expect(page.getByTestId(`sidebar-workspace-list-${projectId}`)).toHaveCount(0);
const newWorkspaceRow = page.getByTestId(`sidebar-project-new-workspace-row-${projectId}`);
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
const workspaces = await client.fetchWorkspaces({ filter: { projectId } });
expect(workspaces.entries).toEqual([]);
} finally {
if (projectId) {
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
}
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("archiving the only workspace keeps the project row with creation still reachable", async ({
page,
}) => {
@@ -39,8 +106,8 @@ test.describe("Empty project persists", () => {
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectNewWorktreeIcon = page.getByTestId(
`sidebar-project-new-worktree-${workspace.projectId}`,
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -53,26 +120,67 @@ test.describe("Empty project persists", () => {
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.
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
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.
// The project survives a reload after its last workspace is archived.
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).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 readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project?.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);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
).toBeVisible({ timeout: 30_000 });
} finally {
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
await workspace.cleanup();
}
});
});
+63 -1
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";
@@ -154,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) {
@@ -665,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
await stopCurrentDaemonFromPidLock();
daemonProcess = null;
metroProcess = null;
relayProcess = null;
+31 -7
View File
@@ -35,7 +35,7 @@ function buildSeededStoragePayload() {
* idle agent from the same client it uses for everything else.
*/
export interface IdleAgentSeedClient {
openProject(cwd: string): Promise<{
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
workspace: { id: string } | null;
error: string | null;
}>;
@@ -58,16 +58,18 @@ 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 createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
}
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "bypassPermissions",
cwd: input.cwd,
workspaceId: opened.workspace.id,
workspaceId: createdWorkspace.workspace.id,
title: input.title,
});
const snapshot = await client.waitForAgentUpsert(
@@ -82,7 +84,7 @@ export async function createIdleAgent(
id: created.id,
title: input.title,
cwd: input.cwd,
workspaceId: opened.workspace.id,
workspaceId: createdWorkspace.workspace.id,
};
}
@@ -93,6 +95,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();
@@ -214,7 +232,7 @@ export async function openSessions(page: Page): Promise<void> {
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Agent history", { exact: true }).last()).toBeVisible({
await expect(page.getByText("History", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
@@ -233,6 +251,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 });
+15 -7
View File
@@ -160,15 +160,19 @@ export async function startRunningMockAgent(
const repo = await createTempGitRepo(opts.prefix);
const client = await connectSeedClient();
const opened = await client.openProject(repo.path);
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
}
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
workspaceId: opened.workspace.id,
workspaceId: createdWorkspace.workspace.id,
model: opts.model,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
@@ -188,10 +192,14 @@ export async function openGithubWorkspace(
repoPath: string,
): Promise<GithubWorkspaceHandle> {
const client = await connectWorkspaceSetupClient();
const opened = await client.openProject(repoPath);
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repoPath },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
}
await gotoAppShell(page);
await selectWorkspaceInSidebar(page, opened.workspace.id);
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
await waitForTabBar(page);
return { cleanup: () => client.close().catch(() => undefined) };
}
+157
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}`,
});
}
+38 -4
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 });
},
};
}
+35 -11
View File
@@ -13,16 +13,16 @@ type NewWorkspaceDaemonClient = Pick<
| "close"
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "openProject"
| "patchDaemonConfig"
>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
type WorkspacePayload = Pick<CreateWorkspacePayload, "error" | "workspace">;
type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
export interface OpenedProject {
workspaceId: string;
@@ -37,7 +37,7 @@ function requireWorkspace(payload: WorkspacePayload) {
throw new Error(payload.error);
}
if (!payload.workspace) {
throw new Error("openProject returned no workspace.");
throw new Error("workspace.create returned no workspace.");
}
return payload.workspace;
}
@@ -96,15 +96,23 @@ export async function openProjectViaDaemon(
client: NewWorkspaceDaemonClient,
repoPath: string,
): Promise<OpenedProject> {
const workspace = requireWorkspace(await client.openProject(repoPath));
const workspace = requireWorkspace(
await client.createWorkspace({
source: { kind: "directory", path: repoPath },
}),
);
return openedProjectFromWorkspace(workspace);
}
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceDirectory: string,
options?: { scope?: "workspace" | "worktree" },
): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory });
const payload = await client.archivePaseoWorktree({
worktreePath: workspaceDirectory,
...(options?.scope !== undefined ? { scope: options.scope } : {}),
});
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -209,18 +217,34 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
export async function selectWorkspaceBacking(
// 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,
backing: "local" | "worktree",
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByTestId("workspace-create-backing-trigger");
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-backing-${backing}`);
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
}
+154
View File
@@ -0,0 +1,154 @@
import type { Page } from "@playwright/test";
import type { ProviderUsage } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
interface ProviderUsageFixturePayload {
fetchedAt: string;
providers: ProviderUsage[];
}
export interface ProviderUsageFixture {
requestCount(): number;
waitForRequestCount(count: number): Promise<void>;
}
type WebSocketMessage = string | Buffer;
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function withProviderUsageFeature(message: WebSocketMessage): string | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as {
type?: unknown;
message?: {
type?: unknown;
payload?: Record<string, unknown>;
};
};
const payload = maybeEnvelope.message?.payload;
if (
maybeEnvelope.type !== "session" ||
maybeEnvelope.message?.type !== "status" ||
payload?.status !== "server_info"
) {
return null;
}
return JSON.stringify({
...maybeEnvelope,
message: {
...maybeEnvelope.message,
payload: {
...payload,
features: {
...(typeof payload.features === "object" && payload.features !== null
? payload.features
: {}),
providerUsageList: true,
},
},
},
});
}
export async function installProviderUsageFixture(
page: Page,
payloads: ProviderUsageFixturePayload[],
): Promise<ProviderUsageFixture> {
let requests = 0;
const waiters: Array<{ count: number; resolve: () => void }> = [];
function notifyWaiters() {
for (const waiter of waiters.splice(0)) {
if (requests >= waiter.count) {
waiter.resolve();
} else {
waiters.push(waiter);
}
}
}
function payloadForRequest(): ProviderUsageFixturePayload {
const index = Math.min(requests - 1, payloads.length - 1);
const payload = payloads[index];
if (!payload) {
throw new Error("Provider usage fixture requires at least one payload.");
}
return payload;
}
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "provider.usage.list.request") {
requests += 1;
const requestId = sessionMessage.requestId;
if (typeof requestId !== "string") {
throw new Error("provider.usage.list.request missing requestId");
}
const payload = payloadForRequest();
notifyWaiters();
ws.send(
JSON.stringify({
type: "session",
message: {
type: "provider.usage.list.response",
payload: {
requestId,
fetchedAt: payload.fetchedAt,
providers: payload.providers,
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
ws.send(serverInfo ?? message);
});
});
return {
requestCount() {
return requests;
},
waitForRequestCount(count: number) {
if (requests >= count) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
waiters.push({ count, resolve });
});
},
};
}
+8 -6
View File
@@ -171,25 +171,27 @@ export async function launchAgent(input: {
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectSeedClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
workspaceId: opened.workspace.id,
workspaceId: createdWorkspace.workspace.id,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
workspaceId: opened.workspace.id,
workspaceId: createdWorkspace.workspace.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
await openAgent(input.page, { workspaceId: createdWorkspace.workspace.id, agentId: agent.id });
return handle;
}
+32 -14
View File
@@ -4,6 +4,15 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import { connectDaemonClient } from "./daemon-client-loader";
import { createTempDirectory, createTempGitRepo } from "./workspace";
export interface SeedWorkspaceDescriptor {
id: string;
name: string;
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
}
/**
* The general-purpose E2E daemon client used to seed and drive state out of
* band (workspaces, agents, terminals) while the UI is exercised through the
@@ -13,17 +22,17 @@ import { createTempDirectory, createTempGitRepo } from "./workspace";
export interface SeedDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
} | null;
error: string | null;
}>;
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
createWorkspace(input: {
source:
| { kind: "directory"; path: string; projectId?: string }
@@ -39,7 +48,7 @@ export interface SeedDaemonClient {
};
title?: string;
}): Promise<{
workspace: { id: string; name: string } | null;
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
}>;
/**
@@ -82,6 +91,7 @@ export interface SeedDaemonClient {
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
labels?: Record<string, string>;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
@@ -121,6 +131,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?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
} | null;
fetchAgentHistory(options?: {
page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>;
@@ -177,18 +193,20 @@ export async function seedWorkspace(options: {
: await createTempGitRepo(options.repoPrefix, options.repo);
const client = await connectSeedClient();
try {
const opened = await client.openProject(project.path);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
const created = await client.createWorkspace({
source: { kind: "directory", path: project.path },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
return {
client,
repoPath: project.path,
workspaceId: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
workspaceId: created.workspace.id,
workspaceName: created.workspace.name,
workspaceDirectory: created.workspace.workspaceDirectory,
projectId: created.workspace.projectId,
projectDisplayName: created.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
await project.cleanup().catch(() => undefined);
+2 -1
View File
@@ -25,7 +25,7 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
@@ -374,6 +374,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-usage")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.
+83
View File
@@ -0,0 +1,83 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client";
export interface SeededSubagentPair {
parent: {
id: string;
title: string;
};
child: {
id: string;
title: string;
};
workspaceId: string;
}
export async function seedParentWithSubagent(
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentTitle: string; childTitle: string },
): Promise<SeededSubagentPair> {
const parent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.parentTitle,
modeId: "load-test",
model: "ten-second-stream",
});
const child = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.childTitle,
modeId: "load-test",
model: "ten-second-stream",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
},
});
return {
parent: {
id: parent.id,
title: input.parentTitle,
},
child: {
id: child.id,
title: input.childTitle,
},
workspaceId: workspace.workspaceId,
};
}
export async function openSubagentsTrack(page: Page): Promise<void> {
await page.getByTestId("subagents-track-header").click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
});
}
export async function expectSubagentRowGone(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("Detach subagent?");
void dialog.accept();
});
const detachButton = page.getByTestId(`subagents-track-detach-${childId}`);
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
+3 -1
View File
@@ -43,7 +43,9 @@ export class TerminalE2EHarness {
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
const tempRepo = await createTempGitRepo(input.tempPrefix);
const client = await connectSeedClient();
const seedResult = await client.openProject(tempRepo.path);
const seedResult = await client.createWorkspace({
source: { kind: "directory", path: tempRepo.path },
});
if (!seedResult.workspace) {
await client.close().catch(() => {});
await tempRepo.cleanup().catch(() => {});
+10 -5
View File
@@ -60,14 +60,19 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
);
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
// Register the parent project so the sidebar lists it before we navigate.
await client.openProject(repo.path);
const added = await client.addProject(repo.path);
if (!added.project) {
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
}
}
const opened = await client.openProject(workspacePath);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
const created = await client.createWorkspace({
source: { kind: "directory", path: workspacePath },
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
}
const workspaceId = opened.workspace.id;
const workspaceId = created.workspace.id;
return {
workspaceId,
+10 -4
View File
@@ -11,13 +11,14 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
type WorkspaceSetupDaemonClient = Pick<
InternalDaemonClient,
| "close"
| "addProject"
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgent"
| "fetchAgents"
| "fetchWorkspaces"
| "listTerminals"
| "openProject"
| "subscribeRawMessages"
>;
@@ -36,9 +37,11 @@ export async function openProjectViaDaemon(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
const result = await client.openProject(repoPath);
const result = await client.createWorkspace({
source: { kind: "directory", path: repoPath },
});
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
throw new Error(result.error ?? `Failed to create workspace ${repoPath}`);
}
return {
id: result.workspace.id,
@@ -51,7 +54,10 @@ export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
await openProjectViaDaemon(client, repoPath);
const result = await client.addProject(repoPath);
if (!result.project || result.error) {
throw new Error(result.error ?? `Failed to add project ${repoPath}`);
}
}
export function projectNameFromPath(repoPath: string): string {
+6 -2
View File
@@ -8,8 +8,8 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
}
/**
* Wait for the sidebar to show at least one project row, indicating that the
* WebSocket connection is up and workspace hydration has completed.
* Wait for the sidebar to show at least one project row. Use this after a spec
* seeds a workspace/project; zero-project flows need their own assertion.
*/
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
await page
@@ -18,6 +18,10 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
.waitFor({ state: "visible", timeout });
}
export async function waitForNoProjectsInSidebar(page: Page, timeout = 60_000): Promise<void> {
await page.getByTestId("sidebar-project-empty-state").waitFor({ state: "visible", timeout });
}
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
}
@@ -58,9 +58,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
const client = await connectSeedClient();
try {
const opened = await client.openProject(PASEO_REPO_PATH);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: PASEO_REPO_PATH },
});
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
return {
prompt,
@@ -69,11 +71,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspace: {
client,
repoPath: PASEO_REPO_PATH,
workspaceId: opened.workspace.id,
workspaceName: opened.workspace.name,
workspaceDirectory: opened.workspace.workspaceDirectory,
projectId: opened.workspace.projectId,
projectDisplayName: opened.workspace.projectDisplayName,
workspaceId: createdWorkspace.workspace.id,
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);
},
+3 -3
View File
@@ -131,9 +131,9 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
// No git checkout means no worktree backing choice: the Isolation row is
// No git checkout means no worktree isolation choice: the Isolation row is
// absent entirely.
await expect(page.getByTestId("workspace-create-backing-trigger")).toHaveCount(0);
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();
@@ -144,7 +144,7 @@ test.describe("New workspace entry points", () => {
await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expect(page.getByTestId("workspace-create-backing-trigger")).toBeVisible({
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
timeout: 30_000,
});
} finally {
@@ -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();
}
});
});
+56 -6
View File
@@ -25,11 +25,15 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceBacking,
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,
@@ -633,7 +637,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
@@ -679,7 +683,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -704,7 +708,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -734,7 +738,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceBacking(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
@@ -751,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();
}
});
});
@@ -0,0 +1,152 @@
import { expect, test } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { installProviderUsageFixture } from "./helpers/provider-usage";
import { getServerId } from "./helpers/server-id";
import { openSettingsHostSection } from "./helpers/settings";
test.describe("provider usage settings", () => {
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
},
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
sourceLabel: "OpenUsage 0.6.27",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
expect(usageFixture.requestCount()).toBe(0);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
});
test("refresh invalidates and refetches usage", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
});
test("one provider error does not collapse the usage list", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "error",
planLabel: null,
windows: [],
error: "Claude auth expired",
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Error", { exact: true })).toBeVisible();
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("71%")).toBeVisible();
});
});
@@ -0,0 +1,113 @@
import { expect, test, type Page } from "./fixtures";
import { expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installProviderUsageFixture } from "./helpers/provider-usage";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgent(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-usage-tooltip-",
title: "Provider usage tooltip e2e",
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
return session;
}
test.describe("provider usage tooltip", () => {
test("fetches usage when the context tooltip opens and renders the active provider", async ({
page,
}) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [
{
id: "session",
label: "Session",
usedPct: 42,
remainingPct: 58,
resetsAt: "2026-06-19T05:00:00.000Z",
},
],
},
],
},
]);
const session = await openMockAgent(page);
try {
expect(usageFixture.requestCount()).toBe(0);
await page.getByTestId("context-window-meter").hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Test plan")).toBeVisible();
await expect(page.getByText("Session", { exact: true })).toBeVisible();
await expect(page.getByText("42%")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 41 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 64 }],
},
],
},
]);
const session = await openMockAgent(page);
try {
const meter = page.getByTestId("context-window-meter");
await meter.hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
await page.mouse.move(0, 0);
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
await meter.hover();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
} finally {
await session.cleanup();
}
});
});
@@ -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 {
+44
View File
@@ -0,0 +1,44 @@
import { test } from "./fixtures";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { expectAgentTabActive } from "./helpers/launcher";
import { openAgentRoute } from "./helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import {
detachSubagentFromTrack,
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentsTrack,
seedParentWithSubagent,
} from "./helpers/subagents";
test.describe("Subagent detach", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "subagent-detach-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("detaching a subagent focuses it as a workspace tab", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Detach parent",
childTitle: "Detached child",
});
await openAgentRoute(page, {
workspaceId: agents.workspaceId,
agentId: agents.parent.id,
});
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await detachSubagentFromTrack(page, agents.child.id);
await expectSubagentRowGone(page, agents.child.id);
await expectWorkspaceTabVisible(page, agents.child.id);
await expectAgentTabActive(page, agents.child.id);
});
});
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceBacking,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "./helpers/new-workspace";
@@ -261,7 +261,7 @@ test.describe("Workspace model regressions", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -346,7 +346,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -462,7 +462,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceBacking(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
+13 -13
View File
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
selectWorkspaceBacking,
selectWorkspaceIsolation,
submitNewWorkspaceEmpty,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
@@ -14,8 +14,8 @@ 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 backing directory (local
// checkout or worktree) is a CHOICE at creation, and creation NEVER dedupes by
// 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.
@@ -39,16 +39,16 @@ async function createWorkspaceViaUi(
input: {
project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to
// touch, the backing is implicitly local.
backing: "local" | "worktree" | null;
// 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.backing !== null) {
await selectWorkspaceBacking(page, input.backing);
if (input.isolation !== null) {
await selectWorkspaceIsolation(page, input.isolation);
}
await submitNewWorkspaceEmpty(page);
@@ -96,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
backing: "local",
isolation: "local",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -127,7 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => {
}
});
test("New worktree backing creates a worktree-backed workspace in a distinct directory", async ({
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({
@@ -148,13 +148,13 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, {
project,
backing: "worktree",
isolation: "worktree",
previousWorkspaceId: seeded.workspaceId,
client,
});
// The worktree row appears, pointing at a directory distinct from the
// backing checkout.
// local checkout.
const worktreeRow = page.getByTestId(workspaceRowTestId(worktree.workspaceId));
await expect(worktreeRow).toBeVisible({ timeout: 30_000 });
expect(worktree.workspaceId).not.toBe(seeded.workspaceId);
@@ -199,8 +199,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
// Non-git project: no Isolation control, backing is implicitly local.
backing: null,
// Non-git project: no Isolation control, isolation is implicitly local.
isolation: null,
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -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 });
});
});
+127
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();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.97-beta.1",
"version": "0.1.97",
"private": true,
"main": "index.ts",
"scripts": {
@@ -109,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": {
+6 -3
View File
@@ -66,6 +66,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti
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";
@@ -945,9 +946,11 @@ function RootProviders({ children }: { children: ReactNode }) {
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
return (
@@ -50,10 +50,9 @@ function HostAgentReadyRouteContent() {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
agentId,
currentPathname: pathname,
});
}
@@ -93,10 +92,10 @@ function HostAgentReadyRouteContent() {
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToPreparedWorkspaceTab({
navigateToAgent({
serverId,
agentId,
workspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
return;
@@ -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):
+125 -111
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,65 +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,
workspaceId: agent.workspaceId,
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,
workspaceId: existing?.workspaceId ?? agent.workspaceId,
});
return next;
});
}
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -151,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,
@@ -201,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,
@@ -221,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;
@@ -258,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>
@@ -279,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}>
@@ -330,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,
@@ -346,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(
@@ -561,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",
@@ -594,6 +602,7 @@ const styles = StyleSheet.create((theme) => ({
},
sessionTitle: {
flexShrink: 1,
minWidth: 0,
fontSize: theme.fontSize.sm,
fontWeight: "400",
color: theme.colors.foreground,
@@ -612,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,
@@ -630,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],
@@ -1,14 +1,21 @@
import { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
maxTokens: number;
usedTokens: number;
totalCostUsd?: number | null;
showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
}
const SVG_SIZE = 16;
@@ -41,16 +48,6 @@ function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
function formatSessionCost(value: number): string | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
@@ -75,15 +72,52 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
function getMeterGeometry(showPercentage: boolean) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
center: COMPACT_CENTER,
radius: COMPACT_RADIUS,
strokeWidth: COMPACT_STROKE_WIDTH,
circumference: COMPACT_CIRCUMFERENCE,
containerStyle: styles.containerWithLabel,
};
}
return {
svgSize: SVG_SIZE,
center: CENTER,
radius: RADIUS,
strokeWidth: STROKE_WIDTH,
circumference: CIRCUMFERENCE,
containerStyle: styles.container,
};
}
export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
showPercentage = false,
serverId,
provider,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage = getUsagePercentage(maxTokens, usedTokens);
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage();
}
},
[refreshProviderUsage],
);
if (percentage === null) {
return null;
@@ -91,22 +125,25 @@ export function ContextWindowMeter({
const clampedPercentage = clampPercentage(percentage);
const roundedPercentage = Math.round(percentage);
const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
const center = showPercentage ? COMPACT_CENTER : CENTER;
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
getMeterGeometry(showPercentage);
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<Tooltip
open={isTooltipOpen}
onOpenChange={handleTooltipOpenChange}
delayDuration={0}
enabledOnDesktop
enabledOnMobile
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={t("contextWindow.accessibility", {
percentage: roundedPercentage,
@@ -162,6 +199,7 @@ export function ContextWindowMeter({
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
</View>
</TooltipContent>
</Tooltip>
@@ -194,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
},
tooltipContent: {
gap: theme.spacing[1],
minWidth: 200,
},
tooltipTitle: {
color: theme.colors.foreground,
@@ -0,0 +1,9 @@
export function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${Math.round(value / 1_000_000)}m`;
}
if (value >= 1_000) {
return `${Math.round(value / 1_000)}k`;
}
return Math.round(value).toString();
}
+19 -10
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { Clock, FolderPlus, Home, 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,
@@ -818,7 +818,7 @@ function MobileSidebar({
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={Clock}
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
@@ -998,7 +998,7 @@ function DesktopSidebar({
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={Clock}
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
@@ -1131,9 +1131,11 @@ const staticStyles = RNStyleSheet.create({
const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroup: {
paddingTop: theme.spacing[2],
// Match WorkspacesSectionHeader's paddingTop below the divider so the divider
// sits visually centered between the Sessions row and the Workspaces header.
paddingBottom: 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,
},
@@ -1142,14 +1144,21 @@ const styles = StyleSheet.create((theme) => ({
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: {
@@ -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" },
@@ -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;
}
@@ -1,4 +1,5 @@
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -248,6 +250,7 @@ function DiagnosticSubSheet({
}) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -288,31 +291,62 @@ function DiagnosticSubSheet({
void fetchDiagnostic();
}, [fetchDiagnostic]);
const copyButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
diagnostic ? null : sheetStyles.disabled,
],
[diagnostic],
);
const handleCopyPress = useCallback(() => {
if (!diagnostic) return;
void Clipboard.setStringAsync(diagnostic)
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
}, [diagnostic, t, toast]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.providers.diagnostic.title"),
actions: (
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<View style={sheetStyles.headerActions}>
<Pressable
onPress={handleCopyPress}
disabled={!diagnostic}
hitSlop={8}
style={copyButtonStyle}
accessibilityRole="button"
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
>
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefreshPress}
disabled={loading}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
loading
? t("settings.providers.diagnostic.refreshingAccessibility")
: t("settings.providers.diagnostic.refreshAccessibility")
}
>
{loading ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
),
}),
[
copyButtonStyle,
diagnostic,
handleCopyPress,
handleRefreshPress,
loading,
refreshButtonStyle,
@@ -733,6 +767,11 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
disabled: {
opacity: 0.5,
},
File diff suppressed because it is too large Load Diff
@@ -52,9 +52,10 @@ export function SidebarHeaderRow({
const buttonStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.button,
variant === "compact" && styles.buttonCompact,
(Boolean(hovered) || isActive) && styles.buttonHovered,
],
[isActive],
[isActive, variant],
);
const renderChildren = useCallback(
@@ -135,6 +136,15 @@ const styles = StyleSheet.create((theme) => ({
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,
},
@@ -1,5 +1,4 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -21,45 +20,12 @@ import {
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
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";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -74,17 +40,6 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
@@ -320,448 +275,18 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
if (!hydratedWorkspace) return null;
return (
<StatusWorkspaceRowWithMenu
<SidebarWorkspaceRow
workspace={hydratedWorkspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
onPress={handlePress}
subtitle={projectName}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, 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 handleArchive = useCallback(() => {
if (isArchiving) return;
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
{shouldRenderActionSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive?: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -825,37 +350,4 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -29,7 +29,6 @@ import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
@@ -124,35 +123,39 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
return (
<View style={styles.workspaceRowContent}>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isLoading}
/>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isLoading}
/>
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
<View style={styles.workspaceTitleLeft}>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
</View>
) : null}
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadgeOverlay} pointerEvents="none">
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
</View>
) : null}
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
</View>
) : null}
</View>
);
});
@@ -208,6 +211,18 @@ function WorkspaceStatusIndicator({
);
}
if (bucket === "attention") {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-attention">
<View style={styles.standaloneStatusDot} />
</View>
);
}
if (bucket === "done") {
return <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />;
}
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
@@ -301,7 +316,7 @@ function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
#{hint.number}
{hint.number}
</Text>
</Pressable>
);
@@ -461,11 +476,20 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowMain: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[2],
width: "100%",
},
workspaceRowLeft: {
workspaceContentColumn: {
flex: 1,
minWidth: 0,
},
workspaceTitleRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[2],
},
workspaceTitleLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -480,7 +504,7 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,
width: theme.iconSize.md,
height: 20,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
@@ -492,6 +516,12 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
standaloneStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.green[500],
},
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -522,14 +552,12 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 14,
marginLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadgeRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[1],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
@@ -0,0 +1,641 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Theme } from "@/styles/theme";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { DiffStat } from "@/components/diff-stat";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
}
function noop() {}
interface SidebarWorkspaceRowProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
onPress: () => void;
/** Secondary line under the name (status grouping shows the project name). */
subtitle?: string | null;
/** Project grouping only: shows a transient "creating" affordance. */
isCreating?: boolean;
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
onPress,
subtitle,
isCreating = false,
drag,
isDragging = false,
dragHandleProps,
}: SidebarWorkspaceRowProps) {
const { t } = useTranslation();
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, 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 handleArchive = useCallback(() => {
if (isArchiving) {
return;
}
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
}, []);
const handleCloseRename = useCallback(() => {
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
handleArchive();
return true;
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
subtitle={subtitle}
isCreating={isCreating}
isArchiving={isArchiving}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={archiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
interface WorkspaceRowBodyProps {
workspace: SidebarWorkspaceEntry;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
subtitle?: string | null;
isCreating: boolean;
isArchiving: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspaceRowBody({
workspace,
selected,
shortcutNumber,
showShortcutBadge,
subtitle,
isCreating,
isArchiving,
onPress,
drag,
isDragging,
dragHandleProps,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isTouchPlatform = platformIsNative;
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag: drag ?? noop,
menuController: null,
});
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
return;
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
return (
<View
{...(draggable ? dragAttributes : {})}
{...(draggable ? dragHandleProps?.listeners : {})}
ref={
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPressIn={draggable ? interaction.handlePressIn : undefined}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={draggable ? interaction.handlePressOut : undefined}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={subtitle}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
>
<WorkspaceRowTrailingActions
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
/>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function WorkspaceRowTrailingActions({
workspace,
isHovered,
isTouchPlatform,
isCreating,
showShortcutBadge,
shortcutNumber,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
onArchive,
onMarkAsRead,
onCopyBranchName,
onCopyPath,
onRename,
}: {
workspace: SidebarWorkspaceEntry;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
showShortcutBadge: boolean;
shortcutNumber: number | null;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
onArchive?: () => void;
onMarkAsRead?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
}) {
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
{onArchive ? (
<WorkspaceKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
) : null}
</>
);
}
function WorkspaceKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={workspaceKebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.menu")}
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
{t("sidebar.workspace.actions.copyPath")}
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
{t("sidebar.workspace.actions.copyBranchName")}
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
{t("sidebar.workspace.actions.rename")}
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getWorkspaceRowStyle({
isDragging,
selected,
isHovered,
}: {
isDragging: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
const styles = StyleSheet.create((theme) => ({
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useRef } from "react";
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import type { useContextMenu } from "@/components/ui/context-menu";
export function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false);
const didStartDragRef = useRef(false);
const scrollIntentRef = useRef(false);
const menuOpenedRef = useRef(false);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dragArmTimerRef.current) {
clearTimeout(dragArmTimerRef.current);
dragArmTimerRef.current = null;
}
if (contextMenuTimerRef.current) {
clearTimeout(contextMenuTimerRef.current);
contextMenuTimerRef.current = null;
}
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
input.menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [input.menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
}, []);
useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
const armTimers = useCallback(() => {
clearTimers();
const DRAG_ARM_DELAY_MS = 180;
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
const CONTEXT_MENU_DELAY_MS = 450;
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
dragArmTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
return;
}
dragArmedRef.current = true;
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
input.drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController || platformIsWeb) {
return;
}
contextMenuTimerRef.current = setTimeout(() => {
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const start = touchStartRef.current;
const current = touchCurrentRef.current ?? start;
if (!start || !current) {
return;
}
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
return;
}
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
if (!dragActivatedRef.current) {
return;
}
didStartDragRef.current = true;
didLongPressRef.current = true;
clearTimers();
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
},
[clearTimers],
);
const handleScrollIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
scrollIntentRef.current = true;
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handleSwipeIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
didLongPressRef.current = false;
dragArmedRef.current = false;
dragActivatedRef.current = false;
didStartDragRef.current = false;
scrollIntentRef.current = false;
menuOpenedRef.current = false;
touchStartRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
touchCurrentRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
armTimers();
},
[armTimers],
);
const handleTouchMove = useCallback(
(event: GestureResponderEvent) => {
const start = touchStartRef.current;
if (!start || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
const x = touch?.pageX;
const y = touch?.pageY;
if (typeof x !== "number" || typeof y !== "number") {
return;
}
const current = { x, y };
touchCurrentRef.current = current;
const dx = current.x - start.x;
const dy = current.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const decision = decideLongPressMove({
dragArmed: dragArmedRef.current,
didStartDrag: didStartDragRef.current,
startPoint: start,
currentPoint: current,
});
if (decision === "vertical_scroll") {
handleScrollIntent({ dx, dy, distance });
return;
}
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
handleSwipeIntent({ dx, dy, distance });
return;
}
if (decision === "start_drag") {
handleDragIntent({ dx, dy, distance });
}
},
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
);
const handlePressOut = useCallback(() => {
clearTimers();
dragArmedRef.current = false;
dragActivatedRef.current = false;
touchStartRef.current = null;
touchCurrentRef.current = null;
}, [clearTimers]);
return {
didLongPressRef,
handleLongPress,
handlePressIn,
handleTouchMove,
handlePressOut,
};
}
@@ -337,14 +337,14 @@ export function TerminalPane({
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => keyboardShift.value > 0,
() => isMobile && keyboardShift.value > 0,
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
},
[pulseKeyboardRefits],
[isMobile, pulseKeyboardRefits],
);
useEffect(() => {
@@ -1,19 +1,14 @@
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
import { Keyboard, View, useWindowDimensions } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -68,18 +63,8 @@ export function AutocompletePopover({
// React Compiler memoizes effect captures by reading SharedValue.value during render.
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const windowDimensions = useWindowDimensions();
const insets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName();
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInsetSV = useSharedValue(insets.bottom);
useEffect(() => {
bottomInsetSV.value = insets.bottom;
}, [bottomInsetSV, insets.bottom]);
const shift = useDerivedValue(() =>
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
);
const { shift } = useKeyboardShift();
const openShift = useSharedValue(0);
useEffect(() => {
@@ -14,6 +14,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
@@ -94,7 +95,9 @@ async function callWorkspaceCreation({
worktreeSlug: createNameId(),
});
}
return connectedClient.openProject(input.cwd);
return connectedClient.createWorkspace({
source: { kind: "directory", path: input.cwd },
});
}
function failureMessageForCreationMethod(
@@ -322,7 +325,13 @@ export function WorkspaceSetupDialog() {
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
next.set(
agent.id,
applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: normalizeAgentSnapshot(agent, serverId),
}),
);
return next;
});
navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id });
@@ -61,6 +61,7 @@ import {
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
interface AgentControlOption {
id: string;
@@ -1486,10 +1487,13 @@ export const AgentControls = memo(function AgentControls({
console.warn("[AgentControls] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
},
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
);
@@ -22,6 +22,7 @@ import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -272,10 +273,13 @@ export const AgentModeControl = memo(function AgentModeControl({
const handleSelectMode = useCallback(
(modeId: string) => {
if (!client) return;
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, client, toast],
);
+17 -1
View File
@@ -196,6 +196,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null,
provider: agent?.provider ?? null,
};
};
}
@@ -205,6 +207,8 @@ function renderContextWindowMeter(
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
serverId: string,
provider: string | null,
): ReactElement | null {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
return null;
@@ -215,6 +219,8 @@ function renderContextWindowMeter(
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
serverId={serverId}
provider={provider}
/>
);
}
@@ -1643,8 +1649,17 @@ export function Composer({
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
[
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
serverId,
agentState.provider,
],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
@@ -1871,6 +1886,7 @@ export function Composer({
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
+13 -1
View File
@@ -94,6 +94,8 @@ export interface MessageInputProps {
submitIcon?: "arrow" | "return";
isSubmitDisabled?: boolean;
isSubmitLoading?: boolean;
/** When true, keep the grown input height after submit (text is preserved, not cleared). */
preserveHeightOnSubmit?: boolean;
attachments: ComposerAttachment[];
cwd: string;
attachmentMenuItems: AttachmentMenuItem[];
@@ -982,6 +984,7 @@ interface SendMessageContext {
isAgentRunning: boolean;
onSubmit: (payload: MessagePayload) => void;
onMinimizeHeight: () => void;
preserveHeightOnSubmit: boolean;
}
function sendMessageImpl(ctx: SendMessageContext): void {
@@ -1000,7 +1003,11 @@ function sendMessageImpl(ctx: SendMessageContext): void {
cwd: ctx.cwd,
forceSend: ctx.isAgentRunning || undefined,
});
ctx.onMinimizeHeight();
// When the host preserves and locks the composer (e.g. new-workspace creation),
// the text stays put — collapsing the height would clip it. Keep it grown.
if (!ctx.preserveHeightOnSubmit) {
ctx.onMinimizeHeight();
}
}
interface QueueMessageContext {
@@ -1141,6 +1148,7 @@ interface ResolvedMessageInputProps {
submitIcon: "arrow" | "return";
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
preserveHeightOnSubmit: boolean;
attachments: ComposerAttachment[];
cwd: string;
attachmentMenuItems: AttachmentMenuItem[];
@@ -1182,6 +1190,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
submitIcon: props.submitIcon ?? "arrow",
isSubmitDisabled: props.isSubmitDisabled ?? false,
isSubmitLoading: props.isSubmitLoading ?? false,
preserveHeightOnSubmit: props.preserveHeightOnSubmit ?? false,
attachments: props.attachments,
cwd: props.cwd,
attachmentMenuItems: props.attachmentMenuItems,
@@ -1231,6 +1240,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitIcon,
isSubmitDisabled,
isSubmitLoading,
preserveHeightOnSubmit,
attachments,
cwd,
attachmentMenuItems,
@@ -1546,6 +1556,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
}),
[
allowEmptySubmit,
@@ -1555,6 +1566,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
hasExternalContent,
minimizeInputHeight,
preserveHeightOnSubmit,
],
);
+113 -58
View File
@@ -71,7 +71,12 @@ import {
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -114,6 +119,56 @@ interface BufferedAudioChunk {
id: string;
}
interface WorkspaceHydrationSnapshot {
workspaces: Map<string, WorkspaceDescriptor>;
emptyProjects: Map<string, EmptyProjectDescriptor>;
}
async function fetchWorkspaceHydrationSnapshot(input: {
client: DaemonClient;
serverId: string;
subscribe: boolean;
isCancelled?: () => boolean;
}): Promise<WorkspaceHydrationSnapshot | null> {
const workspaces = new Map<string, WorkspaceDescriptor>();
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null;
let includeSubscribe = input.subscribe;
while (true) {
const payload = await input.client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
...(includeSubscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
if (input.isCancelled?.()) {
return null;
}
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) {
continue;
}
workspaces.set(workspace.id, workspace);
}
// Project parents with no active workspaces only ride on the first page.
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
includeSubscribe = false;
}
return { workspaces, emptyProjects };
}
function decodeBase64Chunk(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
@@ -477,6 +532,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
const setEmptyProjects = useSessionStore((state) => state.setEmptyProjects);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const removeEmptyProject = useSessionStore((state) => state.removeEmptyProject);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const removeWorkspace = useSessionStore((state) => state.removeWorkspace);
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
@@ -543,48 +599,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return;
}
const workspaces = new Map<string, WorkspaceDescriptor>();
const emptyProjects = new Map<string, EmptyProjectDescriptor>();
let cursor: string | null = null;
let includeSubscribe = options?.subscribe ?? false;
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
...(includeSubscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
if (options?.isCancelled?.()) {
return;
}
for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
workspaces.set(workspace.id, workspace);
}
// Empty project parents only ride on the first page.
for (const project of payload.emptyProjects ?? []) {
const descriptor = normalizeEmptyProjectDescriptor(project);
emptyProjects.set(descriptor.projectId, descriptor);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
includeSubscribe = false;
}
if (options?.isCancelled?.()) {
const snapshot = await fetchWorkspaceHydrationSnapshot({
client,
serverId,
subscribe: options?.subscribe ?? false,
isCancelled: options?.isCancelled,
});
if (!snapshot || options?.isCancelled?.()) {
return;
}
setWorkspaces(serverId, workspaces);
setEmptyProjects(serverId, emptyProjects.values());
const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({
client,
serverId,
workspaces: snapshot.workspaces,
emptyProjects: snapshot.emptyProjects,
isCancelled: options?.isCancelled,
});
if (didBackfillLegacy) {
return;
}
setWorkspaces(serverId, snapshot.workspaces);
setEmptyProjects(serverId, snapshot.emptyProjects.values());
setHasHydratedWorkspaces(serverId, true);
},
[client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces],
@@ -1044,13 +1081,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
const normalized = normalizeAgentSnapshot(update.agent, serverId);
const agent = {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
};
const agent = applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: update.project,
cwd: normalized.cwd,
}),
},
});
applyAuthoritativeAgentSnapshot(agent);
},
@@ -1110,10 +1150,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (payload.agent) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot({
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
});
applyAuthoritativeAgentSnapshot(
applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: {
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
},
}),
);
}
// Call pure reducer
@@ -1305,6 +1350,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.payload.emptyProject) {
addEmptyProject(serverId, normalizeEmptyProjectDescriptor(message.payload.emptyProject));
}
if (message.payload.removedProjectId) {
removeEmptyProject(serverId, message.payload.removedProjectId);
}
return;
}
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
@@ -1728,6 +1776,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
removeWorkspace,
removeWorkspaceSetup,
addEmptyProject,
removeEmptyProject,
setAgentLastActivity,
setPendingPermissions,
setHasHydratedAgents,
@@ -1903,10 +1952,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentMode skipped: daemon unavailable");
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
},
[client, toast],
);
@@ -1931,10 +1983,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable");
return;
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
},
[client, toast],
);
@@ -84,4 +84,27 @@ describe("create agent preferences", () => {
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
it("persists and reloads the workspace isolation choice", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const save = preferences.update({ isolation: "worktree" });
await storage.nextWrite();
storage.finishOldestWrite();
await save;
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
expect(await new CreateAgentPreferencesService(storage).load()).toEqual({
isolation: "worktree",
});
});
it("treats stored preferences without an isolation choice as undefined", () => {
expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined();
});
it("rejects an unknown isolation value as invalid stored preferences", () => {
expect(parseFormPreferences({ provider: "codex", isolation: "sandbox" })).toEqual({});
});
});
@@ -18,13 +18,13 @@ export interface FavoriteModelRow {
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string()).optional(),
featureValues: z.record(z.unknown()).optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
featureValues: z.record(z.string(), z.unknown()).optional(),
});
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(providerPreferencesSchema).optional(),
providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(),
favoriteModels: z
.array(
z.object({
@@ -33,6 +33,7 @@ const formPreferencesSchema = z.object({
}),
)
.optional(),
isolation: z.enum(["local", "worktree"]).optional(),
});
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
+20 -20
View File
@@ -18,10 +18,10 @@ const CATALOG_DATA = [
title: "Agoragentic",
description:
"Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
version: "1.3.2",
version: "1.3.3",
iconId: "agoragentic-acp",
installLink: "https://agoragentic.com",
command: ["npx", "-y", "agoragentic-mcp@1.3.2", "--acp"],
command: ["npx", "-y", "agoragentic-mcp@1.3.3", "--acp"],
},
{
id: "amp-acp",
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.24",
version: "3.0.27",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.24", "--acp"],
command: ["npx", "-y", "cline@3.0.27", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.106.7",
version: "2.108.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.106.7", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.108.2", "--acp"],
},
{
id: "codewhale",
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.12",
version: "0.1.15",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.12"],
command: ["npx", "-y", "deepagents-acp@0.1.15"],
},
{
id: "devin",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.3",
version: "0.2.7",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.3", "acp"],
command: ["npx", "-y", "dimcode@0.2.7", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.0",
version: "0.4.1",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.0", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.1", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.150.0",
version: "0.151.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.150.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.151.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -182,10 +182,10 @@ const CATALOG_DATA = [
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.46.0",
version: "0.47.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.46.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.47.0", "--acp"],
},
{
id: "glm-acp-agent",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.22",
version: "1.0.24",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.22", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.24", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.18.1",
version: "0.18.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.1", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",
+48 -10
View File
@@ -377,7 +377,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge",
label: "Merge PR (squash)",
});
});
@@ -398,7 +398,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge",
label: "Merge PR (squash)",
});
});
@@ -458,7 +458,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge",
label: "Merge PR (squash)",
});
});
@@ -512,7 +512,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge",
label: "Merge PR (squash)",
});
expect(actions.secondary.some((action) => action.id === "merge-branch")).toBe(true);
});
@@ -583,7 +583,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-squash",
label: "Merge",
label: "Merge PR (squash)",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -592,7 +592,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-merge",
label: "Merge",
label: "Merge PR (merge)",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -601,7 +601,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-rebase",
label: "Merge",
label: "Merge PR (rebase)",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -703,7 +703,10 @@ describe("git-actions-policy", () => {
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Merge" });
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
@@ -744,7 +747,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "enable-pr-auto-merge-squash",
label: "Auto merge",
label: "Auto merge (squash)",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
@@ -762,6 +765,41 @@ describe("git-actions-policy", () => {
).toBe(false);
});
it.each([
["SQUASH", "enable-pr-auto-merge-squash", "Auto merge (squash)"],
["MERGE", "enable-pr-auto-merge-merge", "Auto merge (merge)"],
["REBASE", "enable-pr-auto-merge-rebase", "Auto merge (rebase)"],
] as const)(
"labels the %s auto-merge action with its method",
(viewerDefaultMergeMethod, id, label) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/993",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod,
},
}),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id, label });
},
);
it("does not offer auto-merge when the daemon feature gate is missing", () => {
const actions = buildGitActions(
createInput({
@@ -851,7 +889,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-merge",
label: "Merge",
label: "Merge PR (merge)",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
@@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest";
import { formatPrTabLabel } from "./tab-label";
describe("formatPrTabLabel", () => {
it("returns #<number> when a pull request number is present", () => {
expect(formatPrTabLabel(42)).toBe("#42");
it("returns the number when a pull request number is present", () => {
expect(formatPrTabLabel(42)).toBe("42");
});
it("returns #— fallback when the pull request number is null", () => {
expect(formatPrTabLabel(null)).toBe("#—");
it("returns the — fallback when the pull request number is null", () => {
expect(formatPrTabLabel(null)).toBe("—");
});
});
@@ -1,3 +1,3 @@
export function formatPrTabLabel(prNumber: number | null): string {
return prNumber === null ? "#—" : `#${prNumber}`;
return prNumber === null ? "—" : `${prNumber}`;
}
@@ -0,0 +1,23 @@
export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
export function resolveKeyboardShift(input: {
rawKeyboardHeight: number;
keyboardProgress: number;
bottomInset: number;
isIos: boolean;
iosMinHeight: number;
}): number {
"worklet";
if (!(input.keyboardProgress > 0) || !(input.rawKeyboardHeight > 0)) {
return 0;
}
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
return 0;
}
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
+23 -23
View File
@@ -1,9 +1,11 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { OpenProjectResponseMessage } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { ProjectAddResponse } from "@getpaseo/protocol/messages";
import {
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
} from "@/stores/session-store";
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
export interface OpenProjectSuccess {
@@ -22,11 +24,10 @@ export interface OpenProjectDirectlyInput {
serverId: string;
projectPath: string;
isConnected: boolean;
client: Pick<DaemonClient, "openProject"> | null;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
canAddProject: boolean;
client: Pick<DaemonClient, "addProject"> | null;
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
openDraftTab: (workspaceKey: string) => string | null;
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
}
export async function openProjectDirectly(
@@ -38,8 +39,16 @@ export async function openProjectDirectly(
return { ok: false, errorCode: null, error: null };
}
const payload = await input.client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
if (!input.canAddProject) {
return {
ok: false,
errorCode: null,
error: "Update the host to add projects without creating a workspace.",
};
}
const payload = await input.client.addProject(trimmedPath);
if (payload.error || !payload.project) {
return {
ok: false,
errorCode: payload.errorCode ?? null,
@@ -47,19 +56,10 @@ export async function openProjectDirectly(
};
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
input.mergeWorkspaces(normalizedServerId, [workspace]);
input.addEmptyProject(
normalizedServerId,
normalizeProjectWithoutWorkspacesDescriptor(payload.project),
);
input.setHasHydratedWorkspaces(normalizedServerId, true);
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: workspace.id,
});
if (!workspaceKey) {
return { ok: false, errorCode: null, error: null };
}
input.openDraftTab(workspaceKey);
input.navigateToWorkspace(normalizedServerId, workspace.id);
return { ok: true };
}
@@ -69,6 +69,7 @@ export async function fetchAgentHistoryPage(input: {
archivedAt: agent.archivedAt ?? null,
createdAt: agent.createdAt,
labels: agent.labels,
projectPlacement: agent.projectPlacement,
})),
pageInfo: payload.pageInfo,
};
@@ -87,6 +87,7 @@ export function useAggregatedAgents(options?: {
archivedAt: agent.archivedAt,
createdAt: agent.createdAt,
labels: agent.labels,
projectPlacement: agent.projectPlacement,
};
const cacheKey = `${serverId}:${agent.id}`;
const prev = prevAgentsRef.current.get(cacheKey);
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveKeyboardShift } from "./keyboard-shift-policy";
describe("resolveKeyboardShift", () => {
it("keeps the existing open-keyboard offset behavior", () => {
expect(
resolveKeyboardShift({
rawKeyboardHeight: 320,
keyboardProgress: 1,
bottomInset: 24,
isIos: false,
iosMinHeight: 120,
}),
).toBe(296);
});
it("treats progress zero as closed even when Android reports a stale height", () => {
expect(
resolveKeyboardShift({
rawKeyboardHeight: 320,
keyboardProgress: 0,
bottomInset: 24,
isIos: false,
iosMinHeight: 120,
}),
).toBe(0);
});
it("still ignores small iOS accessory bar reports", () => {
expect(
resolveKeyboardShift({
rawKeyboardHeight: 80,
keyboardProgress: 1,
bottomInset: 0,
isIos: true,
iosMinHeight: 120,
}),
).toBe(0);
});
});
@@ -1,4 +1,11 @@
import { useEffect } from "react";
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -9,47 +16,25 @@ import {
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
function resolveKeyboardShift(input: {
rawKeyboardHeight: number;
bottomInset: number;
isIos: boolean;
iosMinHeight: number;
enabled: boolean;
}): number {
"worklet";
if (!input.enabled) {
return 0;
}
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
return 0;
}
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
} from "@/hooks/keyboard-shift-policy";
type KeyboardShiftMode = "translate" | "padding";
export function useKeyboardShiftStyle(input: {
mode: KeyboardShiftMode;
enabled?: boolean;
iosMinHeight?: number;
}): {
interface KeyboardShiftContextValue {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
} {
bottomInset: SharedValue<number>;
}
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const enabled = input.enabled ?? true;
const isIos = Platform.OS === "ios";
const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT;
useEffect(() => {
bottomInset.value = insets.bottom;
@@ -57,19 +42,45 @@ export function useKeyboardShiftStyle(input: {
const shift = useDerivedValue(() => {
"worklet";
const rawKeyboardHeight = Math.abs(keyboardHeight.value);
return resolveKeyboardShift({
rawKeyboardHeight,
rawKeyboardHeight: Math.abs(keyboardHeight.value),
keyboardProgress: keyboardProgress.value,
bottomInset: bottomInset.value,
isIos,
iosMinHeight,
enabled,
iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
});
});
const value = useMemo(
() => ({
shift,
bottomInset,
}),
[bottomInset, shift],
);
return createElement(KeyboardShiftContext.Provider, { value }, children);
}
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
} {
const { shift, bottomInset } = useKeyboardShift();
const mode = input.mode;
const enabled = input.enabled ?? true;
const style = useAnimatedStyle<ViewStyle>(() => {
"worklet";
if (input.mode === "padding") {
if (mode === "padding") {
if (!enabled) {
return { paddingBottom: 0 };
}
@@ -77,8 +88,8 @@ export function useKeyboardShiftStyle(input: {
return { paddingBottom: bottomInset.value + shift.value };
}
return { transform: [{ translateY: -shift.value }] };
}, [input.mode]);
return { transform: [{ translateY: enabled ? -shift.value : 0 }] };
}, [enabled, mode]);
return { shift, style };
}
@@ -9,6 +9,7 @@ import {
type ChordState,
resolveKeyboardShortcut,
buildEffectiveBindings,
getWorkspaceIndexJumpModifierKey,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import {
@@ -73,6 +74,20 @@ export function useKeyboardShortcuts({
const isDesktopApp = getIsElectronRuntime();
const isMac = getShortcutOs() === "mac";
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
// to drive badge visibility, so we set the flag matching this runtime.
const badgeModifierKey = getWorkspaceIndexJumpModifierKey({ isMac, isDesktop: isDesktopApp });
const setBadgeModifierDown = (down: boolean) => {
const state = useKeyboardShortcutsStore.getState();
if (isDesktopApp) {
state.setCmdOrCtrlDown(down);
} else {
state.setAltDown(down);
}
};
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
@@ -156,11 +171,8 @@ export function useKeyboardShortcuts({
}
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
}
if (isDesktopApp && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
if (key === badgeModifierKey && !event.shiftKey) {
setBadgeModifierDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
@@ -231,11 +243,8 @@ export function useKeyboardShortcuts({
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false);
}
if (isDesktopApp && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
if (key === badgeModifierKey) {
setBadgeModifierDown(false);
}
};
+60 -79
View File
@@ -1,32 +1,22 @@
import { describe, expect, it } from "vitest";
import { openProjectDirectly } from "@/hooks/open-project";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
const SERVER_ID = "server-1";
const PROJECT_PATH = "/repo/project";
function buildWorkspacePayload() {
function buildProjectPayload() {
return {
id: "1",
projectId: "1",
projectId: "project-1",
projectDisplayName: "project",
projectRootPath: PROJECT_PATH,
workspaceDirectory: PROJECT_PATH,
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
archivingAt: null,
status: "done" as const,
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
};
}
interface RecordedMerge {
interface RecordedProject {
serverId: string;
workspaces: WorkspaceDescriptor[];
project: ProjectWithoutWorkspacesDescriptor;
}
interface RecordedHydrated {
@@ -34,23 +24,14 @@ interface RecordedHydrated {
hydrated: boolean;
}
interface RecordedOpenDraftTab {
workspaceKey: string;
}
interface RecordedNavigate {
serverId: string;
workspaceId: string;
}
function createFakeSession() {
const merges: RecordedMerge[] = [];
const projects: RecordedProject[] = [];
const hydrated: RecordedHydrated[] = [];
return {
merges,
projects,
hydrated,
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
merges.push({ serverId, workspaces: Array.from(workspaces) });
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
projects.push({ serverId, project });
},
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
hydrated.push({ serverId, hydrated: value });
@@ -58,86 +39,88 @@ function createFakeSession() {
};
}
function createFakeWorkspaceLayout() {
const openedTabs: RecordedOpenDraftTab[] = [];
return {
openedTabs,
openDraftTab: (workspaceKey: string) => {
openedTabs.push({ workspaceKey });
return "tab-1";
},
};
}
function createFakeNavigator() {
const navigations: RecordedNavigate[] = [];
return {
navigations,
navigateToWorkspace: (serverId: string, workspaceId: string) => {
navigations.push({ serverId, workspaceId });
},
};
}
describe("openProjectDirectly", () => {
it("opens the workspace, marks workspaces hydrated, and seeds a draft tab", async () => {
it("adds the project and marks workspaces hydrated without opening a workspace", async () => {
const session = createFakeSession();
const layout = createFakeWorkspaceLayout();
const navigator = createFakeNavigator();
const workspacePayload = buildWorkspacePayload();
const projectPayload = buildProjectPayload();
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: PROJECT_PATH,
isConnected: true,
canAddProject: true,
client: {
openProject: async () => ({
addProject: async () => ({
requestId: "request-1",
error: null,
workspace: workspacePayload,
project: projectPayload,
}),
},
mergeWorkspaces: session.mergeWorkspaces,
addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
openDraftTab: layout.openDraftTab,
navigateToWorkspace: navigator.navigateToWorkspace,
});
expect(result).toEqual({ ok: true });
expect(session.merges).toHaveLength(1);
expect(session.merges[0]?.serverId).toBe(SERVER_ID);
expect(session.merges[0]?.workspaces[0]).toMatchObject({
id: "1",
projectId: "1",
projectRootPath: PROJECT_PATH,
workspaceDirectory: PROJECT_PATH,
});
expect(session.projects).toEqual([
{
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
projectRootPath: PROJECT_PATH,
},
},
]);
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]);
expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]);
});
it("does not navigate or seed tabs when openProject fails", async () => {
it("fails before sending when the host does not support adding projects without workspaces", async () => {
const session = createFakeSession();
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: PROJECT_PATH,
isConnected: true,
canAddProject: false,
client: {
addProject: async () => ({
requestId: "request-unsupported",
error: null,
project: buildProjectPayload(),
}),
},
addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
});
expect(result).toEqual({
ok: false,
errorCode: null,
error: "Update the host to add projects without creating a workspace.",
});
expect(session.projects).toEqual([]);
expect(session.hydrated).toEqual([]);
});
it("does not add a project when addProject fails", async () => {
const session = createFakeSession();
const layout = createFakeWorkspaceLayout();
const navigator = createFakeNavigator();
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: PROJECT_PATH,
isConnected: true,
canAddProject: true,
client: {
openProject: async () => ({
addProject: async () => ({
requestId: "request-2",
error: "Directory not found: /repo/project",
errorCode: "directory_not_found" as const,
workspace: null,
project: null,
}),
},
mergeWorkspaces: session.mergeWorkspaces,
addEmptyProject: session.addEmptyProject,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
openDraftTab: layout.openDraftTab,
navigateToWorkspace: navigator.navigateToWorkspace,
});
expect(result).toEqual({
@@ -145,9 +128,7 @@ describe("openProjectDirectly", () => {
errorCode: "directory_not_found",
error: "Directory not found: /repo/project",
});
expect(session.merges).toEqual([]);
expect(session.projects).toEqual([]);
expect(session.hydrated).toEqual([]);
expect(layout.openedTabs).toEqual([]);
expect(navigator.navigations).toEqual([]);
});
});
+16 -12
View File
@@ -1,9 +1,6 @@
import { useCallback } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
export function useOpenProject(
@@ -12,7 +9,12 @@ export function useOpenProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const canAddProject = useSessionStore((state) =>
normalizedServerId
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
: false,
);
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
@@ -21,17 +23,19 @@ export function useOpenProject(
serverId: normalizedServerId,
projectPath: path,
isConnected,
canAddProject,
client,
mergeWorkspaces,
addEmptyProject,
setHasHydratedWorkspaces,
openDraftTab: (workspaceKey: string) =>
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
kind: "draft",
draftId: generateDraftId(),
}),
navigateToWorkspace,
});
},
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
[
addEmptyProject,
canAddProject,
client,
isConnected,
normalizedServerId,
setHasHydratedWorkspaces,
],
);
}
+4 -1
View File
@@ -249,6 +249,9 @@ describe("translation resources", () => {
it("includes sidebar and workspace creation keys for the Batch 4C migration", () => {
expect(en.sidebar.workspace.actions.copyPath).toBe("Copy path");
expect(en.sidebar.project.confirmations.removeTitle).toBe("Remove project?");
expect(en.sidebar.project.toasts.updateHostToRemove).toBe(
"Update the host to remove projects.",
);
expect(en.newWorkspace.title).toBe("New workspace");
expect(en.newWorkspace.refPicker.searchPlaceholder).toBe("Search branches and PRs");
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
@@ -291,7 +294,7 @@ describe("translation resources", () => {
});
it("includes sessions and agent list keys for the Batch 4H migration", () => {
expect(en.sessions.title).toBe("Agent history");
expect(en.sessions.title).toBe("History");
expect(en.sessions.empty).toBe("No sessions yet");
expect(en.sessions.actions.loadMore).toBe("Load more");
expect(en.agentList.fallbackTitle).toBe("New session");
+18 -8
View File
@@ -206,7 +206,7 @@ export const ar: TranslationResources = {
},
},
sessions: {
title: "سجل الوكلاء",
title: "السجل",
empty: "لا توجد جلسات بعد",
actions: {
loadMore: "تحميل المزيد",
@@ -328,11 +328,14 @@ export const ar: TranslationResources = {
workspace: {
route: {
loading: "جارٍ تحميل مساحة العمل",
restoring: "جارٍ استعادة مساحة العمل",
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
connecting: "الاتصال",
hostOffline: "{{hostName}}غير متواجد حالياً",
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
hostStatus: "حالة Host:{{status}}",
missing: "لم يتم العثور على Workspace",
needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
manageHost: "إدارة المضيف",
},
hoverCard: {
@@ -601,16 +604,16 @@ export const ar: TranslationResources = {
success: "مؤرشف",
},
mergePr: {
squash: "دمج",
merge: "دمج",
rebase: "دمج",
squash: "دمج PR (squash)",
merge: "دمج PR (merge)",
rebase: "دمج PR (rebase)",
pending: "دمج PR...",
success: "تم دمج PR",
},
autoMerge: {
enableSquash: "دمج تلقائي",
enableMerge: "دمج تلقائي",
enableRebase: "دمج تلقائي",
enableSquash: "دمج تلقائي (squash)",
enableMerge: "دمج تلقائي (merge)",
enableRebase: "دمج تلقائي (rebase)",
enabled: "تم تمكين الدمج التلقائي",
enabling: "تمكين الدمج التلقائي...",
disabling: "تعطيل الدمج التلقائي...",
@@ -784,6 +787,7 @@ export const ar: TranslationResources = {
toasts: {
hostDisconnected: "Host غير متصل",
removeFailed: "فشل في إزالة بعض مساحات العمل",
updateHostToRemove: "قم بتحديث Host لإزالة المشاريع.",
},
empty: {
title: "لا توجد مشاريع حتى الآن",
@@ -833,7 +837,7 @@ export const ar: TranslationResources = {
newWorkspace: {
title: "مساحة عمل جديدة",
create: "يخلق",
backing: {
isolation: {
local: "محلي",
worktree: "شجرة عمل جديدة",
label: "العزل",
@@ -1303,6 +1307,8 @@ export const ar: TranslationResources = {
backdrop: "خلفية القائمة",
},
subagents: {
detachAction: "فصل {{label}}",
detachTooltip: "فصل الوكيل الفرعي",
archiveAction: "أرشيف{{label}}",
archiveTooltip: "أرشفة الوكيل الفرعي",
},
@@ -1388,6 +1394,7 @@ export const ar: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
host: "Host",
},
@@ -1774,6 +1781,9 @@ export const ar: TranslationResources = {
button: "التشخيص",
refresh: "ينعش",
refreshing: "منعش...",
copyLabel: "التشخيص",
copyAccessibility: "نسخ التشخيص",
copyFailed: "فشل نسخ التشخيص",
refreshAccessibility: "تحديث التشخيص",
refreshingAccessibility: "تحديث التشخيص",
running: "تشغيل التشخيص...",
+19 -8
View File
@@ -205,7 +205,7 @@ export const en = {
},
},
sessions: {
title: "Agent history",
title: "History",
empty: "No sessions yet",
actions: {
loadMore: "Load more",
@@ -327,11 +327,15 @@ export const en = {
workspace: {
route: {
loading: "Loading workspace",
restoring: "Restoring workspace",
restoreFailed:
"Couldn't restore this workspace — the directory may have been moved or deleted",
connecting: "Connecting",
hostOffline: "{{hostName}} is offline",
cannotReachHost: "Cannot reach {{hostName}}",
hostStatus: "Host status: {{status}}",
missing: "Workspace not found",
needsHostUpgrade: "Update your host to restore this workspace",
manageHost: "Manage host",
},
hoverCard: {
@@ -599,16 +603,16 @@ export const en = {
success: "Archived",
},
mergePr: {
squash: "Merge",
merge: "Merge",
rebase: "Merge",
squash: "Merge PR (squash)",
merge: "Merge PR (merge)",
rebase: "Merge PR (rebase)",
pending: "Merging PR...",
success: "PR merged",
},
autoMerge: {
enableSquash: "Auto merge",
enableMerge: "Auto merge",
enableRebase: "Auto merge",
enableSquash: "Auto merge (squash)",
enableMerge: "Auto merge (merge)",
enableRebase: "Auto merge (rebase)",
enabled: "Auto-merge enabled",
enabling: "Enabling auto-merge...",
disabling: "Disabling auto-merge...",
@@ -790,6 +794,7 @@ export const en = {
toasts: {
hostDisconnected: "Host is not connected",
removeFailed: "Failed to remove some workspaces",
updateHostToRemove: "Update the host to remove projects.",
},
empty: {
title: "No projects yet",
@@ -839,7 +844,7 @@ export const en = {
newWorkspace: {
title: "New workspace",
create: "Create",
backing: {
isolation: {
local: "Local",
worktree: "New worktree",
label: "Isolation",
@@ -1309,6 +1314,8 @@ export const en = {
backdrop: "Menu backdrop",
},
subagents: {
detachAction: "Detach {{label}}",
detachTooltip: "Detach subagent",
archiveAction: "Archive {{label}}",
archiveTooltip: "Archive subagent",
},
@@ -1394,6 +1401,7 @@ export const en = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
host: "Host",
},
@@ -1781,6 +1789,9 @@ export const en = {
button: "Diagnostic",
refresh: "Refresh",
refreshing: "Refreshing...",
copyLabel: "diagnostic",
copyAccessibility: "Copy diagnostic",
copyFailed: "Failed to copy diagnostic",
refreshAccessibility: "Refresh diagnostic",
refreshingAccessibility: "Refreshing diagnostic",
running: "Running diagnostic...",
+21 -10
View File
@@ -209,7 +209,7 @@ export const es: TranslationResources = {
},
},
sessions: {
title: "Historial de agentes",
title: "Historial",
empty: "Aún no hay sesiones",
actions: {
loadMore: "Cargar más",
@@ -331,11 +331,15 @@ export const es: TranslationResources = {
workspace: {
route: {
loading: "Cargando espacio de trabajo",
restoring: "Restaurando espacio de trabajo",
restoreFailed:
"No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado",
connecting: "Conectando",
hostOffline: "{{hostName}}está desconectado",
cannotReachHost: "No se puede alcanzar{{hostName}}",
hostStatus: "Estado deHost:{{status}}",
missing: "Workspaceno encontrado",
needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo",
manageHost: "Administrar host",
},
hoverCard: {
@@ -606,16 +610,16 @@ export const es: TranslationResources = {
success: "Archivado",
},
mergePr: {
squash: "Fusionar",
merge: "Fusionar",
rebase: "Fusionar",
pending: "FusionandoPR...",
success: "PRfusionado",
squash: "Fusionar PR (squash)",
merge: "Fusionar PR (merge)",
rebase: "Fusionar PR (rebase)",
pending: "Fusionando PR...",
success: "PR fusionado",
},
autoMerge: {
enableSquash: "Fusión automática",
enableMerge: "Fusión automática",
enableRebase: "Fusión automática",
enableSquash: "Fusión automática (squash)",
enableMerge: "Fusión automática (merge)",
enableRebase: "Fusión automática (rebase)",
enabled: "Combinación automática habilitada",
enabling: "Habilitando la fusión automática...",
disabling: "Desactivando la fusión automática...",
@@ -810,6 +814,7 @@ export const es: TranslationResources = {
toasts: {
hostDisconnected: "Hostno está conectado",
removeFailed: "No se pudieron eliminar algunos espacios de trabajo",
updateHostToRemove: "Actualiza el host para eliminar proyectos.",
},
empty: {
title: "Aún no hay proyectos",
@@ -859,7 +864,7 @@ export const es: TranslationResources = {
newWorkspace: {
title: "Nuevo espacio de trabajo",
create: "Crear",
backing: {
isolation: {
local: "Local",
worktree: "Nuevo worktree",
label: "Aislamiento",
@@ -1338,6 +1343,8 @@ export const es: TranslationResources = {
backdrop: "Fondo del menú",
},
subagents: {
detachAction: "Separar {{label}}",
detachTooltip: "Separar subagente",
archiveAction: "Archivo{{label}}",
archiveTooltip: "Subagente de archivo",
},
@@ -1423,6 +1430,7 @@ export const es: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
host: "Host",
},
@@ -1814,6 +1822,9 @@ export const es: TranslationResources = {
button: "Diagnóstico",
refresh: "Refrescar",
refreshing: "Refrescante...",
copyLabel: "diagnóstico",
copyAccessibility: "Copiar diagnóstico",
copyFailed: "No se pudo copiar el diagnóstico",
refreshAccessibility: "Actualizar diagnóstico",
refreshingAccessibility: "Diagnóstico refrescante",
running: "Ejecutando diagnóstico...",
+21 -10
View File
@@ -209,7 +209,7 @@ export const fr: TranslationResources = {
},
},
sessions: {
title: "Historique des agents",
title: "Historique",
empty: "Aucune séance pour l'instant",
actions: {
loadMore: "Charger plus",
@@ -331,11 +331,15 @@ export const fr: TranslationResources = {
workspace: {
route: {
loading: "Chargement de l'espace de travail",
restoring: "Restauration de l'espace de travail",
restoreFailed:
"Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé",
connecting: "De liaison",
hostOffline: "{{hostName}}est hors ligne",
cannotReachHost: "Impossible d'atteindre{{hostName}}",
hostStatus: "StatutHost:{{status}}",
missing: "Workspaceintrouvable",
needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail",
manageHost: "Gérer l'hôte",
},
hoverCard: {
@@ -606,16 +610,16 @@ export const fr: TranslationResources = {
success: "Archivé",
},
mergePr: {
squash: "Fusionner",
merge: "Fusionner",
rebase: "Fusionner",
pending: "Fusion dePR...",
success: "PRfusionné",
squash: "Fusionner PR (squash)",
merge: "Fusionner PR (merge)",
rebase: "Fusionner PR (rebase)",
pending: "Fusion de PR...",
success: "PR fusionné",
},
autoMerge: {
enableSquash: "Fusion automatique",
enableMerge: "Fusion automatique",
enableRebase: "Fusion automatique",
enableSquash: "Fusion automatique (squash)",
enableMerge: "Fusion automatique (merge)",
enableRebase: "Fusion automatique (rebase)",
enabled: "Fusion automatique activée",
enabling: "Activation de la fusion automatique...",
disabling: "Désactivation de la fusion automatique...",
@@ -809,6 +813,7 @@ export const fr: TranslationResources = {
toasts: {
hostDisconnected: "Hostn'est pas connecté",
removeFailed: "Échec de la suppression de certains espaces de travail",
updateHostToRemove: "Mettez à jour le host pour supprimer des projets.",
},
empty: {
title: "Aucun projet pour l'instant",
@@ -858,7 +863,7 @@ export const fr: TranslationResources = {
newWorkspace: {
title: "Nouvel espace de travail",
create: "Créer",
backing: {
isolation: {
local: "Local",
worktree: "Nouveau worktree",
label: "Isolation",
@@ -1341,6 +1346,8 @@ export const fr: TranslationResources = {
backdrop: "Toile de fond du menu",
},
subagents: {
detachAction: "Detacher {{label}}",
detachTooltip: "Detacher le sous-agent",
archiveAction: "Archiver{{label}}",
archiveTooltip: "Sous-agent d'archivage",
},
@@ -1426,6 +1433,7 @@ export const fr: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
host: "Host",
},
@@ -1819,6 +1827,9 @@ export const fr: TranslationResources = {
button: "Diagnostique",
refresh: "Rafraîchir",
refreshing: "Rafraîchissant...",
copyLabel: "diagnostic",
copyAccessibility: "Copier le diagnostic",
copyFailed: "Échec de la copie du diagnostic",
refreshAccessibility: "Actualiser le diagnostic",
refreshingAccessibility: "Diagnostic rafraîchissant",
running: "Exécution du diagnostic...",
+19 -8
View File
@@ -208,7 +208,7 @@ export const ru: TranslationResources = {
},
},
sessions: {
title: "История агентов",
title: "История",
empty: "Сеансов пока нет",
actions: {
loadMore: "Загрузить больше",
@@ -330,11 +330,15 @@ export const ru: TranslationResources = {
workspace: {
route: {
loading: "Загрузка рабочей области",
restoring: "Восстановление рабочей области",
restoreFailed:
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
connecting: "Подключение",
hostOffline: "{{hostName}}не в сети",
cannotReachHost: "Невозможно связаться с{{hostName}}",
hostStatus: "Статус Host:{{status}}",
missing: "Workspace не найден",
needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
manageHost: "Управление хостом",
},
hoverCard: {
@@ -605,16 +609,16 @@ export const ru: TranslationResources = {
success: "В архиве",
},
mergePr: {
squash: "Объединить",
merge: "Объединить",
rebase: "Объединить",
squash: "Объединить PR (squash)",
merge: "Объединить PR (merge)",
rebase: "Объединить PR (rebase)",
pending: "Объединение PR...",
success: "PR объединен",
},
autoMerge: {
enableSquash: "Автообъединение",
enableMerge: "Автообъединение",
enableRebase: "Автообъединение",
enableSquash: "Автообъединение (squash)",
enableMerge: "Автообъединение (merge)",
enableRebase: "Автообъединение (rebase)",
enabled: "Автоматическое объединение включено",
enabling: "Включение автоматического объединения...",
disabling: "Отключение автоматического объединения...",
@@ -802,6 +806,7 @@ export const ru: TranslationResources = {
toasts: {
hostDisconnected: "Host не подключен",
removeFailed: "Не удалось удалить некоторые рабочие области.",
updateHostToRemove: "Обновите host, чтобы удалять проекты.",
},
empty: {
title: "Пока нет проектов",
@@ -851,7 +856,7 @@ export const ru: TranslationResources = {
newWorkspace: {
title: "Новое рабочее пространство",
create: "Создавать",
backing: {
isolation: {
local: "Локально",
worktree: "Новый worktree",
label: "Изоляция",
@@ -1330,6 +1335,8 @@ export const ru: TranslationResources = {
backdrop: "Фон меню",
},
subagents: {
detachAction: "Отсоединить {{label}}",
detachTooltip: "Отсоединить субагент",
archiveAction: "Архив{{label}}",
archiveTooltip: "Архивный субагент",
},
@@ -1415,6 +1422,7 @@ export const ru: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
host: "Host",
},
@@ -1805,6 +1813,9 @@ export const ru: TranslationResources = {
button: "Диагностика",
refresh: "Обновить",
refreshing: "Освежающий...",
copyLabel: "диагностика",
copyAccessibility: "Скопировать диагностику",
copyFailed: "Не удалось скопировать диагностику",
refreshAccessibility: "Обновить диагностику",
refreshingAccessibility: "Обновление диагностики",
running: "Запускаю диагностику...",
+18 -8
View File
@@ -206,7 +206,7 @@ export const zhCN: TranslationResources = {
},
},
sessions: {
title: "Agent 历史",
title: "历史",
empty: "还没有会话",
actions: {
loadMore: "加载更多",
@@ -328,11 +328,14 @@ export const zhCN: TranslationResources = {
workspace: {
route: {
loading: "正在加载 workspace",
restoring: "正在恢复 workspace",
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
connecting: "正在连接",
hostOffline: "{{hostName}} 已离线",
cannotReachHost: "无法连接 {{hostName}}",
hostStatus: "Host 状态:{{status}}",
missing: "Workspace 未找到",
needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
manageHost: "管理 Host",
},
hoverCard: {
@@ -599,16 +602,16 @@ export const zhCN: TranslationResources = {
success: "已归档",
},
mergePr: {
squash: "Merge",
merge: "Merge",
rebase: "Merge",
squash: "Merge PR (squash)",
merge: "Merge PR (merge)",
rebase: "Merge PR (rebase)",
pending: "正在 merge PR...",
success: "PR 已 merge",
},
autoMerge: {
enableSquash: "Auto merge",
enableMerge: "Auto merge",
enableRebase: "Auto merge",
enableSquash: "Auto merge (squash)",
enableMerge: "Auto merge (merge)",
enableRebase: "Auto merge (rebase)",
enabled: "Auto-merge 已启用",
enabling: "正在启用 auto-merge...",
disabling: "正在禁用 auto-merge...",
@@ -776,6 +779,7 @@ export const zhCN: TranslationResources = {
toasts: {
hostDisconnected: "Host 未连接",
removeFailed: "部分 workspace 移除失败",
updateHostToRemove: "更新 host 以移除 projects。",
},
empty: {
title: "还没有 projects",
@@ -824,7 +828,7 @@ export const zhCN: TranslationResources = {
newWorkspace: {
title: "新建 workspace",
create: "创建",
backing: {
isolation: {
local: "本地",
worktree: "新建 worktree",
label: "隔离",
@@ -1286,6 +1290,8 @@ export const zhCN: TranslationResources = {
backdrop: "菜单背景",
},
subagents: {
detachAction: "分离 {{label}}",
detachTooltip: "分离 subagent",
archiveAction: "归档 {{label}}",
archiveTooltip: "归档 subagent",
},
@@ -1371,6 +1377,7 @@ export const zhCN: TranslationResources = {
agents: "Agents",
workspaces: "Workspaces",
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
host: "Host",
},
@@ -1751,6 +1758,9 @@ export const zhCN: TranslationResources = {
button: "诊断",
refresh: "刷新",
refreshing: "正在刷新...",
copyLabel: "诊断",
copyAccessibility: "复制诊断",
copyFailed: "复制诊断失败",
refreshAccessibility: "刷新诊断",
refreshingAccessibility: "正在刷新诊断",
running: "正在运行诊断...",
@@ -3,6 +3,7 @@ import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
getBindingIdForAction,
getWorkspaceIndexJumpModifierKey,
resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext,
@@ -638,3 +639,18 @@ describe("keyboard-shortcut help sections", () => {
).toBeNull();
});
});
describe("getWorkspaceIndexJumpModifierKey", () => {
it("uses Alt on web, regardless of OS", () => {
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: false })).toBe("Alt");
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: false })).toBe("Alt");
});
it("uses Cmd (Meta) on desktop Mac, not Control or Alt", () => {
expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true })).toBe("Meta");
});
it("uses Ctrl on desktop non-Mac, not Meta or Alt", () => {
expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: true })).toBe("Control");
});
});
@@ -1344,6 +1344,21 @@ export function getDefaultKeysForAction(
return null;
}
/**
* The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number
* badges. It must match the modifier of the active `workspace.navigate.index`
* binding for this runtime, otherwise the badges appear for a modifier that
* does not actually jump: Alt on web, Cmd (Meta) on desktop Mac, Ctrl on
* desktop non-Mac.
*/
export function getWorkspaceIndexJumpModifierKey(platform: {
isMac: boolean;
isDesktop: boolean;
}): "Alt" | "Meta" | "Control" {
if (!platform.isDesktop) return "Alt";
return platform.isMac ? "Meta" : "Control";
}
export function buildKeyboardShortcutHelpSections(
input: KeyboardShortcutPlatformContext,
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
+16 -6
View File
@@ -77,12 +77,13 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import { useArchiveSubagent, useSubagentsForParent } from "@/subagents";
import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -238,10 +239,13 @@ function storeFetchedAgentDetail(input: {
result: NonNullable<FetchAgentResult>;
}): Agent {
const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId);
const hydrated: Agent = {
...normalized,
projectPlacement: input.result.project,
};
const hydrated: Agent = applyLegacyDaemonWorkspaceOwnership({
serverId: input.serverId,
agent: {
...normalized,
projectPlacement: input.result.project,
},
});
const store = useSessionStore.getState();
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
@@ -345,9 +349,10 @@ function DraftPanel() {
const handleCreated = useCallback(
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
useSessionStore.getState().setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentSnapshot.id, normalized);
next.set(agentSnapshot.id, agent);
return next;
});
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
@@ -1437,6 +1442,9 @@ function ActiveAgentComposer({
serverId,
parentAgentId: agentId,
});
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const handleOpenSubagent = useCallback(
(subagentId: string) => {
navigateToAgent({ serverId, agentId: subagentId });
@@ -1444,6 +1452,7 @@ function ActiveAgentComposer({
[serverId],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd,
@@ -1540,6 +1549,7 @@ function ActiveAgentComposer({
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onArchiveSubagent={handleArchiveSubagent}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<Composer
agentId={agentId}
@@ -0,0 +1,118 @@
import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
interface ResolvedBalance {
amountText: string;
usedPct: number | null;
}
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
if (limit != null && limit > 0) {
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
}
if (remaining != null) {
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
}
if (used != null) {
return { amountText: formatAmount(used, unit), usedPct: null };
}
return { amountText: "—", usedPct: null };
}
function fillToneStyle(tone: ProviderUsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
case "warning":
return styles.fillWarning;
case "danger":
return styles.fillDanger;
default:
return styles.fillDefault;
}
}
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
[usedPct, tone],
);
return (
<View style={styles.container}>
<View style={styles.labelRow}>
<Text style={styles.label} numberOfLines={1}>
{balance.label}
</Text>
<Text style={styles.value}>
{amountText}
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
</Text>
</View>
{usedPct != null ? (
<View style={styles.track}>
<View style={fillStyle} />
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: 3,
},
labelRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
label: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
value: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
reset: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
track: {
height: 4,
borderRadius: 2,
backgroundColor: theme.colors.surface3,
overflow: "hidden",
},
fill: {
height: 4,
borderRadius: 2,
},
fillDefault: {
backgroundColor: theme.colors.foregroundMuted,
},
fillOk: {
backgroundColor: theme.colors.statusSuccess,
},
fillWarning: {
backgroundColor: theme.colors.statusWarning,
},
fillDanger: {
backgroundColor: theme.colors.statusDanger,
},
}));
+201
View File
@@ -0,0 +1,201 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { StatusBadge } from "@/components/ui/status-badge";
import type { Theme } from "@/styles/theme";
import { ProviderUsageBalanceBar } from "./balance-bar";
import { formatAgo } from "./format";
import type { ProviderUsage } from "./types";
import { ProviderUsageWindowBar } from "./window-bar";
interface ProviderUsageIconProps {
iconKey: string;
size: number;
color?: string;
}
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
}
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
function statusText(usage: ProviderUsage): string | null {
if (usage.status === "available") return null;
return usage.status === "error" ? "Error" : "Unavailable";
}
function footerText(usage: ProviderUsage): string | null {
const updated = formatAgo(usage.fetchedAt);
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
(part): part is string => typeof part === "string" && part.length > 0,
);
return parts.length > 0 ? parts.join(" · ") : null;
}
export function ProviderUsageCard({
usage,
compact = false,
}: {
usage: ProviderUsage;
compact?: boolean;
}) {
const status = statusText(usage);
const footer = footerText(usage);
const balances = usage.balances ?? [];
const details = usage.details ?? [];
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
[compact],
);
const dotStyle = useMemo(
() => [
styles.statusDot,
usage.status === "available" && styles.statusDotAvailable,
usage.status === "error" && styles.statusDotError,
],
[usage.status],
);
return (
<View style={containerStyle}>
<View style={styles.header}>
<ThemedProviderUsageIcon
iconKey={usage.providerId}
size={16}
uniProps={foregroundIconColor}
/>
<Text style={styles.name} numberOfLines={1}>
{usage.displayName}
</Text>
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
<View style={dotStyle} />
<Text style={styles.statusLabel}>{status}</Text>
</View>
) : null}
</View>
{usage.error ? (
<Text style={styles.error} numberOfLines={3}>
{usage.error}
</Text>
) : null}
{usage.windows.length > 0 || balances.length > 0 ? (
<View style={styles.bars}>
{usage.windows.map((window) => (
<ProviderUsageWindowBar key={window.id} window={window} />
))}
{balances.map((balance) => (
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
{details.length > 0 ? (
<View style={styles.details}>
{details.map((detail) => (
<View key={detail.id} style={styles.detailRow}>
<Text style={styles.detailLabel} numberOfLines={1}>
{detail.label}
</Text>
<Text style={styles.detailValue} numberOfLines={1}>
{detail.value}
</Text>
</View>
))}
</View>
) : null}
{footer ? (
<Text style={styles.footer} numberOfLines={1}>
{footer}
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[3],
},
containerPadded: {
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
},
containerCompact: {
gap: theme.spacing[2],
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
name: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
headerSpacer: {
flex: 1,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.foregroundMuted,
},
statusDotAvailable: {
backgroundColor: theme.colors.statusSuccess,
},
statusDotError: {
backgroundColor: theme.colors.statusDanger,
},
statusLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
bars: {
gap: theme.spacing[2],
},
details: {
gap: theme.spacing[1],
},
detailRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: theme.spacing[2],
},
detailLabel: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
detailValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
footer: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
+18
View File
@@ -0,0 +1,18 @@
// User-facing copy for the provider-usage surfaces, centralized so localization
// is a single-file change. INTEGRATION: move these into the i18n resources
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
// call sites once the feature is wired to data. Kept inline for now because the
// surfaces are not yet mounted and the locale files are being edited elsewhere.
export const providerUsageCopy = {
title: "Plan usage",
refresh: "Refresh",
refreshing: "Refreshing...",
loading: "Loading usage...",
empty: "No usage data",
errorTitle: "Unable to load usage",
hostUnavailable: "Connect to this host to see provider usage",
hostUpgradeRequired: "Update the host to see provider usage",
clientUnavailable: "Host connection is not ready",
retry: "Try again",
tooltipLoading: "Loading plan usage…",
} as const;
+53
View File
@@ -0,0 +1,53 @@
import { formatTokenCount } from "@/components/context-window-meter.utils";
import type { ProviderUsageBalanceUnit } from "./types";
export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value));
}
export function formatPct(value: number): string {
return `${Math.round(clampPct(value))}%`;
}
function relativeDuration(iso: string): string | null {
const diffMs = new Date(iso).getTime() - Date.now();
if (!Number.isFinite(diffMs)) return null;
if (diffMs <= 0) return "now";
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d`;
if (diffHours > 0) return `${diffHours}h`;
return `${diffMinutes}m`;
}
export function formatResetLabel(iso: string | null | undefined): string | null {
if (!iso) return null;
const rel = relativeDuration(iso);
if (!rel) return null;
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
export function formatAgo(iso: string | null | undefined): string | null {
if (!iso) return null;
const diffMs = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(diffMs)) return null;
if (diffMs < 60_000) return "just now";
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
return `${diffMinutes}m ago`;
}
export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
switch (unit) {
case "usd":
return `$${value.toFixed(2)}`;
case "tokens":
return formatTokenCount(value);
default:
return value.toLocaleString();
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Fragment } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { ProviderUsageCard } from "./card";
import type { ProviderUsage } from "./types";
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
return (
<View style={settingsStyles.card}>
{providers.map((usage, index) => (
<Fragment key={usage.providerId}>
{index > 0 ? <View style={styles.divider} /> : null}
<ProviderUsageCard usage={usage} />
</Fragment>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
}));
@@ -0,0 +1,96 @@
import { RefreshCw } from "lucide-react-native";
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { providerUsageCopy } from "./copy";
import { ProviderUsageList } from "./list";
import type { ProviderUsageView } from "./types";
export function ProviderUsageSettingsSection({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
const refreshButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={RefreshCw}
loading={busy}
onPress={onRefresh}
accessibilityLabel={providerUsageCopy.refresh}
>
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
</Button>
),
[busy, onRefresh],
);
return (
<SettingsSection
title={providerUsageCopy.title}
testID="provider-usage-card"
trailing={refreshButton}
>
<ProviderUsageBody view={view} onRefresh={onRefresh} />
</SettingsSection>
);
}
function ProviderUsageBody({
view,
onRefresh,
}: {
view: ProviderUsageView;
onRefresh: () => void;
}) {
if (view.kind === "loading") {
return (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
</View>
);
}
if (view.kind === "error") {
return (
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
<Button variant="outline" size="sm" onPress={onRefresh}>
{providerUsageCopy.retry}
</Button>
</Alert>
);
}
if (view.payload.providers.length === 0) {
return (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
</View>
);
}
return <ProviderUsageList providers={view.payload.providers} />;
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
+8
View File
@@ -0,0 +1,8 @@
import type { ProviderUsageTone } from "./types";
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
if (usedPct == null) return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
return "default";
}
@@ -0,0 +1,71 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ProviderUsageCard } from "./card";
import { providerUsageCopy } from "./copy";
import type { ProviderUsage, ProviderUsageView } from "./types";
function matchProvider(
providers: ProviderUsage[],
activeProviderId: string | null | undefined,
): ProviderUsage | null {
if (!activeProviderId) return null;
const target = activeProviderId.toLowerCase();
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
}
// Renders the active agent's provider usage inside the context-meter tooltip.
// Returns nothing when the active provider has no usage entry, so the meter's
// own context section stays the whole tooltip.
export function ProviderUsageTooltipSection({
view,
activeProviderId,
}: {
view: ProviderUsageView;
activeProviderId: string | null | undefined;
}) {
if (view.kind === "loading") {
return (
<>
<View style={styles.divider} />
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
</>
);
}
if (view.kind === "error") {
return (
<>
<View style={styles.divider} />
<Text style={styles.error}>{view.message}</Text>
</>
);
}
const usage = matchProvider(view.payload.providers, activeProviderId);
if (!usage) return null;
return (
<>
<View style={styles.divider} />
<ProviderUsageCard usage={usage} compact />
</>
);
}
const styles = StyleSheet.create((theme) => ({
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1] + 1,
},
detail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
error: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.4,
},
}));

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