Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 250da262f4 test(server): record two-client directory diagnosis
Creation and workspace archive agree between a subscribed client and fresh active-directory reads on the tested main baseline. This passing protocol control does not reproduce issue #7010 or verify its phone/desktop path. No production behavior changes.
2026-10-15 14:41:34 +02:00
71f575e16a Keep malformed Claude question answers retryable (#6936)
* fix: reject a question permission that would deliver no answer

Answering an `AskUserQuestion` permission in any shape but
`updatedInput.answers` keyed by one of its questions resolves as `allow` and
delivers nothing. The waiting agent is told "The user did not answer the
questions." — an affirmative falsehood rather than silence, so neither side can
see that anything failed. The responder sees success; the agent believes the
human declined and acts on that.

The field it is most natural to reach for cannot work. `selectedActionId` is
`z.string().optional()` with no membership check, and is read only for `plan`
kinds; a question advertises `actions: undefined`, so there is no action id to
name in the first place.

It is also unrecoverable. `respondToPermission` deletes the request from
`pendingPermissions` on entry, so the malformed answer consumes it and the retry
fails with "No pending permission request". The guard runs before that delete,
and `AgentManager` drops its own copy only once the call resolves, so both maps
survive the throw and the request stays answerable.

The answer contract is per provider, so the guard is in the Claude provider
rather than in `AgentManager`. Claude keeps an answer only when its key is a
question's full text or its header and its value is a non-empty string; Codex's
`mapCodexQuestionResponseByHeader` reads headers only and deliberately selects
each question's first option when nothing maps, which a shared guard would have
rejected; OpenCode reads headers only as well.

`resolveClaudeAskUserQuestionAnswers` is extracted from
`normalizeClaudeAskUserQuestionUpdatedInput` with no change in behaviour, and
both it and the guard now use it. So "would this deliver anything" is answered
by the code that does the delivering, rather than by a second copy of its rule —
which matters more than it looks, because that rule has two details easy to
restate wrongly: `readNonEmptyString` tests `value.trim()` for emptiness but
returns the *original* string, so a question named `" Which colour? "` is keyed
by that padding; and an empty `updatedInput.questions` array is a list that wins
over the request's own questions and maps nothing, not an absent one.

Verified on a live daemon over the MCP `respond_to_permission` path against a
seat whose question text is literally `" Which colour? "`: `selectedActionId`,
the trimmed key, an empty echoed question list and a numeric answer are each
rejected with the request still pending, and
`{"answers":{" Which colour? ":"Ochre"}}` then reaches the agent.

One known limit: the WebSocket path (`session.ts handleAgentPermissionResponse`)
catches the throw and emits an `activity_log` error rather than failing the
call, because a permission response is modelled as a notification. So
`paseo permit allow` still prints `allowed` and exits 0 for a rejected shape.
The destructive half is closed on every path — the request survives and the
agent is never told a falsehood — but closing the reporting half needs a
protocol change.

* test(claude): cover the question-answer-required retry path

A non-deliverable answer to an AskUserQuestion permission is rejected, but
the pending request survives it: a corrected retry against the same request
id is accepted, and the waiting agent receives the answer.

Mutation testing on a fork found that the existing suite does not pin this
ordering — moving the deliverability check to run after the request is
deleted from pendingPermissions still leaves every existing test green. This
test reds against that change.

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

---------

Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
2026-10-15 14:21:03 +02:00
paseo-bot[bot]andplatanus-kr 0708b8295c Preserve explicit Claude session names on import (#6930)
* fix(server): name an imported Claude agent after its /rename

Importing a Claude session named the agent after its first prompt even when the
session had been renamed in the CLI, so the name had to be set again by hand.
#4216 fixed the Import Session listing; the imported agent still kept the
prompt.

Read the last usable `custom-title` from the transcript and pass it as the
imported config title. The record is re-emitted on every save, so searching
backwards from the end of the file finds the current name without walking a
transcript that can reach tens of megabytes.

Only an explicit rename is carried over. Agents are named after their first
prompt line since #1563, so adopting the generated `ai-title` here would put
that LLM summary back.

Refs #2513

* refactor(server): drop unused generality from the Claude title reader

readLastClaudeTitle took content, type and field so it could read both
custom-title and ai-title records, which the listing needed before #4216 took
that over. Only the custom-title read is left, so the two extra parameters are
dead flexibility that also broke the single-object-parameter rule in
packages/server/CLAUDE.md.

Collapse it to readLastClaudeCustomTitle(content) and pull the record type into
a constant so the needle and the type check share one literal.

Hooks skipped: the repo-wide typecheck fails on two pre-existing Electron
typing errors in packages/desktop/src/main.ts that reproduce with this change
stashed. packages/server typecheck, lint and format all pass.

* test(server): drive the real resume path in the import title tests

The tests assigned an inline arrow function to client.resumeSession to keep
Claude Code from spawning. Importing replays the transcript from disk and never
starts a process, so the stub was never needed. Dropping it runs the real
resume path, which is what docs/testing.md asks for, and removes the arrow
assignment that docs/coding-standards.md rules out.

Hooks skipped: the repo-wide typecheck fails on two pre-existing Electron
typing errors in packages/desktop/src/main.ts that reproduce with this change
stashed. packages/server typecheck, lint, format and the 83 claude tests pass.

* fix(server): narrow the transcript read error instead of asserting it

The ENOENT check cast the caught value to NodeJS.ErrnoException to reach
`code`, which is the kind of assertion docs/coding-standards.md rules out:
narrow with `if`, do not silence the compiler. Add the same local type guard
pid-lock.ts already uses and test through it.

Behaviour is unchanged. A value that is not an Error still falls through to
the rethrow, exactly as reading `code` off the assertion did.

Hooks skipped: the repo-wide typecheck fails on two pre-existing Electron
typing errors in packages/desktop/src/main.ts that reproduce with this change
stashed. packages/server typecheck, lint, format and the 83 claude tests pass.

* fix(server): let claudeProjectDirSync resolve the Claude config dir

The import title reader built the config dir itself through os.homedir(),
but os is no longer imported here, so the fallback threw a ReferenceError
whenever CLAUDE_CONFIG_DIR was unset. claudeProjectDirSync already resolves
the same default.

* test(server): give the import title helpers object parameters

* fix(server): preserve imported session renames across history locations

The import title reader must use the same provider environment and transcript
lookup as history replay. Otherwise an imported session has its history but
loses its explicit name when its configuration directory or project changed.

Co-authored-by: platanus-kr <platanus.kr@gmail.com>

---------

Co-authored-by: platanus-kr <platanus.kr@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 13:17:01 +02:00
paseo-bot[bot]andbwestlund17 4d5c3c13d4 fix(acp): preserve slash command argument hints (#6920)
ACP command updates discarded input hints before the autocomplete command list reached consumers. Preserve the advertised hint, retaining the empty hint for commands without input metadata.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: bwestlund17 <brian.westlund@proton.me>
2026-10-15 12:13:18 +02:00
Mohamed Boudra d3efddb0b5 test(server): await provider discovery before readiness assertion (#6994)
Repeated snapshot reads supersede pending launch resolution and keep readiness loading. Await the refresh RPC before asserting the discovered state.
2026-10-15 15:35:14 +07:00
paseo-bot[bot]andAlexander Adam 0ed57187a9 Keep fork owners in Gitea and Forgejo checkout names (#6829)
* fix(gitea): prefix fork PR branches with the owner

Two forks with the same branch name (say patch-1) ended up on the same
local branch. The gitea service had no buildPrLocalBranchName or
defaultCheckoutRefs, github has both.

getPullRequestCheckoutTarget also fell back to refs/heads/<name> on
origin for fork PRs. Thats the base repo's branch, not the fork's, so
a failed refs/pull fetch checked out the wrong branch without an error.

fixes #3515

* refactor(forge): share fork branch naming with github

moved the owner normalizer and the branch name builder to
utils/change-request-checkout.ts, gitea and github both use it.

github changes a bit: a fork PR with an unknown or unusable owner (eg
deleted fork) now gets pr-N/<branch> instead of the bare branch name,
which could collide with a local branch the same way #3515 did.

* fix(gitea): PRs from a deleted fork are still cross repo

the API drops head.repo once the fork is gone, we read that as a same
repo PR and fetched refs/heads/<name> from origin

* fix(gitea): handle AGit PRs and deleted head branches

neither has a branch on origin. forgejo reports refs/pull/N/head as
head.ref for both, so skip the refs/heads fallback and dont track
origin.

also try refs/pull/N/head on upstream for clones where origin is your
fork, like github does. that list lives in the shared util now.

* test: resolver harness uses the real branch helper

plus deleted forks and same repo PRs without an origin branch

* fix(gitea): collapse pull-ref-only heads to pr-N in local branch names

buildForkLocalBranchName still used the raw headRef even when it was
just the pull ref (refs/pull/N/head), which agit prs and deleted head
branches report as their headRefName. that gave local branches literal
names like refs/heads/refs/pull/3/head.

* fix(gitea): surface hasHeadBranch on the pr checkout target

resolveForkCheckoutFacts already knew whether a pr had a real head
branch (agit flow, or head.ref == the pull ref both mean no). now that
signal reaches buildPrLocalBranchName so worktree names and local
branches stop being named after the raw pull ref.

covers same-repo, fork with owner known, and deleted fork all with no
real head branch. added an end to end gitea-service test plus resolver
cases since the worktree slug follows localBranchName too.

* refactor(forge): one head ref kind on the checkout target

hasOriginHeadBranch and hasHeadBranch could describe contradictory
states (cross-repo checkout claiming an origin head branch). collapse
both into headRefKind: "branch" | "pull-ref" and derive origin
tracking in the resolver instead of storing it separately.

Co-authored-by: Alexander Adam <alexander.adam@vade.io>

* fix(worktrees): avoid branch namespace conflicts in fork checkouts

Fork owner prefixes can name an existing branch as a parent, which Git rejects even when the full proposed name is unused. Allocate names against parent and child refs, suffixing the conflicting segment while preserving existing branches and fetched commits.

* test(worktrees): make fork fixture line endings deterministic

Windows Git converts the fixture checkout to CRLF when core.autocrlf is enabled. Set the repository fixture to preserve its written LF bytes so the content assertion checks the fetched patch on every platform. Reproduced the failure locally with inherited core.autocrlf=true and verified the corrected setup against that same configuration.

---------

Co-authored-by: Alexander Adam <alexander.adam@vade.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 10:10:54 +02:00
Mohamed Boudra a96c892302 fix(website): start the sponsors band heart at the column edge (#6993) 2026-10-15 15:04:10 +07:00
Mohamed Boudra 74282dac12 fix(app): return to chat after fullscreen submission (#6987) 2026-10-15 14:55:19 +07:00
Mohamed Boudra 12066431ad feat(website): head the sponsors band with a heart and one Sponsors title (#6988) 2026-10-15 14:36:12 +07:00
paseo-bot[bot] 4d8bed9ddd Keep Claude's context meter on the active model (#6814)
* fix(claude): keep context meter on the active model

Claude result modelUsage accumulates models across the session. Reading its largest window replaced Haiku capacity with a previous Opus window at turn completion. Select the runtime model entry and retain the seeded limit when that entry is absent or invalid.

* refactor(claude): name context fixture inputs and simplify result usage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 09:13:13 +02:00
paseo-bot[bot]andhadan8977 b2ca7dcf03 Check generated IDs in the daemon upload test (#6810)
* test(server): stop expecting request-derived upload IDs

* test(server): check full uuid shape for upload id

* test(server): format generated upload ID assertion

Retain the contributor correction to the stale request-derived ID expectation while satisfying the repository formatter.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 08:08:40 +02:00
Mohamed Boudra 8b1ba3ca58 Lighter landing sections, FAQ last, followed sponsor links (#6970)
* feat(website): follow sponsor links and lay landing sections on the page background

* feat(website): keep landing card copy to two lines
2026-10-15 12:52:09 +07:00
Mohamed BoudraandClaude e5124b039a Put sheets on one stack and show an agent's facts in its sheet (#6958)
* Put sheet tabs on the one sheet stack

Sheets are one primitive with one stack. A sheet opened from any sheet
stacks on top of it, and closing it uncovers the sheet underneath. The
compact workspace's file and subagent sheets were an in-tree Gorhom
BottomSheet with their own stacking, so a subagent chosen from the
subagents list sheet replaced the list instead of covering it.

- SheetHostScope: a subtree hosts the sheets presented inside it, so the
  sheet tabs draw inside the compact column and read the workspace
  providers while still joining the app-wide stack. Needs Gorhom's
  BottomSheetModalInternalProvider, which the patch exports.
- IsolatedBottomSheetModal takes enableDismissOnClose; false keeps the
  content mounted through a close so the unsaved-changes guard can veto
  it. Its backdrop then closes the sheet rather than dismissing the top
  modal. The Gorhom patch also keeps a dismissing modal's status through
  its final onChange(-1), without which such a modal unmounting while
  presented never left the portal.
- SheetTabs (was WorkspaceSheetHost) renders one IsolatedBottomSheetModal
  per store entry. The store never reorders sheets; a reveal of a covered
  sheet drops the unmodified sheets above it.
- Subagent rows that open a sheet keep the list open (closeOnSelect
  false); rows that open a tab still close it.
- A menu closes when its retained panel goes inactive, instead of hiding
  and reappearing with the panel on the next tab switch.

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

* Show an agent's model and thinking in its sheet, and fit Open as tab in the header rail

A Paseo agent shown in a sheet has no composer, so the facts the composer's
model and thinking controls would show now sit in one line under the sheet
header, the same row provider subagents already get from the daemon. The line
resolves exactly as the controls do (resolveAgentModelSelection), from the
session agent and the providers snapshot, and shows wherever the pane host is
a sheet.

Open as tab becomes the full-screen glyph beside Close, drawn by the shared
SheetHeaderIconButton, so the title keeps its width. The tab switcher sheet is
titled "Workspace tabs" so it reads as what it lists.

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

* Show mode and context usage in the sheet facts line

The line under a sheet's header now carries everything the composer's
controls and context meter show: model, thinking, mode, and used/max context
tokens with the share, each resolved as its control resolves it.

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

* Re-render subagent rows when the workspace's tabs change

A row decides at render time whether choosing it opens a sheet or reveals
a tab, and keeps the list open only for a sheet. The hook behind it only
subscribed to the layout mode, so a row rendered while its agent was a
full tab kept closing the list after that tab was gone: on iOS, promoting
a managed child, closing its tab, and reopening it from the list dropped
the list under the sheet. Subscribe to the workspace's layout too.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 12:16:45 +07:00
paseo-bot[bot] 5c108f345c Preserve renames in committed file diffs (#6895)
* fix(git): preserve renames in committed file diffs

Follow the requested destination through rename detection so the patch agrees with commit history instead of showing the whole file as newly added.

* test(git): keep rename fixtures valid across platforms

* fix(git): keep added copies out of committed rename diffs

Detect rename sources across the first-parent commit before narrowing the patch. Follow mode also detected copies, causing added files to open as partial diffs against unchanged files.

* perf(git): share committed rename discovery across file requests

Opening a commit requests all of its file diffs concurrently. Share pending and completed rename scans for immutable commit IDs in a bounded cache, while symbolic refs always read fresh metadata.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 07:05:42 +02:00
Mohamed Boudra 9ab99a6e1e feat(website): show sponsors on paseo.sh and at the top of the README (#6956) 2026-10-15 11:19:32 +07:00
paseo-bot[bot] 8ff2424da1 Return checkout diff hunks from subdirectory workspaces (#6882)
* fix(git): return checkout diff hunks from subdirectory workspaces

Checkout paths are repository-relative, but one-shot diff reads used the workspace subdirectory for Git pathspecs. Resolve the worktree root once so patch selection and file reads use the same coordinates as the live subscription.

* test(server): await bounded discard fixture cleanup on Windows

The discard assertions pass but Windows can retain a directory lock during cleanup. Await asynchronous removal with bounded retries after daemon shutdown, preserving failure for persistent locks. Matches the verified cleanup correction in #6912 for #6918.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 05:08:39 +02:00
Mohamed Boudra 6db47b3c37 fix(app): keep the keyboard closed after mobile sidebar dismissal (#6945)
Separate composer keyboard eligibility from pane focus restoration. A sidebar dismisses the keyboard without hiding the pane, so its close must not restore editing focus.
2026-10-15 08:59:49 +07:00
paseo-bot[bot] 73221ce5bd fix(app): render local Markdown images with encoded paths (#6877)
Decode assistant Markdown image destinations before resolving a filesystem read. Keep plain filesystem paths and direct image URIs unchanged so Markdown Preview and literal percent filenames are decoded only once.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 03:14:42 +02:00
102 changed files with 3853 additions and 941 deletions
+13 -4
View File
@@ -53,6 +53,17 @@
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
## スポンサー
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
</tr>
</table>
## はじめかた
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
@@ -151,11 +162,9 @@ npm run build:server
npm run typecheck
```
## スポンサー
## Paseo を支援する
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
## 関連プロジェクト
+13 -4
View File
@@ -47,6 +47,17 @@
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
## 스폰서
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
</tr>
</table>
## 시작하기
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
@@ -161,11 +172,9 @@ npm run build:server
npm run typecheck
```
## 스폰서
## Paseo 후원하기
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
## 관련 프로젝트
+13 -4
View File
@@ -52,6 +52,17 @@ Paseo is an open source agentic development environment for desktop, mobile, web
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
## Sponsors
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
</tr>
</table>
## Plugins
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
@@ -207,11 +218,9 @@ npm run build:server
npm run typecheck
```
## Sponsors
## Support Paseo
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs.
## Related projects
+13 -4
View File
@@ -53,6 +53,17 @@
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
## 赞助商
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
</tr>
</table>
## 快速开始
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
@@ -151,11 +162,9 @@ npm run build:server
npm run typecheck
```
## 赞助
## 支持 Paseo
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
## 相关项目
+1 -1
View File
@@ -212,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
+21 -14
View File
@@ -7,7 +7,7 @@ contracts.
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab |
| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
## Panel host contract
@@ -23,23 +23,30 @@ can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
## Workspace sheet
## Sheets
On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher
keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
sheet and tab, so callers open targets the same way on every layout. Each sheet holds one ordinary
`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has
a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved
work. Opening a sheet as a tab dismisses the sheets above it, since the
tab would sit under them. The stack is not persisted.
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Sheets render in
the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator
and every workspace provider an ordinary pane has, and a portal drops them
([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. The sheets present
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
workspace provider an ordinary pane has without a context bridge
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
+26 -9
View File
@@ -217,7 +217,14 @@ sheet suspends pane focus, and the composer gives up input from the moment the
user asks for the compact agent list or explorer, before the panel's motion
settles. Pane focus itself stays put under those panels: the agent panel reads
it as "the user is in this chat" for attention clearing, and opening the agent
list must not clear the attention it shows.
list must not clear the attention it shows. Focus restoration follows pane
focus, not the active composer. The editor keeps its focus intent only across
a blur that lands while its pane is not the user's (a retained tab hidden, a
sheet over it) and refocuses when the pane comes back. A blur while the pane is
theirs drops the intent in every layout mode, and the keyboard the compact
panels dismiss when they settle is such a blur, so closing the agent list or
explorer never reopens the keyboard. Keying restoration on the active composer
made that dismiss look like a hidden-tab blur and refocused on close.
The band beside the inline editor where the fullscreen glyph sits is the input's
own right padding, so a tap anywhere in it focuses the editor whether or not the
glyph shows. On web the slot is a fixed box over the visible viewport, which is
@@ -336,11 +343,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
includes native centered dialogs: separate React Native Modals cannot present a root-owned
sibling dialog over an existing iOS presenter. The one exception is the
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
whole workspace panel.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
close. This includes native centered dialogs: separate React Native Modals cannot present a
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
portal host and container measurement while the sheet still joins the same stack. That export is
what `patches/@gorhom+bottom-sheet+*.patch` adds.
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
close path then parks the sheet at index -1 with its content mounted; the owner hears
`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the
sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and
unmounts it, so its backdrop and Back press call `close()`.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
@@ -350,9 +366,10 @@ forwards commands to the current Gorhom handle.
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
keeps its press target until portal teardown and asks the provider to dismiss the top modal
(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the
authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the
same push/replace presentation semantics.
## Gotcha 7 — A sheet cannot read context from its call site
+2 -1
View File
@@ -21,7 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Workspace sheet** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+8
View File
@@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
@@ -193,6 +198,9 @@ its own.
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its
retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the
menu would reappear with the panel on the next tab switch.
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
@@ -16,6 +16,7 @@ import {
} from "../support/helpers/assistant-images";
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import path from "node:path";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
},
});
test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Angle destination image",
fileName: "My image Мои.png",
});
const agent = await createSettledMockAgent(workspace, "Encoded image paths");
await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, image);
const encodedImage = { ...image, alt: "Encoded destination image" };
await emitSettledAssistantImage(
workspace.client,
agent,
encodedImage,
encodeURI(path.join(workspace.repoPath, image.relativePath)),
);
await expectAssistantImageRendered(page, encodedImage);
const literalPercentImage = await createSmallAssistantPng(workspace, {
alt: "Literal percent filename image",
fileName: "literal%20name.png",
});
await emitSettledAssistantImage(
workspace.client,
agent,
literalPercentImage,
encodeURI(literalPercentImage.relativePath),
);
await expectAssistantImageRendered(page, literalPercentImage);
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
@@ -1,70 +0,0 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openDefaultAppearance(page: Page) {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ codeFontSize: 12 }));
const observations: unknown[] = [];
Object.assign(window, { fontRaceObservations: observations });
for (const type of ["input", "change", "keydown", "focusin", "focusout"]) {
document.addEventListener(
type,
(event) => {
const input = event.target as HTMLInputElement;
if (!input?.getAttribute?.("aria-label")?.startsWith("Code font")) return;
observations.push({
type,
label: input.getAttribute("aria-label"),
value: input.value,
key: (event as KeyboardEvent).key,
at: performance.now(),
});
},
true,
);
}
new MutationObserver((records) => {
for (const record of records)
for (const removed of record.removedNodes) {
if (
removed instanceof Element &&
(removed.matches('[aria-label="Code font size"]') ||
removed.querySelector('[aria-label="Code font size"]'))
)
observations.push({ type: "removed-size-input", at: performance.now() });
}
}).observe(document, { childList: true, subtree: true });
});
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function changeCodeFamilyThenSize(page: Page) {
await page.getByLabel("Code font family").fill("Courier New, Courier, monospace");
await page.getByLabel("Code font family").press("Enter");
await page.getByLabel("Code font size").fill("18");
await page.getByLabel("Code font size").press("Enter");
}
test("keeps the size entered immediately after changing the code font family", async ({
page,
}, testInfo) => {
await openDefaultAppearance(page);
try {
await changeCodeFamilyThenSize(page);
await expect(page.getByLabel("Code font size")).toHaveValue("18");
} finally {
await testInfo.attach("font-race", {
body: JSON.stringify(
await page.evaluate(() => ({
events: (window as Window & { fontRaceObservations: unknown[] }).fontRaceObservations,
stored: localStorage.getItem("@paseo:app-settings"),
})),
null,
2,
),
contentType: "application/json",
});
}
});
@@ -4,18 +4,23 @@ import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
import {
closeWorkspaceSheet,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
closeSheet,
expectOpenTabCount,
openWorkspaceSheetAsTab,
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectWorkspaceSheetAtSnap,
replaceWorkspaceSheetFileText,
expectSheetAtSnap,
replaceSheetFileText,
selectTabFromSwitcher,
tapWorkspaceSheetBackdrop,
workspaceSheet,
} from "../support/helpers/workspace-sheet";
sheet,
sheetHeader,
tapSheetBackdrop,
} from "../support/helpers/sheet";
const FILE_LINK = "linked.ts:3";
@@ -55,47 +60,90 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
const sheet = workspaceSheet(page, "linked.ts");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("three")).toBeVisible();
await expectWorkspaceSheetAtSnap(page, "linked.ts");
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
await expect(fileSheet.getByText("three")).toBeVisible();
await expectSheetAtSnap(page, "linked.ts");
await expect(
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
).toHaveCount(0);
await expectOpenTabCount(page, 1);
await closeWorkspaceSheet(page, "linked.ts");
await closeSheet(page, "linked.ts");
});
await test.step("a subagent opens in a sheet without its composer", async () => {
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const sheet = workspaceSheet(page, child.title);
await expect(sheet).toBeVisible();
await expectWorkspaceSheetAtSnap(page, child.title);
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
"Ten second stream · Low · Load test",
);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab", async () => {
await openWorkspaceSheetAsTab(page, child.title);
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
await expectOpenTabCount(page, 2);
await expectComposerVisible(page);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await test.step("the list the subagent was opened from closed with its tab", async () => {
await selectTabFromSwitcher(page, "Sheet parent");
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const menuBase = `workspace-tab-menu-agent_${child.id}`;
await page.getByTestId(`${menuBase}-trigger`).click();
await page.getByTestId(`${menuBase}-close`).click();
await expectOpenTabCount(page, 1);
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
// tapped once the row menu's own sheet has gone.
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await page.getByText(FILE_LINK, { exact: true }).click();
const sheet = workspaceSheet(page, "linked.ts");
await expect(sheet).toBeVisible();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
// Hold the autosave so the edit stays unsaved.
gate.holdNextClientRequest("fs.file.write.request");
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
await gate.waitForHeldClientRequest();
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () =>
sheet.getByRole("button", { name: "Close" }).click(),
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
);
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
gate.releaseHeldClientRequest();
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
@@ -1,13 +1,30 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
blurComposerInput,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerDraft,
expectComposerFocused,
expectComposerFullscreenControls,
expectComposerNotFocused,
expectComposerVisible,
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectMobileExplorerHidden,
expectMobileExplorerVisible,
swipeOpenMobileAgentSidebar,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
}
});
});
const COMPACT_PANELS = [
{
name: "agent list",
open: swipeOpenMobileAgentSidebar,
expectOpen: expectMobileAgentSidebarVisible,
close: closeMobileAgentSidebar,
expectClosed: expectMobileAgentSidebarHidden,
},
{
name: "Explorer",
open: swipeOpenMobileExplorer,
expectOpen: expectMobileExplorerVisible,
close: closeMobileExplorer,
expectClosed: expectMobileExplorerHidden,
},
] as const;
for (const panel of COMPACT_PANELS) {
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-panel-",
title: `Composer focus under ${panel.name}`,
});
const draft = "Line one\nLine two\nLine three";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("an untouched editor stays untouched", async () => {
await expectComposerNotFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
});
await test.step("an inline editor blurred under the panel stays blurred", async () => {
await composerLocator(page).fill(draft);
await composerLocator(page).click();
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerDraft(page, draft);
});
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
await composerLocator(page).click();
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerFullscreenControls(page);
await expectComposerDraft(page, draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
});
} finally {
await agent.cleanup();
}
});
}
@@ -37,6 +37,7 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
swipeOpenMobileAgentSidebar,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
@@ -165,22 +166,12 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
}
});
async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
}
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
async function pausePageClock(page: Page): Promise<void> {
const now = Date.now();
await page.clock.install({ time: now });
await page.clock.pauseAt(now);
// The installed clock advances between protocol calls; pause at a future time.
await page.clock.pauseAt(now + 60_000);
}
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
@@ -216,7 +207,7 @@ test("compact long drafts stop growing before the header", async ({ page }) => {
}
});
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
@@ -270,7 +261,7 @@ test("compact fullscreen editing preserves the live draft and selection and stay
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => {
await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
@@ -284,10 +275,12 @@ test("compact fullscreen editing preserves the live draft and selection and stay
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
await expectWorkspaceHeaderVisible(page);
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
});
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Typed inside fullscreen");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await exitComposerFullscreen(page);
@@ -349,16 +342,16 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await sendComposerWithButton(page);
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
"Requested mock prompt rejection",
);
await expectWorkspaceHeaderVisible(page);
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
await enterComposerFullscreen(page);
await expectFullscreenErrorAboveSend(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
@@ -450,8 +443,7 @@ test("fullscreen Send keeps the configured queue behavior while an agent runs",
await expectComposerFullscreenControls(page);
await queueComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expectQueuedMessageButton(page);
} finally {
await agent.cleanup();
@@ -1,7 +1,14 @@
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
}
});
});
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
page,
}) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const delay = await delayBrowserWorkspaceCreatedResponse(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await resizeComposerViewport(page, "portrait");
await fillNewWorkspaceDraft(page, DRAFT);
await enterComposerFullscreen(page);
await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest();
await test.step("submission returns inline with the original draft and spinner", async () => {
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
});
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
});
delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1);
} finally {
delay.release();
await project.cleanup();
}
});
@@ -87,10 +87,11 @@ export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
destination: string = image.relativePath,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
`Emit settled assistant image Markdown: ![${image.alt}](${destination})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise<void> {
await expect(composerInput(page)).not.toBeFocused();
}
/** Blur the editor without anything else taking focus. */
export async function blurComposerInput(page: Page): Promise<void> {
await composerInput(page).blur();
await expectComposerNotFocused(page);
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -1,11 +1,16 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The compact workspace sheet, named after the tab it hosts. */
export function workspaceSheet(page: Page, label: string): Locator {
/** A sheet tab's body, named after the tab it shows. */
export function sheet(page: Page, label: string): Locator {
return page.getByRole("dialog", { name: label });
}
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
export function sheetHeader(page: Page, label: string): Locator {
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
}
/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
const SHEET_SNAP_FRACTION = 0.92;
/** The header starts below the sheet's drag indicator, inside the handle. */
const SHEET_INDICATOR_MAX_HEIGHT = 32;
@@ -14,12 +19,11 @@ const SHEET_INDICATOR_MAX_HEIGHT = 32;
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
* covers the workspace header; a sheet sunk below it hides content.
*/
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
export async function expectSheetAtSnap(page: Page, label: string): Promise<void> {
const viewportHeight = page.viewportSize()?.height ?? 0;
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
const sheet = workspaceSheet(page, label);
const header = sheet.getByTestId("workspace-sheet-header");
const body = sheet.getByTestId("workspace-sheet-body");
const header = sheetHeader(page, label);
const body = sheet(page, label);
await expect
.poll(async () => {
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
@@ -39,26 +43,22 @@ export async function expectOpenTabCount(page: Page, count: number): Promise<voi
).toBeVisible();
}
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
const sheet = workspaceSheet(page, label);
await sheet.getByRole("button", { name: "Close" }).click();
await expect(sheet).toHaveCount(0);
export async function closeSheet(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
const sheet = workspaceSheet(page, label);
await sheet.getByRole("button", { name: "Open as tab" }).click();
await expect(sheet).toHaveCount(0);
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function replaceWorkspaceSheetFileText(
export async function replaceSheetFileText(
page: Page,
label: string,
content: string,
): Promise<void> {
const editor = workspaceSheet(page, label)
.getByTestId("file-source-editor")
.locator(".cm-content");
const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
await editor.click();
await editor.press("ControlOrMeta+A");
await editor.pressSequentially(content);
@@ -69,6 +69,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
await expect(option).toBeVisible();
await option.click();
@@ -76,7 +77,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
}
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
export async function tapSheetBackdrop(page: Page): Promise<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.last()
@@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise<void>
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
}
/** Drag from the right edge to open the compact Explorer over the agent. */
export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(viewport.width - 8, y);
await page.mouse.down();
await page.mouse.move(viewport.width - 40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 });
await page.mouse.up();
}
export async function closeMobileExplorer(page: Page): Promise<void> {
const closeButton = page.getByTestId("explorer-close");
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
await closeButton.click();
}
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
}
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
}
@@ -42,6 +42,18 @@ describe("fileUriToPath", () => {
});
describe("localFileSourceToPath", () => {
it.each([
"https://example.com/image%20name.png",
"data:image/png;base64,abc%20",
"blob:https://example.com/image%20name",
])("preserves a direct Markdown image URI: %s", (source) => {
expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source);
});
it("preserves malformed percent escapes rather than throwing", () => {
expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png");
});
it("decodes markdown-encoded Windows drive-letter paths", () => {
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
});
+9 -2
View File
@@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string {
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
export function localFileSourceToPath(source: string): string {
export function localFileSourceToPath(
source: string,
// Markdown destinations are URI-encoded; plain filesystem paths are not.
{ decodeHref = false }: { decodeHref?: boolean } = {},
): string {
if (/^(https?:|data:|blob:)/i.test(source)) {
return source;
}
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
} else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
@@ -14,7 +14,7 @@ import {
} from "../lib/overlay-root";
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
@@ -287,6 +287,37 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
/**
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
*/
export function SheetHeaderIconButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: {
icon: LucideIcon;
accessibilityLabel: string;
onPress: () => void;
testID?: string;
}) {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.closeButton}
onPress={onPress}
testID={testID}
>
{({ pressed }) => (
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
)}
</Pressable>
);
}
export function SheetHeaderView({
header,
onClose,
@@ -343,19 +374,11 @@ export function SheetHeaderView({
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable
accessibilityRole="button"
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
>
{({ pressed }) => (
<X
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
/>
) : null}
</View>
{search ? (
+2 -1
View File
@@ -95,6 +95,7 @@ import {
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
import { localFileSourceToPath } from "@/attachments/utils";
import {
AttachmentFrame,
AttachmentLabel,
@@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<AssistantMarkdownImage
key={node.key}
source={String(node.attributes?.src ?? "")}
source={localFileSourceToPath(String(node.attributes?.src ?? ""), { decodeHref: true })}
occurrenceKey={`${occurrenceKey}:${node.key}`}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
hasLeadingContent={hasLeadingContent}
@@ -0,0 +1,91 @@
import {
BottomSheetModalInternalProvider,
INITIAL_CONTAINER_LAYOUT,
useBottomSheetModalInternal,
} from "@gorhom/bottom-sheet";
import { PortalHost } from "@gorhom/portal";
import { useCallback, useId, useMemo, useRef, type ReactNode } from "react";
import {
Dimensions,
StatusBar,
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
/**
* Hosts the sheets presented from inside it.
*
* A sheet's content normally lands at the app root, on the far side of the portal, where every
* context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a
* scope, a sheet renders into the scope's own host instead, so its content reads the scope's
* contexts — the navigator, the workspace providers — without bridging each one by hand. The
* scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking,
* Back and the backdrop treat it like every other sheet.
*
* The host fills the scope, and the sheets presented in it size and dock to the scope, not the
* window. Sheets presented outside the scope keep drawing at the root, above it.
*/
export function SheetHostScope({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const parent = useBottomSheetModalInternal();
const hostName = `sheet-host-${useId()}`;
const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT });
const hostRef = useRef<View>(null);
// Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against
// the scope the way a root sheet lays out against the window.
const handleLayout = useCallback(
({ nativeEvent }: LayoutChangeEvent) => {
const { height } = nativeEvent.layout;
hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
const windowHeight = Dimensions.get("window").height;
const top = pageY ?? 0;
containerLayoutState.set({
height,
offset: {
top,
bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))),
left: 0,
right: 0,
},
});
});
},
[containerLayoutState],
);
const value = useMemo(
() => ({ ...parent, hostName, containerLayoutState }),
[containerLayoutState, hostName, parent],
);
return (
<BottomSheetModalInternalProvider value={value}>
<View style={[styles.scope, style]}>
{children}
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
<PortalHost name={hostName} />
</View>
</View>
</BottomSheetModalInternalProvider>
);
}
const styles = StyleSheet.create({
scope: {
flex: 1,
minHeight: 0,
},
host: {
...StyleSheet.absoluteFillObject,
},
});
@@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent"
> & {
/**
* Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default
* true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the
* owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`,
* and either snaps it back or unmounts it, which dismisses it from the stack.
*/
enableDismissOnClose?: boolean;
/**
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
* would have to reach around it.
@@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef<
presentation = "push",
contextBridge,
backdropOpacity,
enableDismissOnClose = true,
...bottomSheetProps
} = props;
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
<SheetBackdrop
{...backdropProps}
opacity={backdropOpacity}
pressBehavior={enableDismissOnClose ? "dismiss" : "close"}
/>
),
[backdropOpacity],
[backdropOpacity, enableDismissOnClose],
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={modalRef}
enableDismissOnClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
>
@@ -155,3 +167,4 @@ export function useIsolatedBottomSheetVisibility({
export { SheetVisibleFrame } from "./visible-frame";
export { systemBackPress } from "./back-press";
export { SheetHostScope } from "./host-scope";
@@ -1,5 +1,15 @@
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
/**
* What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close`
* only slides this sheet down, so a sheet that keeps its content through a close can veto it.
*/
export type SheetBackdropPress = "dismiss" | "close";
export function SheetBackdrop({
pressBehavior: _pressBehavior,
...props
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
// Gorhom's native backdrop closes its own sheet, which here is always the top one.
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
}
@@ -1,16 +1,30 @@
import {
BottomSheetBackdrop,
useBottomSheet,
useBottomSheetModal,
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, StyleSheet, View } from "react-native";
import type { SheetBackdropPress } from "./sheet-backdrop";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
export type { SheetBackdropPress };
export function SheetBackdrop({
pressBehavior,
...props
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
const { dismiss } = useBottomSheetModal();
const { close } = useBottomSheet();
const { t } = useTranslation();
const dismissTopSheet = useCallback(() => dismiss(), [dismiss]);
const handlePress = useCallback(() => {
// A sheet that may veto its close slides itself down and lets its owner decide. The other
// sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still
// dismisses the sheet the user sees.
if (pressBehavior === "close") close();
else dismiss();
}, [close, dismiss, pressBehavior]);
return (
<View style={StyleSheet.absoluteFill}>
@@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb
accessibilityLabel={t("common.bottomSheetBackdrop")}
// The provider owns presentation order, including when a lower backdrop remounts.
// Keep this target until the portal unmounts: animation must not expose the sheet below.
onPress={dismissTopSheet}
onPress={handlePress}
style={StyleSheet.absoluteFill}
/>
</View>
@@ -126,8 +126,14 @@ export interface MenuSurfaceProps {
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
*/
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
const { presentation } = useMenuContext("MenuSurface");
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
const active = useRetainedPanelActive();
// A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes
// for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet
// and promoted it to a tab, say, would otherwise bring the list back on the next tab switch.
useEffect(() => {
if (!active && open) setOpen(false);
}, [active, open, setOpen]);
// Portals escape the hidden panel. Remove the surface in its inactive commit,
// before freezing can suspend an async menu action's closing update.
if (!active) return null;
+6 -1
View File
@@ -1196,11 +1196,16 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
// not when its motion settles, so the exit action and autocomplete never race it.
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused && !coveredByOverlayPanel}>
<ComposerKeyboardScopeProvider
isPaneFocused={isPaneFocused}
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
+22 -13
View File
@@ -524,8 +524,8 @@ function MessageInputAutoFocus({
autoFocusKey: string | undefined;
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
const { isPaneFocused } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey);
return null;
}
@@ -942,6 +942,7 @@ interface SendMessageContext {
onSubmit: (payload: MessagePayload) => void;
onMinimizeHeight: () => void;
preserveHeightOnSubmit: boolean;
exitFullscreen: () => void;
}
function sendMessageImpl(ctx: SendMessageContext): void {
@@ -954,6 +955,7 @@ function sendMessageImpl(ctx: SendMessageContext): void {
) {
return;
}
ctx.exitFullscreen();
ctx.onSubmit({
text: trimmed,
attachments: ctx.attachments,
@@ -974,12 +976,14 @@ interface QueueMessageContext {
onQueue: ((payload: MessagePayload) => void) | undefined;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
exitFullscreen: () => void;
}
function queueMessageImpl(ctx: QueueMessageContext): void {
if (!ctx.onQueue) return;
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.exitFullscreen();
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.replaceText("");
ctx.onMinimizeHeight();
@@ -1236,7 +1240,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textReplacement,
submitLabel,
} = resolveMessageInputProps(props);
const { isActiveComposer } = useComposerKeyboardScope();
const { isPaneFocused } = useComposerKeyboardScope();
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
@@ -1264,8 +1268,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const focusIntentRef = useRef(false);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const activeComposerRef = useRef(isActiveComposer);
activeComposerRef.current = isActiveComposer;
const paneFocusedRef = useRef(isPaneFocused);
paneFocusedRef.current = isPaneFocused;
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const webTextareaRef = useRef<HTMLElement | null>(null);
@@ -1285,9 +1289,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
webTextareaRef.current = element;
setWebTextArea(element);
}, []);
// Focus intent outlives a blur only while the pane is not the user's pane (a retained
// tab hidden, a sheet over it); the pane coming back restores focus. A blur while the
// pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard,
// or a compact overlay panel did on their behalf, and nothing later refocuses.
useLayoutEffect(() => {
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
}, [isActiveComposer]);
if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus();
}, [isPaneFocused]);
// The compact and desktop screens mount the editor in different trees, so a
// viewport that crosses the breakpoint remounts it. The draft owner brings the
// text back; the session brings the caret and focus intent back. On web the
@@ -1305,7 +1313,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textInputRef.current?.setSelection(selection);
if (retained.focused) {
focusIntentRef.current = true;
if (activeComposerRef.current) textInputRef.current?.focus();
if (paneFocusedRef.current) textInputRef.current?.focus();
}
}
return () => {
@@ -1636,6 +1644,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
exitFullscreen,
});
}, [
allowEmptySubmit,
@@ -1647,6 +1656,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
minimizeInputHeight,
preserveHeightOnSubmit,
updateLiveTextPresence,
exitFullscreen,
]);
const handleQueueMessage = useCallback(
@@ -1658,8 +1668,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onQueue,
replaceText,
onMinimizeHeight: minimizeInputHeight,
exitFullscreen,
}),
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
[attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen],
);
const handleDefaultSendAction = useCallback(() => {
@@ -1807,13 +1818,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
// Fullscreen is nothing but the editor, so a blur there (a retained tab being
// hidden, the keyboard dismissed) keeps the intent: showing it again refocuses.
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
if (paneFocusedRef.current) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [isFullscreen, onFocusChange]);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
+18 -1
View File
@@ -7,18 +7,35 @@ import {
} from "react";
interface ComposerKeyboardScopeValue {
/**
* The pane this composer sits in is the user's pane. Focus restoration follows it:
* the editor keeps its focus intent across blurs that happen while the pane is not
* the user's (a retained tab hidden, a sheet over it) and brings focus back when
* the pane is theirs again.
*/
isPaneFocused: boolean;
/**
* The composer takes input right now: keyboard actions, shortcuts, autocomplete,
* and the fullscreen exit action. False while a compact overlay panel covers the
* pane, which does not take pane focus.
*/
isActiveComposer: boolean;
}
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
isPaneFocused: false,
isActiveComposer: false,
});
export function ComposerKeyboardScopeProvider({
isPaneFocused,
isActiveComposer,
children,
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
const value = useMemo(
() => ({ isPaneFocused, isActiveComposer }),
[isPaneFocused, isActiveComposer],
);
return (
<ComposerKeyboardScopeContext.Provider value={value}>
{children}
@@ -1,7 +1,8 @@
import React, { act, type ReactNode } from "react";
import React, { act, useCallback, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
@@ -109,6 +110,43 @@ describe("composer track panel", () => {
expect(row("row")).not.toBeNull();
});
it("closes for good when the panel it belongs to leaves the screen", () => {
const onPress = vi.fn();
function Harness() {
const [active, setActive] = useState(true);
const hide = useCallback(() => setActive(false), []);
const show = useCallback(() => setActive(true), []);
return (
<>
<Pressable accessibilityRole="button" testID="hide" onPress={hide} />
<Pressable accessibilityRole="button" testID="show" onPress={show} />
<RetainedPanelActivity active={active}>
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow
accessibilityLabel="Subagent one"
testID="row"
closeOnSelect={false}
onPress={onPress}
>
<Text>Subagent one</Text>
</ComposerTrackRow>
</ComposerTrackPill>
</RetainedPanelActivity>
</>
);
}
mount(<Harness />);
openPanel();
expect(isPanelOpen()).toBe(true);
click(row("hide") as Element);
click(row("show") as Element);
expect(isPanelOpen()).toBe(false);
expect(row("row")).toBeNull();
});
it("leaves the panel alone when a row's own action button is pressed", () => {
const onPress = vi.fn();
const onAction = vi.fn();
+1 -1
View File
@@ -631,7 +631,7 @@ export const ar: TranslationResources = {
},
switcher: {
trigger: "تبديل علامات التبويب (فتح{{count}})",
title: "علامة التبويب التبديل",
title: "علامات تبويب الـ Workspace",
searchPlaceholder: "علامات تبويب البحث",
},
menu: {
+1 -1
View File
@@ -628,7 +628,7 @@ export const en = {
},
switcher: {
trigger: "Switch tabs ({{count}} open)",
title: "Switch tab",
title: "Workspace tabs",
searchPlaceholder: "Search tabs",
},
menu: {
+1 -1
View File
@@ -637,7 +637,7 @@ export const es: TranslationResources = {
},
switcher: {
trigger: "Cambiar pestañas ({{count}}abierto)",
title: "Cambiar pestaña",
title: "Pestañas del workspace",
searchPlaceholder: "Pestañas de búsqueda",
},
menu: {
+1 -1
View File
@@ -636,7 +636,7 @@ export const fr: TranslationResources = {
},
switcher: {
trigger: "Changer d’onglet ({{count}} ouverts)",
title: "Changer d’onglet",
title: "Onglets de l’espace de travail",
searchPlaceholder: "Rechercher des onglets",
},
menu: {
+1 -1
View File
@@ -637,7 +637,7 @@ export const ja: TranslationResources = {
},
switcher: {
trigger: "タブを切り替え({{count}}件開いています)",
title: "タブを切り替え",
title: "ワークスペースのタブ",
searchPlaceholder: "タブを検索",
},
menu: {
+1 -1
View File
@@ -632,7 +632,7 @@ export const ko: TranslationResources = {
},
switcher: {
trigger: "탭 전환 ({{count}}개 열림)",
title: "탭 전환",
title: "워크스페이스 탭",
searchPlaceholder: "탭 검색",
},
menu: {
+1 -1
View File
@@ -636,7 +636,7 @@ export const ptBR: TranslationResources = {
},
switcher: {
trigger: "Alternar abas ({{count}} abertas)",
title: "Alternar aba",
title: "Abas do workspace",
searchPlaceholder: "Buscar abas",
},
menu: {
+1 -1
View File
@@ -638,7 +638,7 @@ export const ru: TranslationResources = {
},
switcher: {
trigger: "Переключить вкладки (открыто: {{count}})",
title: "Переключить вкладку",
title: "Вкладки рабочего пространства",
searchPlaceholder: "Поиск вкладок",
},
menu: {
+1 -1
View File
@@ -631,7 +631,7 @@ export const zhCN: TranslationResources = {
},
switcher: {
trigger: "切换标签(已打开 {{count}} 个)",
title: "切换标签",
title: "Workspace 标签",
searchPlaceholder: "搜索标签",
},
menu: {
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line";
const models = [
{
provider: "claude",
id: "claude-fable-5-1",
label: "Fable 5.1",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
},
];
const modes = [
{ id: "default", label: "Default" },
{ id: "bypassPermissions", label: "Bypass permissions" },
];
const base: AgentFactsInput = {
models,
runtimeModelId: "claude-fable-5-1",
configuredModelId: null,
runtimeThinkingOptionId: "high",
explicitThinkingOptionId: null,
availableModes: modes,
currentModeId: "bypassPermissions",
contextWindowUsedTokens: 56_200,
contextWindowMaxTokens: 200_000,
};
describe("buildAgentFactsLine", () => {
it("joins model, thinking, mode, and context usage as the controls show them", () => {
expect(buildAgentFactsLine(base)).toBe(
"Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)",
);
});
it("shows the raw model id before the catalog has loaded", () => {
expect(
buildAgentFactsLine({
...base,
models: null,
runtimeModelId: null,
configuredModelId: "gpt-6.1-sol",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: "medium",
availableModes: [],
contextWindowUsedTokens: null,
contextWindowMaxTokens: null,
}),
).toBe("gpt-6.1-sol · Medium");
});
it("falls back to the provider's first mode until the agent reports one", () => {
expect(
buildAgentFactsLine({
...base,
currentModeId: null,
contextWindowUsedTokens: undefined,
contextWindowMaxTokens: undefined,
}),
).toBe("Fable 5.1 · High · Default");
});
it("leaves thinking and context out when the agent has none", () => {
expect(
buildAgentFactsLine({
...base,
models: [{ provider: "mock", id: "m", label: "Model M" }],
runtimeModelId: "m",
runtimeThinkingOptionId: null,
availableModes: [],
contextWindowUsedTokens: 10,
contextWindowMaxTokens: 0,
}),
).toBe("Model M");
});
it("is empty until the agent has a model", () => {
expect(
buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }),
).toBeNull();
});
});
@@ -0,0 +1,59 @@
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { formatAgentModeLabel } from "@/agent-controls/labels";
import { formatTokenCount } from "@/components/context-window-meter.utils";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
export interface AgentFactsInput {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
runtimeThinkingOptionId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
availableModes: AgentMode[];
currentModeId: string | null | undefined;
contextWindowUsedTokens: number | null | undefined;
contextWindowMaxTokens: number | null | undefined;
}
/**
* What the composer's controls and context meter show about a Paseo agent, as one line: model,
* thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until
* the agent has a model at all.
*/
export function buildAgentFactsLine(input: AgentFactsInput): string | null {
const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim());
if (!hasModel) return null;
const selection = resolveAgentModelSelection(input);
const parts = [selection.displayModel];
if (selection.selectedThinkingId) parts.push(selection.displayThinking);
const mode = resolveModeLabel(input.availableModes, input.currentModeId);
if (mode) parts.push(mode);
const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens);
if (context) parts.push(context);
return parts.join(" · ");
}
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
return mode ? formatAgentModeLabel(mode) : null;
}
/** The context meter's numbers: used of max, with the share it draws as the ring. */
function formatContextUsage(
usedTokens: number | null | undefined,
maxTokens: number | null | undefined,
): string | null {
if (
typeof usedTokens !== "number" ||
typeof maxTokens !== "number" ||
!Number.isFinite(usedTokens) ||
!Number.isFinite(maxTokens) ||
usedTokens < 0 ||
maxTokens <= 0
) {
return null;
}
const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100));
return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`;
}
+34
View File
@@ -60,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
import { TimelineSyncStatus } from "@/timeline/sync-status";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
@@ -1247,6 +1250,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : null;
const streamContent = (
<View style={animatedStaticStyles.content}>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
streamViewRef={streamViewRef}
@@ -1375,6 +1379,36 @@ function TimelineSyncErrorCallout({
);
}
/**
* What the composer's controls and context meter would show, as one line, for an agent shown
* without its composer.
*/
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
}
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
+3
View File
@@ -12,6 +12,7 @@ import {
type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
useSubagentOpensInSheet,
type SubagentRow,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
@@ -90,6 +91,7 @@ export const AgentTracks = memo(function AgentTracks({
},
[openSubagentTarget],
);
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
@@ -122,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
opensInSheet={opensInSheet}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
+30
View File
@@ -0,0 +1,30 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* One muted line of facts about a pane's agent, under the pane's header and above its stream:
* model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without
* its composer gets it from the same facts the composer's controls show.
*/
export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) {
return (
<View style={styles.header}>
<Text style={styles.text} numberOfLines={1} testID={testID}>
{text}
</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { usePaneContext } from "@/panels/pane-context";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { useSubagentsForParent } from "@/subagents/select";
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
import { SubagentsTrack } from "@/subagents/track";
import {
providerSubagentKey,
@@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({
serverId,
rows,
onOpenProviderSubagent,
opensInSheet,
}: {
serverId: string;
rows: ReturnType<typeof useSubagentsForParent>;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: (row: SubagentRow) => boolean;
}) {
if (rows.length === 0) return null;
return (
@@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({
rows={rows}
onOpenSubagent={NOOP_SUBAGENT}
onOpenProviderSubagent={onOpenProviderSubagent}
opensInSheet={opensInSheet}
onArchiveSubagent={NOOP_SUBAGENT}
/>
</ComposerTrackBar>
@@ -109,7 +115,7 @@ function useProviderSubagentDescriptor(
function ProviderSubagentPanel() {
const { t } = useTranslation();
const { serverId, target, openFileInWorkspace, openTab } = usePaneContext();
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
@@ -137,6 +143,9 @@ function ProviderSubagentPanel() {
providerParentSubagentId: target.subagentId,
});
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
const opensInSheet = useSubagentOpensInSheet(
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
);
const openProviderChild = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
@@ -243,17 +252,7 @@ function ProviderSubagentPanel() {
return (
<View style={styles.container} testID="provider-subagent-panel">
{subtitle ? (
<View style={styles.subtitleHeader}>
<Text
style={styles.subtitleText}
numberOfLines={1}
testID="provider-subagent-pane-subtitle"
>
{subtitle}
</Text>
</View>
) : null}
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
<AgentStreamView
agentId={streamId}
serverId={serverId}
@@ -273,6 +272,7 @@ function ProviderSubagentPanel() {
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
opensInSheet={opensInSheet}
/>
</View>
);
@@ -280,16 +280,6 @@ function ProviderSubagentPanel() {
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
subtitleHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
subtitleText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
}));
@@ -0,0 +1,311 @@
import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet";
import { useIsFocused } from "@react-navigation/native";
import {
type ComponentType,
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useStore } from "zustand";
import { createStore } from "zustand/vanilla";
import { Maximize2 } from "lucide-react-native";
import {
SheetBackground,
SheetHandle,
SheetHeaderIconButton,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import {
IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef,
systemBackPress,
} from "@/components/ui/isolated-bottom-sheet-modal";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const NO_SHEETS: readonly SheetTab[] = [];
function PanelIcon({
icon: Icon,
size,
color,
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface SheetTabHandleValue {
header: SheetHeader;
onClose: () => void;
}
/**
* The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and
* renders it on the far side of the portal: a new function per render would remount the header on
* every change, and no context from here reaches it. So each sheet gets one handle component for
* its lifetime, which reads the header through a store the sheet keeps current.
*/
function useSheetTabHandle(value: SheetTabHandleValue): FC<BottomSheetHandleProps> {
const [store] = useState(() => createStore<SheetTabHandleValue>(() => value));
useEffect(() => store.setState(value, true), [store, value]);
const [Handle] = useState(
() =>
function SheetTabHandle(props: BottomSheetHandleProps) {
const { header, onClose } = useStore(store);
return (
<SheetHandle {...props}>
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
</SheetHandle>
);
},
);
return Handle;
}
export interface SheetTabsProps {
workspaceKey: string | null;
serverId: string;
workspaceId: string;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
onCloseTab: (tabId: string) => Promise<void>;
}
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
sheet: SheetTab;
/** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */
isTop: boolean;
/** Runs the guarded close and resolves to whether the tab is still open. */
onClose: (tabId: string) => Promise<boolean>;
onOpenAsTab: (tabId: string) => void;
}
/**
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside
* the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
*/
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
const sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const handleOpenAsTab = useCallback(
(tabId: string) => {
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
},
[openSheetAsTab, workspaceKey],
);
const handleClose = useCallback(
async (tabId: string) => {
await onCloseTab(tabId);
// A declined unsaved-changes prompt leaves the tab in its sheet.
const open = workspaceKey
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
: undefined;
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
},
[onCloseTab, workspaceKey],
);
return (
<>
{sheets.map((sheet, index) => (
<SheetTabSheet
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
{...rest}
sheet={sheet}
isTop={index === sheets.length - 1}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
))}
</>
);
}
function SheetTabSheet({
sheet,
isTop,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: SheetTabSheetProps) {
const { t } = useTranslation();
const { tab } = sheet;
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const isScreenFocused = useIsFocused();
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
// reports that as `onChange(-1)`, which must not read as the user closing it: the store already
// moved on, and the same tab id may now be a pane tab (Open as tab).
const unmounted = useRef(false);
useEffect(() => {
sheetRef.current?.present();
return () => {
unmounted.current = true;
};
}, []);
// Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then
// runs the one guarded close. The sheet keeps its content through the close, so a declined
// unsaved-changes prompt only has to bring it back up.
const close = useCallback(() => sheetRef.current?.close(), []);
const handleChange = useCallback(
(index: number) => {
if (index !== -1 || unmounted.current) return;
void onClose(tab.tabId).then((stillOpen) => {
if (stillOpen) sheetRef.current?.snapToIndex(0);
return stillOpen;
});
},
[onClose, tab.tabId],
);
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns Back.
const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused;
useEffect(() => {
if (!ownsBackPress) return;
return systemBackPress.subscribe(() => {
close();
return true;
});
}, [close, ownsBackPress]);
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
// the switcher, where it gets its composer back.
actions: sheet.policy.openAsTab ? (
<SheetHeaderIconButton
icon={Maximize2}
accessibilityLabel={t("workspace.sheet.openAsTab")}
onPress={handleOpenAsTab}
testID="sheet-tab-open-as-tab"
/>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
);
const handleValue = useMemo<SheetTabHandleValue>(
() => ({ header, onClose: close }),
[close, header],
);
// The header is the sheet's handle: the panel scrolls on its own, so dragging the content would
// fight it, and the header is where people reach to pull the sheet down.
const SheetTabHandle = useSheetTabHandle(handleValue);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
enableDismissOnClose={false}
backgroundComponent={SheetBackground}
handleComponent={SheetTabHandle}
backdropOpacity={0.45}
onChange={handleChange}
accessible={false}
>
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
<SheetTabBody
tab={tab}
label={descriptor.label}
isPaneFocused={isTop && isWorkspaceFocused}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
/>
</IsolatedBottomSheetModal>
);
}
function SheetTabBody({
tab,
label,
isPaneFocused,
isWorkspaceFocused,
buildContent,
}: {
tab: SheetTab["tab"];
label: string;
/** Only the top sheet's pane is focused; the ones below are covered. */
isPaneFocused: boolean;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
}) {
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
state: tab.state,
}),
[tab],
);
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
const { bottom: bottomInset } = useSafeAreaInsets();
const contentStyle = useMemo(
() => [styles.content, { paddingBottom: bottomInset }],
[bottomInset],
);
return (
<View style={contentStyle} role="dialog" aria-label={label} testID="sheet-tab-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
minHeight: 0,
},
});
@@ -3,6 +3,7 @@ import {
useComposerTabFullscreen,
} from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import {
@@ -78,7 +79,7 @@ import {
selectExplorerSidebarPaneId,
selectPaneFocusSuspended,
type WorkspaceLayout,
type WorkspaceSheet,
type SheetTab,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
@@ -165,7 +166,7 @@ import {
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host";
import { SheetTabs } from "@/screens/workspace/sheet-tabs";
import type { PaneHost } from "@/panels/panel-manifest";
import { WorkspaceFocusProvider } from "@/workspace/focus";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
@@ -208,7 +209,7 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request";
const NO_SHEETS: readonly WorkspaceSheet[] = [];
const NO_SHEETS: readonly SheetTab[] = [];
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
@@ -3552,14 +3553,15 @@ function WorkspaceScreenContent({
composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId),
});
// The sheet is the column's last child, so it covers the compact header as a modal.
// Sheets opened from the compact column present inside it, so their panels read the workspace
// providers, and they cover the compact header as a modal.
const workspaceCenterColumn = isMobile ? (
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<View style={styles.centerColumn}>
<SheetHostScope style={styles.centerColumn}>
<FloatingHeaderLayout
header={compactHeaderLayout.header}
floating={compactHeaderLayout.floating}
@@ -3570,17 +3572,15 @@ function WorkspaceScreenContent({
</StreamTopOverlayProvider>
)}
</FloatingHeaderLayout>
<StreamTopOverlayProvider value={0}>
<WorkspaceSheetHost
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</StreamTopOverlayProvider>
</View>
<SheetTabs
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</SheetHostScope>
</WorkspacePanelProviders>
) : (
<View style={styles.centerColumn}>
@@ -1,326 +0,0 @@
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
type BottomSheetHandleProps,
} from "@gorhom/bottom-sheet";
import { useIsFocused } from "@react-navigation/native";
import {
type ComponentType,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
SheetBackground,
SheetHandle,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { Button } from "@/components/ui/button";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const NO_SHEETS: readonly WorkspaceSheet[] = [];
function PanelIcon({
icon: Icon,
size,
color,
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the
// backdrop closes the sheet itself instead of dismissing the top of the modal stack.
function renderBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
pressBehavior="close"
/>
);
}
// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the
// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views
// under 0.01 opacity when hit testing, which would hand the tap to the sheet below.
function renderStackedBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
{...props}
style={styles.clearBackdrop}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={1}
pressBehavior="close"
/>
);
}
interface WorkspaceSheetHandleValue {
header: SheetHeader;
onClose: () => void;
}
const WorkspaceSheetHandleContext = createContext<WorkspaceSheetHandleValue | null>(null);
// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and
// reads the header from context; a per-render function would remount the header on every change.
function WorkspaceSheetHandle(props: BottomSheetHandleProps) {
const value = useContext(WorkspaceSheetHandleContext);
if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet");
return (
<SheetHandle {...props}>
<SheetHeaderView
header={value.header}
onClose={value.onClose}
testID="workspace-sheet-header"
/>
</SheetHandle>
);
}
export interface WorkspaceSheetHostProps {
workspaceKey: string | null;
serverId: string;
workspaceId: string;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
onCloseTab: (tabId: string) => Promise<void>;
}
interface WorkspaceSheetPresentationProps extends Omit<
WorkspaceSheetHostProps,
"workspaceKey" | "onCloseTab"
> {
sheet: WorkspaceSheet;
/** Only the bottom sheet dims the workspace; the ones above share its dim. */
isBottom: boolean;
/** Only the top sheet answers Android Back; the ones below wait their turn. */
isTop: boolean;
onClose: (tabId: string) => Promise<void>;
onOpenAsTab: (tabId: string) => void;
}
/**
* Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last.
* They render in the workspace tree, not through a portal, so each panel keeps every context an
* ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`)
* keep them looking and dragging like every other sheet.
*/
export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
const sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const handleOpenAsTab = useCallback(
(tabId: string) => {
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
},
[openSheetAsTab, workspaceKey],
);
if (sheets.length === 0) return null;
return (
<View style={styles.host} pointerEvents="box-none">
{sheets.map((sheet, index) => (
<WorkspaceSheetPresentation
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
{...rest}
sheet={sheet}
isBottom={index === 0}
isTop={index === sheets.length - 1}
onClose={onCloseTab}
onOpenAsTab={handleOpenAsTab}
/>
))}
</View>
);
}
function WorkspaceSheetPresentation({
sheet,
isBottom,
isTop,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: WorkspaceSheetPresentationProps) {
const { t } = useTranslation();
const { tab } = sheet;
const sheetRef = useRef<BottomSheet>(null);
const isScreenFocused = useIsFocused();
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]);
const handleChange = useCallback(
(index: number) => {
if (index !== -1) return;
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
void handleClose().then(() => sheetRef.current?.snapToIndex(0));
},
[handleClose],
);
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused;
useEffect(() => {
if (!ownsBackPress) return;
return systemBackPress.subscribe(() => {
void handleClose();
return true;
});
}, [handleClose, ownsBackPress]);
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={handleOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
);
const handleHeaderClose = useCallback(() => {
void handleClose();
}, [handleClose]);
const handleValue = useMemo<WorkspaceSheetHandleValue>(
() => ({ header, onClose: handleHeaderClose }),
[handleHeaderClose, header],
);
return (
// Gorhom renders the handle beside the content, so the dialog wraps the whole sheet.
<View
style={styles.sheetLayer}
pointerEvents="box-none"
role="dialog"
aria-label={descriptor.label}
>
{/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content
would fight it, and the header is where people reach to pull the sheet down. */}
<WorkspaceSheetHandleContext.Provider value={handleValue}>
<BottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backgroundComponent={SheetBackground}
handleComponent={WorkspaceSheetHandle}
backdropComponent={isBottom ? renderBackdrop : renderStackedBackdrop}
onChange={handleChange}
accessible={false}
>
{/* A covered sheet keeps its state but stops its live work until it is on top again. */}
<RetainedPanelActivity active={isTop}>
<WorkspaceSheetBody
tab={tab}
isPaneFocused={isTop && isWorkspaceFocused}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
/>
</RetainedPanelActivity>
</BottomSheet>
</WorkspaceSheetHandleContext.Provider>
</View>
);
}
function WorkspaceSheetBody({
tab,
isPaneFocused,
isWorkspaceFocused,
buildContent,
}: {
tab: WorkspaceSheet["tab"];
/** Only the top sheet's pane is focused; the ones below are covered. */
isPaneFocused: boolean;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
}) {
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
state: tab.state,
}),
[tab],
);
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
const { bottom: bottomInset } = useSafeAreaInsets();
const contentStyle = useMemo(
() => [styles.content, { paddingBottom: bottomInset }],
[bottomInset],
);
return (
<View style={contentStyle} testID="workspace-sheet-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
}
const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
clearBackdrop: {
backgroundColor: "transparent",
},
sheetLayer: {
...StyleSheet.absoluteFillObject,
},
content: {
flex: 1,
minHeight: 0,
},
});
@@ -4592,7 +4592,7 @@ describe("compact workspace sheet", () => {
expect(tabKinds(store)).toEqual(["terminal"]);
});
it("brings a stacked target back to the top by dropping the sheets above it", () => {
it("uncovers a covered sheet by dropping the sheets above it", () => {
const store = createCompactStore();
const fileTabId = store
.getState()
@@ -4878,13 +4878,15 @@ describe("compact workspace sheet", () => {
});
});
it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => {
it("keeps a covered sheet in place under a modified sheet instead of reordering", () => {
const store = createCompactStore();
const otherTarget = { kind: "file" as const, path: "src/other.ts" };
const otherTabId = store
.getState()
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
const modifiedSheetTabId = openModifiedFileSheet(store);
const unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" };
store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" });
const focusedBefore = focusedTabId(store);
const tabId = store
@@ -4892,10 +4894,10 @@ describe("compact workspace sheet", () => {
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
expect(tabId).toBe(otherTabId);
expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]);
expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe(
modifiedSheetTabId,
);
// Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet
// stays on top of the one revealed, and only the unmodified sheet above them closes.
expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]);
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
expect(stripTab(store, tabId ?? "")).toBeNull();
expect(focusedTabId(store)).toBe(focusedBefore);
});
@@ -111,13 +111,13 @@ export interface OpenWorkspaceTabInput {
}
/**
* How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single
* How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single
* wide pane (touch tablets, which cannot render splits), or split panes.
*/
export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
/** A tab hosted by the compact workspace sheet instead of a pane. */
export interface WorkspaceSheet {
/** A tab shown in a sheet over the compact workspace instead of in a pane. */
export interface SheetTab {
tab: WorkspaceTab;
policy: CompactSheetPolicy;
parentTabId: string | null;
@@ -127,10 +127,12 @@ interface WorkspaceLayoutStore {
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
/**
* Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with
* no sheet has no entry. Not persisted: a reload returns to the tab strip.
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
* its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab
* strip.
*/
sheetsByWorkspace: Record<string, readonly WorkspaceSheet[]>;
sheetsByWorkspace: Record<string, readonly SheetTab[]>;
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
explorerSidebarWidthByWorkspace: Record<string, number>;
@@ -143,10 +145,10 @@ interface WorkspaceLayoutStore {
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
/**
* The one entry point for showing a target. On compact layouts a target whose panel declares
* `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
*/
openTab: (input: OpenWorkspaceTabInput) => string | null;
/** Whether `openTab` with this input would show the target in the sheet. */
/** Whether `openTab` with this input would show the target in a sheet. */
opensInSheet: (
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => boolean;
@@ -684,12 +686,12 @@ function attachParentTab(input: {
});
}
const NO_SHEETS: readonly WorkspaceSheet[] = [];
const NO_SHEETS: readonly SheetTab[] = [];
function getSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): readonly WorkspaceSheet[] {
): readonly SheetTab[] {
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
}
@@ -697,14 +699,14 @@ function findSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): WorkspaceSheet | undefined {
): SheetTab | undefined {
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
}
function withSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
sheets: readonly WorkspaceSheet[],
sheets: readonly SheetTab[],
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
if (sheets.length > 0) {
return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } };
@@ -732,7 +734,7 @@ function withSheetTab(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
update: (sheet: WorkspaceSheet) => WorkspaceSheet,
update: (sheet: SheetTab) => SheetTab,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
@@ -777,13 +779,13 @@ function resolveOpenParentTabId(
function placeTargetInSheet(input: {
/** The sheet whose tab the target keeps, if any. */
kept: WorkspaceSheet | undefined;
kept: SheetTab | undefined;
target: WorkspaceTabTarget;
intent: WorkspaceTabOpenIntent;
policy: CompactSheetPolicy;
parentTabId: string | null;
state: JsonValue | undefined;
}): WorkspaceSheet {
}): SheetTab {
if (input.kept) {
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
}
@@ -857,14 +859,14 @@ function promoteSheetToTab(
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
* panel and drop the work. Only the explicit, guarded close removes it.
*/
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean {
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean {
return isSheetTabModified(workspaceKey, sheet.tab.tabId);
}
/**
* Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to
* the top; the sheets above it close, except ones holding unsaved work, which stay beneath it.
* Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by
* closing the sheets above it, except ones holding unsaved work, which stay where they are.
*/
function resolveSheetPlacement(
state: WorkspaceLayoutStore,
@@ -915,7 +917,7 @@ function openSheetTabInFront(
*/
function sheetAgentNoLongerFits(
workspaceKey: string,
sheet: WorkspaceSheet,
sheet: SheetTab,
snapshot: WorkspaceTabSnapshot,
): boolean {
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
@@ -1001,16 +1003,17 @@ export function createWorkspaceLayoutStore(
parentTabId,
state: input.state,
});
const below =
const next =
keptIndex === null
? sheets
: [
...sheets.slice(0, keptIndex),
...sheets
.slice(keptIndex + 1)
.filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)),
];
set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet]));
? [...sheets, sheet]
: sheets.flatMap((existing, index) => {
if (index === keptIndex) return [sheet];
if (index < keptIndex) return [existing];
return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing)
? [existing]
: [];
});
set((state) => withSheets(state, normalizedWorkspaceKey, next));
return sheet.tab.tabId;
}
const placement = getOpenTabPlacement(
+1
View File
@@ -9,3 +9,4 @@ export {
type ArchiveFinishedStatus,
type UseArchiveFinishedSubagentsInput,
} from "./use-archive-finished";
export { useSubagentOpensInSheet } from "./opens-in-sheet";
@@ -0,0 +1,133 @@
/**
* @vitest-environment jsdom
*/
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useSubagentOpensInSheet } from "./opens-in-sheet";
import { selectSubagentsForParent, type SubagentRow } from "./select";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main";
const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: SERVER_ID,
id: "agent",
provider: "codex",
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: AGENT_TIMESTAMP,
updatedAt: AGENT_TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: AGENT_TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
model: null,
features: undefined,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
if (!workspaceKey) throw new Error("workspace key");
function seedParentWithChild(): SubagentRow {
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(
SERVER_ID,
new Map([
[parent.id, parent],
[child.id, child],
]),
);
const [row] = selectSubagentsForParent(
useSessionStore.getState(),
{ serverId: SERVER_ID, parentAgentId: "parent" },
new Set(),
);
if (!row) throw new Error("child row");
return row;
}
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useWorkspaceLayoutStore.setState({
layoutMode: "split-panes",
layoutByWorkspace: {},
sheetsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
});
});
describe("useSubagentOpensInSheet", () => {
it("re-renders the row's answer as the child's tab opens and closes", () => {
const row = seedParentWithChild();
const store = useWorkspaceLayoutStore.getState();
store.setLayoutMode("compact");
const target = { kind: "agent", agentId: "child" } as const;
// The row renders its answer, as the track does, so a stale render is a stale answer.
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
expect(result.current).toBe(true);
let tabId: string | null = null;
act(() => {
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
});
expect(tabId).not.toBeNull();
expect(result.current).toBe(false);
act(() => {
store.closeTab(workspaceKey, tabId ?? "");
});
expect(result.current).toBe(true);
});
});
@@ -0,0 +1,29 @@
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { SubagentRow } from "./select";
/** The workspace target a subagent row opens. */
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
return row.kind === "provider"
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
: { kind: "agent", agentId: row.id };
}
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
export function useSubagentOpensInSheet(
workspaceKey: string | null,
): (row: SubagentRow) => boolean {
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
// would still close the list it sits in.
useWorkspaceLayoutStore((state) => state.layoutMode);
useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
return (row) =>
workspaceKey !== null &&
useWorkspaceLayoutStore
.getState()
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
}
+10
View File
@@ -35,6 +35,11 @@ export interface SubagentsTrackProps {
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
/**
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
*/
opensInSheet: (row: SubagentRow) => boolean;
onArchiveSubagent: (id: string) => void;
onArchiveFinished?: () => void;
archiveFinishedStatus?: ArchiveFinishedStatus;
@@ -63,6 +68,7 @@ export function SubagentsTrack({
rows,
onOpenSubagent,
onOpenProviderSubagent,
opensInSheet,
onArchiveSubagent,
onArchiveFinished,
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
@@ -103,6 +109,7 @@ export function SubagentsTrack({
serverId={serverId}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
opensInSheet={opensInSheet(row)}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
@@ -174,6 +181,7 @@ interface SubagentsTrackRowProps {
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: boolean;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
}
@@ -183,6 +191,7 @@ function SubagentsTrackRow({
row,
onOpenSubagent,
onOpenProviderSubagent,
opensInSheet,
onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackRowProps): ReactElement {
@@ -245,6 +254,7 @@ function SubagentsTrackRow({
<ComposerTrackRow
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
closeOnSelect={!opensInSheet}
onPress={handlePress}
>
{renderRow}
@@ -0,0 +1,58 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { localFileSourceToPath } from "@/attachments/utils";
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
import { resolveAssistantImageSource } from "./assistant-image-source";
describe("local assistant Markdown images", () => {
let workspaceRoot: string;
const imageBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
beforeEach(async () => {
workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-"));
await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true });
await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes);
await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes);
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true });
});
it.each([
"absolute angle",
"absolute encoded",
"relative angle",
"relative encoded",
"file URI",
"literal percent",
])("reads the actual image for the %s destination", async (form) => {
const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png");
const destinations: Record<string, string> = {
"absolute angle": `<${absolute}>`,
"absolute encoded": encodeURI(absolute),
"relative angle": "<My Apps/Мои/test.png>",
"relative encoded": encodeURI("My Apps/Мои/test.png"),
"file URI": `file://${encodeURI(absolute)}`,
"literal percent": `${workspaceRoot}/literal%2520name.png`,
};
const tokens = createAssistantMarkdownParser().parseInline(
`![test](${destinations[form]})`,
{},
);
const image = tokens
.flatMap((token) => token.children ?? [])
.find((token) => token.type === "image");
expect(image).toBeDefined();
const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true });
const target = resolveAssistantImageSource({ source, workspaceRoot });
expect(target?.kind).toBe("file_rpc");
if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target");
await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes);
});
});
+20 -2
View File
@@ -3,6 +3,7 @@
// to keep the module graph importable.
const Stub = () => null;
const PassThrough = ({ children }: { children?: unknown }) => children;
const sharedValue = (value: unknown) => ({ get: () => value });
export default Stub;
export const BottomSheetModal = Stub;
@@ -17,10 +18,27 @@ export function useBottomSheetModal(): never {
throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests");
}
// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host.
export function useBottomSheet(): never {
throw new Error("useBottomSheet is unavailable in tests");
}
export const BottomSheetModalInternalProvider = PassThrough;
export const INITIAL_CONTAINER_LAYOUT = {
height: 0,
offset: { top: 0, bottom: 0, left: 0, right: 0 },
};
export function useBottomSheetModalInternal() {
return {
hostName: "stub",
containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT),
mountSheet: () => undefined,
unmountSheet: () => undefined,
willUnmountSheet: () => undefined,
};
}
// The app imports the internal layout values to compute the visible sheet body. Browser unit tests
// never render the native sheet, but Vite still validates this module's export surface.
const sharedValue = (value: unknown) => ({ get: () => value });
export const KEYBOARD_STATUS = { SHOWN: "SHOWN" };
export function useBottomSheetInternal() {
return {
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -3098,7 +3098,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.cachedCommands = update.availableCommands.map((command) => ({
name: command.name,
description: command.description,
argumentHint: "",
argumentHint: command.input?.hint ?? "",
kind: "command",
}));
this.settleCommandsReady();
@@ -0,0 +1,42 @@
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
// Explicit real-provider test: run separately from the default suite.
test("uses the active model context window after switching from a 1M model", async () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd(), model: "claude-haiku-4-5" },
undefined,
{ persistSession: false },
);
try {
for (const [model, maxTokens] of [
["claude-haiku-4-5", 200_000],
["claude-opus-5-5", 1_000_000],
["claude-haiku-4-5", 200_000],
] as const) {
await session.setModel(model);
const events = await collectSessionTurnEvents(
session,
"Reply with exactly: OK. Do not use tools.",
{ signal: AbortSignal.timeout(90_000) },
);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }),
}),
);
}
} finally {
await session.close();
}
}, 300_000);
@@ -6,12 +6,28 @@ import type { ClaudeQueryFactory } from "./query.js";
// A query dependency that speaks the same stream interface as Claude Code.
// Each request repeats init so a second turn exercises runtime-limit retention.
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
function contextQuery({
runtimeModel,
reportedLimit,
modelWindowsByTurn,
}: {
runtimeModel: string;
reportedLimit: number;
modelWindowsByTurn?: Array<Record<string, number>>;
}): ClaudeQueryFactory {
return ({ prompt }) => {
let model = runtimeModel;
let turn = 0;
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const modelWindows = modelWindowsByTurn?.[turn] ?? { [model]: reportedLimit };
turn += 1;
const usageEntries = Object.entries(modelWindows).map(([id, contextWindow]) => [
id,
{ contextWindow },
]);
const modelUsage = Object.fromEntries(usageEntries);
yield {
type: "system",
subtype: "init",
@@ -41,7 +57,7 @@ function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryF
stop_reason: null,
total_cost_usd: 0,
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
modelUsage: { [model]: { contextWindow: reportedLimit } },
modelUsage,
permission_denials: [],
uuid: "context-result",
};
@@ -71,7 +87,7 @@ test.each([
async ({ selected, runtime, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: contextQuery(runtime, 800_000),
queryFactory: contextQuery({ runtimeModel: runtime, reportedLimit: 800_000 }),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
@@ -110,3 +126,93 @@ test.each([
}
},
);
test("completed turns use the active model's window after switching both ways", async () => {
const haiku = "claude-haiku-4-5";
const opus = "claude-opus-5-5";
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => process.execPath,
queryFactory: contextQuery({
runtimeModel: haiku,
reportedLimit: 200_000,
modelWindowsByTurn: [
{ [haiku]: 200_000 },
{ [haiku]: 200_000, [opus]: 1_000_000 },
{ [haiku]: 200_000, [opus]: 1_000_000 },
],
}),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: haiku,
});
try {
for (const [model, limit] of [
[haiku, 200_000],
[opus, 1_000_000],
[haiku, 200_000],
] as const) {
await session.setModel(model);
const events = await collectSessionTurnEvents(session, "OK");
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowMaxTokens: limit }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: limit }),
}),
);
}
} finally {
await session.close();
}
});
test.each([
{
name: "runtime-reported custom limit",
model: "custom-model",
windows: { "custom-model": 400_000, "prior-model": 1_000_000 },
expected: 400_000,
},
{
name: "missing active model entry",
model: "claude-haiku-4-5",
windows: { "prior-model": 1_000_000 },
expected: 200_000,
},
{
name: "invalid active model limit",
model: "claude-haiku-4-5",
windows: { "claude-haiku-4-5": -1, "prior-model": 1_000_000 },
expected: 200_000,
},
])("preserves $name without using another model's window", async ({ model, windows, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => process.execPath,
queryFactory: contextQuery({
runtimeModel: model,
reportedLimit: expected,
modelWindowsByTurn: [windows],
}),
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd(), model });
try {
const events = await collectSessionTurnEvents(session, "OK");
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: expected }),
}),
);
} finally {
await session.close();
}
});
@@ -0,0 +1,239 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
const SESSION_CWD = "/tmp/paseo-claude-renamed-import";
interface ClaudeTranscriptRecord {
[key: string]: unknown;
}
let configDir: string;
let previousConfigDir: string | undefined;
interface UserRecordInput {
sessionId: string;
content: string;
}
interface WriteSessionInput {
sessionId: string;
records: ClaudeTranscriptRecord[];
}
function userRecord({ sessionId, content }: UserRecordInput): ClaudeTranscriptRecord {
return {
isSidechain: false,
type: "user",
message: { role: "user", content },
cwd: SESSION_CWD,
sessionId,
};
}
async function writeSession({ sessionId, records }: WriteSessionInput): Promise<void> {
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
path.join(projectDir, `${sessionId}.jsonl`),
`${records.map((record) => JSON.stringify(record)).join("\n")}\n`,
);
}
// Importing replays the transcript from disk, so this drives the real resume
// path without starting Claude Code.
async function importSession(sessionId: string) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
});
return client.importSession(
{ providerHandleId: sessionId, cwd: SESSION_CWD },
{
config: { provider: "claude", cwd: SESSION_CWD },
storedConfig: { provider: "claude", cwd: SESSION_CWD },
},
);
}
beforeEach(async () => {
configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-renamed-"));
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
});
afterEach(async () => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
await fs.rm(configDir, { recursive: true, force: true });
});
describe("importing a renamed Claude session", () => {
test.each(["provider", "launch"])(
"reads the rename from the %s config directory used for history",
async (source) => {
const sessionId = "provider-directory-rename";
const providerConfigDir = path.join(configDir, "provider");
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir: providerConfigDir });
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
path.join(projectDir, `${sessionId}.jsonl`),
[
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "My research session", sessionId },
]
.map((record) => JSON.stringify(record))
.join("\n") + "\n",
);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings:
source === "provider" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined,
resolveBinary: async () => "/test/claude/bin",
});
const imported = await client.importSession(
{ providerHandleId: sessionId, cwd: SESSION_CWD },
{
config: { provider: "claude", cwd: SESSION_CWD },
storedConfig: { provider: "claude", cwd: SESSION_CWD },
launchContext:
source === "launch" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined,
},
);
expect(imported.timeline.map((entry) => entry.item)).toContainEqual(
expect.objectContaining({ type: "user_message", text: "Review this project" }),
);
expect(imported.config.title).toBe("My research session");
},
);
test("names the imported agent after a /rename", async () => {
const sessionId = "renamed-session";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "My research session", sessionId },
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("My research session");
});
test("uses the last rename when the session was renamed more than once", async () => {
const sessionId = "renamed-twice";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Move invoices to the new schema" }),
{ type: "custom-title", customTitle: "invoices", sessionId },
{ type: "custom-title", customTitle: "billing rework", sessionId },
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("billing rework");
});
test("preserves a rename when the first prompt was /model", async () => {
const sessionId = "model-first";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "/model" }),
{ type: "custom-title", customTitle: "My research session", sessionId },
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("My research session");
});
test("keeps the rename when a later record carries no usable name", async () => {
const sessionId = "malformed-later-record";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "My research session", sessionId },
{ type: "custom-title", sessionId },
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("My research session");
});
test("ignores a rename recorded on a sidechain transcript", async () => {
const sessionId = "sidechain-rename";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "Subagent title", sessionId, isSidechain: true },
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBeUndefined();
});
test("leaves the agent untitled when the session was never renamed", async () => {
const sessionId = "never-renamed";
await writeSession({
sessionId,
records: [userRecord({ sessionId, content: "Review this project" })],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBeUndefined();
});
test("does not name the imported agent after a generated ai-title", async () => {
const sessionId = "auto-titled";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Add pagination to the invoice list" }),
{ type: "ai-title", aiTitle: "Invoice list pagination", sessionId },
],
});
const imported = await importSession(sessionId);
// The Import Session list shows it, but agents are named from their first
// prompt line.
expect(imported.config.title).toBeUndefined();
});
test("imports a session that has no transcript on disk", async () => {
const imported = await importSession("no-such-session");
expect(imported.config.title).toBeUndefined();
});
test("surfaces an unreadable transcript instead of importing it as never renamed", async () => {
const sessionId = "unreadable-transcript";
// A directory where the transcript belongs fails the read with EISDIR, the
// deterministic stand-in for a permissions or I/O fault.
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(path.join(projectDir, `${sessionId}.jsonl`), { recursive: true });
await expect(importSession(sessionId)).rejects.toThrow();
});
});
@@ -203,24 +203,26 @@ function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetada
};
}
export function normalizeClaudeAskUserQuestionUpdatedInput(
/**
* The answers this response will actually deliver, keyed by full question text,
* or null when there is no question list and answers map to work from.
*
* Shared by the normalizer and by the check that rejects an undeliverable
* answer, so the two can never disagree about what "deliverable" means.
*/
function resolveClaudeAskUserQuestionAnswers(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): AgentMetadata {
): Record<string, string> | null {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
const questions =
(Array.isArray(base.questions) ? base.questions : null) ??
(Array.isArray(fallback.questions) ? fallback.questions : null);
const answers = isMetadata(base.answers) ? base.answers : null;
if (!questions || !answers) {
return merged;
return null;
}
const normalizedAnswers: Record<string, string> = {};
@@ -244,7 +246,23 @@ export function normalizeClaudeAskUserQuestionUpdatedInput(
}
}
if (Object.keys(normalizedAnswers).length === 0) {
return normalizedAnswers;
}
export function normalizeClaudeAskUserQuestionUpdatedInput(
updatedInput: AgentMetadata | undefined,
fallbackInput: AgentMetadata | undefined,
): AgentMetadata {
const fallback = isMetadata(fallbackInput) ? fallbackInput : {};
const base = isMetadata(updatedInput) ? updatedInput : {};
// Paseo's shared question UI serializes answers by question header, but Claude's
// AskUserQuestion tool expects answer keys to match the full question text. Merge
// the original request payload back in so provider callbacks that only return
// `{ answers }` still satisfy Claude's full tool input schema.
const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base });
const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput);
if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) {
return merged;
}
@@ -254,6 +272,32 @@ export function normalizeClaudeAskUserQuestionUpdatedInput(
};
}
class ClaudeQuestionAnswerRequiredError extends Error {
readonly code = "question_answer_required" as const;
constructor(readonly requestId: string) {
super(
`Permission request '${requestId}' requires updatedInput.answers keyed by a ` +
`question's full text or header, with a non-empty string value. ` +
`The request is still pending; retry with that shape.`,
);
this.name = "ClaudeQuestionAnswerRequiredError";
}
}
function assertClaudeQuestionAnswerDeliverable(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): void {
if (request.kind !== "question" || response.behavior !== "allow") {
return;
}
const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input);
if (!answers || Object.keys(answers).length === 0) {
throw new ClaudeQuestionAnswerRequiredError(request.id);
}
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -1693,11 +1737,21 @@ export class ClaudeAgentClient implements AgentClient {
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
// Carry a `/rename` onto the imported agent so the name chosen in the CLI
// survives the import. Only an explicit rename counts: agents are named
// after their first prompt line since #1563, so adopting the generated
// `ai-title` here would put that LLM summary back.
const renamedTitle = await readClaudeRenamedSessionTitle({
cwd: input.cwd,
sessionId: input.providerHandleId,
configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)),
});
return importSessionFromPersistence({
provider: "claude",
request: input,
context,
resumeSession: this.resumeSession.bind(this),
...(renamedTitle ? { config: { title: renamedTitle } } : {}),
});
}
@@ -1829,30 +1883,22 @@ async function resolveClaudeAuth(
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined {
function extractContextWindowSize(
modelUsage: unknown,
modelId: string | null | undefined,
): number | undefined {
const usageRecord = toObjectRecord(modelUsage);
if (!usageRecord) {
return undefined;
}
let maxContextWindow: number | undefined;
for (const value of Object.values(usageRecord)) {
const valueRecord = toObjectRecord(value);
if (!valueRecord) {
continue;
}
const contextWindow = valueRecord.contextWindow;
if (
typeof contextWindow !== "number" ||
!Number.isFinite(contextWindow) ||
contextWindow <= 0
) {
continue;
}
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
}
return maxContextWindow;
// modelUsage accumulates every model used in the session, including earlier turns
// and subagents. Only the active model describes this turn's context capacity.
const modelUsageEntry = modelId ? usageRecord[modelId] : undefined;
const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow;
return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
? contextWindow
: undefined;
}
interface StreamRequestInputUsage {
@@ -2006,8 +2052,8 @@ class ClaudeContextUsageState {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
recordModelUsage(modelUsage: unknown): number | undefined {
const contextWindowMaxTokens = extractContextWindowSize(modelUsage);
recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined {
const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId);
if (contextWindowMaxTokens !== undefined) {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
@@ -2050,7 +2096,10 @@ class ClaudeContextUsageState {
return this.createUsageUpdatedEvent(usedTokens);
}
buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined {
buildResultUsage(
message: SDKResultMessage,
modelId: string | null | undefined,
): AgentUsage | undefined {
try {
if (!message.usage) {
return undefined;
@@ -2062,7 +2111,7 @@ class ClaudeContextUsageState {
totalCostUsd: message.total_cost_usd,
};
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId);
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
} else if (modelContextWindowMaxTokens !== undefined) {
@@ -2729,6 +2778,12 @@ class ClaudeAgentSession implements AgentSession {
if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
// Before the delete below, so a rejected answer leaves the request pending
// and answerable rather than consuming it for nothing. AgentManager only
// drops its own copy once this resolves, so both maps survive the throw.
assertClaudeQuestionAnswerDeliverable(pending.request, response);
this.pendingPermissions.delete(requestId);
pending.cleanup?.();
@@ -4696,7 +4751,7 @@ class ClaudeAgentSession implements AgentSession {
message: Extract<SDKMessage, { type: "result" }>,
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message, message.modelUsage);
const usage = this.convertUsage(message);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -4878,8 +4933,8 @@ class ClaudeAgentSession implements AgentSession {
return null;
}
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, modelUsage);
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model);
}
private handlePermissionRequest: CanUseTool = async (
@@ -6608,6 +6663,66 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
return normalized ? normalized : null;
}
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
/**
* The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Searching backwards finds it without
* walking the transcript, which matters because sessions reach tens of
* megabytes.
*/
function readLastClaudeCustomTitle(content: string): string | null {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
let at = content.lastIndexOf(needle);
while (at >= 0) {
const lineStart = content.lastIndexOf("\n", at) + 1;
const lineEnd = content.indexOf("\n", at);
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
let parsed: unknown;
try {
parsed = JSON.parse(line.trim());
} catch {
parsed = undefined;
}
const entry = toObjectRecord(parsed);
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
const title = normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
if (title) {
return title;
}
}
// The needle also matches a prompt that quotes it, and a record can carry no
// usable name, so keep looking backwards for one that does.
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
}
return null;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
async function readClaudeRenamedSessionTitle(input: {
cwd: string;
sessionId: string;
configDir: string;
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
if (isErrnoException(error) && error.code === "ENOENT") {
return null;
}
throw error;
}
return readLastClaudeCustomTitle(content);
}
function normalizeImportablePromptPreview(text: string): string | null {
const normalized = text.trim().replace(/\s+/g, " ");
if (!normalized) return null;
@@ -0,0 +1,164 @@
import { describe, expect, test } from "vitest";
import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
import type { AgentPermissionResponse } from "../../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
// The SDK dependency receives the same permission callback as a real Claude process.
// Keep its stream open until close; permission state belongs entirely to the session.
function createSdkFake() {
let canUseTool: CanUseTool | undefined;
let finish!: () => void;
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const queryFactory: ClaudeQueryFactory = ({ options }) => {
canUseTool = options.canUseTool;
return {
close: finish,
return: async () => {
finish();
return { value: undefined, done: true };
},
[Symbol.asyncIterator]() {
return {
next: async (): Promise<IteratorResult<SDKMessage, void>> => {
await finished;
return { value: undefined, done: true };
},
};
},
} as Query;
};
return {
queryFactory,
ask: (input: Record<string, unknown>) => {
if (!canUseTool) throw new Error("SDK permission callback was not configured");
return canUseTool("AskUserQuestion", input, {
signal: new AbortController().signal,
toolUseID: "question-tool-call",
});
},
};
}
const questionInput = {
questions: [{ question: "Which colour?", header: "Colour", options: [], multiSelect: false }],
};
const invalidResponses: Array<[string, AgentPermissionResponse]> = [
["selectedActionId", { behavior: "allow", selectedActionId: "Viridian" }],
["missing answers", { behavior: "allow", updatedInput: {} }],
["unknown question", { behavior: "allow", updatedInput: { answers: { Hue: "Viridian" } } }],
["blank answer", { behavior: "allow", updatedInput: { answers: { Colour: " " } } }],
["non-string answer", { behavior: "allow", updatedInput: { answers: { Colour: 1 } } }],
[
"empty echoed questions",
{ behavior: "allow", updatedInput: { questions: [], answers: { Colour: "Viridian" } } },
],
];
describe("Claude question permission responses", () => {
test.each(invalidResponses)(
"rejects %s and delivers a corrected retry",
async (_label, response) => {
const sdk = createSdkFake();
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdk.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
try {
await session.startTurn("Ask which colour");
const answer = sdk.ask(questionInput);
void answer.catch(() => undefined);
const request = session.getPendingPermissions()[0]!;
expect(request.kind).toBe("question");
await expect(session.respondToPermission(request.id, response)).rejects.toMatchObject({
code: "question_answer_required",
requestId: request.id,
});
expect(session.getPendingPermissions()).toEqual([request]);
await session.respondToPermission(request.id, {
behavior: "allow",
updatedInput: { answers: { Colour: "Viridian" } },
});
await expect(answer).resolves.toMatchObject({
behavior: "allow",
updatedInput: { answers: { "Which colour?": "Viridian" } },
});
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
},
);
test.each([
["header", questionInput, { Colour: "Viridian" }, { "Which colour?": "Viridian" }],
["question text", questionInput, { "Which colour?": "Ochre" }, { "Which colour?": "Ochre" }],
[
"padded question text",
{ questions: [{ question: " Which colour? ", header: " Colour " }] },
{ " Which colour? ": "Ochre" },
{ " Which colour? ": "Ochre" },
],
[
"padded header",
{ questions: [{ question: " Which colour? ", header: " Colour " }] },
{ " Colour ": "Viridian" },
{ " Which colour? ": "Viridian" },
],
[
"partial answer",
{ questions: [...questionInput.questions, { question: "Ship it?", header: "Ship" }] },
{ Ship: "Yes" },
{ "Ship it?": "Yes" },
],
])("delivers answers keyed by %s", async (_label, input, answers, expected) => {
const sdk = createSdkFake();
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdk.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
try {
await session.startTurn("Ask which colour");
const answer = sdk.ask(input);
void answer.catch(() => undefined);
const request = session.getPendingPermissions()[0]!;
await session.respondToPermission(request.id, {
behavior: "allow",
updatedInput: { answers },
});
await expect(answer).resolves.toMatchObject({
behavior: "allow",
updatedInput: { answers: expected },
});
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test("allows denying a question without answers", async () => {
const sdk = createSdkFake();
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdk.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
try {
await session.startTurn("Ask which colour");
const answer = sdk.ask(questionInput);
void answer.catch(() => undefined);
const request = session.getPendingPermissions()[0]!;
await session.respondToPermission(request.id, { behavior: "deny", message: "No answer" });
await expect(answer).resolves.toMatchObject({ behavior: "deny", message: "No answer" });
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
});
@@ -8,7 +8,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient slash commands", () => {
test("lists commands an agent advertises right after session/new", async () => {
test("preserves argument hints in commands advertised right after session/new", async () => {
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
@@ -17,9 +17,17 @@ describe("GenericACPAgentClient slash commands", () => {
});
const session = await client.createSession({ provider: "acp", cwd });
try {
await expect(session.listCommands?.()).resolves.toEqual([
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
]);
const commands = [
{
name: "review",
description: "Review the diff",
argumentHint: "query",
kind: "command",
},
{ name: "status", description: "Show status", argumentHint: "", kind: "command" },
];
await expect(session.listCommands?.()).resolves.toEqual(commands);
await expect(session.listCommands?.()).resolves.toEqual(commands);
} finally {
await session.close();
}
@@ -91,7 +99,10 @@ rl.on("line", (line) => {
sessionId: "session-1",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review", description: "Review the diff" }],
availableCommands: [
{ name: "review", description: "Review the diff", input: { hint: "query" } },
{ name: "status", description: "Show status" },
],
},
},
},
@@ -157,15 +157,19 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
chunkSize: 5,
});
const uploadId = result.file?.id ?? "";
expect(uploadId).toMatch(
/^upload_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(result).toEqual({
requestId: "req-upload-e2e",
file: {
type: "uploaded_file",
id: "upload_req-upload-e2e",
id: uploadId,
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"),
path: path.join(daemon.paseoHome, "uploads", uploadId, "notes.txt"),
},
error: null,
});
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
@@ -85,3 +85,76 @@ test.each(["uncommitted", "base", "commit"] as const)(
},
60_000,
);
test.each(
(["uncommitted", "base"] as const).flatMap((mode) =>
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
),
)(
"$mode $shape diff includes hunks from a repository subdirectory",
async ({ mode, shape }) => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
git(cwd, "config", "core.autocrlf", "false");
const subdirectory = join(cwd, "sub");
mkdirSync(subdirectory);
const unchangedLines = "unchanged\n".repeat(10);
writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`);
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "old sibling\n");
}
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
if (mode === "base") {
git(cwd, "checkout", "-b", "feature");
}
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
if (shape === "rename") {
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
}
writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`);
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
}
if (mode === "base") {
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
}
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
@@ -67,6 +67,7 @@ describe("daemon E2E - Claude history stored under another project folder", () =
content: "reply from the original folder",
},
},
{ type: "custom-title", customTitle: "Renamed moved session", sessionId: SESSION_ID },
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join(""),
@@ -114,6 +115,7 @@ describe("daemon E2E - Claude history stored under another project folder", () =
sessionId: SESSION_ID,
cwd: agentCwd,
});
expect(agent.title).toBe("Renamed moved session");
await stopDaemon();
const restartedClient = await startDaemon();
@@ -55,6 +55,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR"
cwd,
message: { role: "assistant", content: "reply from the custom config dir" },
},
{ type: "custom-title", customTitle: "Renamed provider session", sessionId: SESSION_ID },
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join(""),
@@ -111,6 +112,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR"
sessionId: SESSION_ID,
cwd,
});
expect(agent.title).toBe("Renamed provider session");
await stopDaemon();
const restartedClient = await startDaemon();
@@ -8,6 +8,7 @@ import {
readdirSync,
readlinkSync,
} from "fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "os";
import path from "path";
import { execFileSync } from "node:child_process";
@@ -100,7 +101,9 @@ describe("checkout discard", () => {
}
} finally {
// afterEach shuts down the daemon and its repository observers first.
onTestFinished(() => rmSync(cwd, { recursive: true, force: true }));
onTestFinished(() =>
rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }),
);
}
},
);
@@ -124,15 +124,12 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
await expect
.poll(
async () => {
const snapshot = await client.getProvidersSnapshot({ cwd });
return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status;
},
{ timeout: 30000 },
)
.toBe("ready");
// Snapshot reads start discovery again; await one refresh before checking readiness.
await client.refreshProvidersSnapshot({ cwd, providers: ["antigravity"] });
const snapshot = await client.getProvidersSnapshot({ cwd });
expect(snapshot.entries.find((entry) => entry.provider === "antigravity")?.status).toBe(
"ready",
);
const agent = await client.createAgent({
provider: "antigravity",
cwd,
@@ -5,6 +5,44 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
test("a rename-only commit returns zero added or deleted lines through the client", async () => {
const cwd = mkdtempSync(join(tmpdir(), "commit-rename-diff-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
writeFileSync(join(cwd, "old.txt"), "same\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
git("mv", "old.txt", "new.txt");
git("-c", "commit.gpgsign=false", "commit", "-m", "rename");
const sha = git("rev-parse", "HEAD");
await client.connect();
const history = await client.listCheckoutCommits(cwd);
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
{ path: "new.txt", additions: 0, deletions: 0, status: "renamed" },
]);
expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({
file: {
path: "new.txt",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
},
});
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
test.skipIf(process.platform === "win32")(
"checkout and commit diffs preserve filenames that Git quotes",
async () => {
@@ -169,3 +169,47 @@ test("a restored public SDK timeline leaves missed history to the consumer", asy
await daemon.close();
}
});
test("active directory observers agree on creation and workspace archive across clients", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const actor = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
const observer = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.8.0",
});
const directory = new Set<string>();
try {
await actor.connect();
await observer.connect();
const subscription = observer.observeAgents({
scope: "active",
sync: {},
includeBackground: true,
});
subscription.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type !== "agent_update") return;
if (message.payload.kind === "upsert") directory.add(message.payload.agent.id);
else directory.delete(message.payload.agentId);
},
});
await subscription.ready;
const agent = await actor.createAgent({
provider: "codex",
cwd: daemon.staticDir,
title: "Cross-client agent",
});
await expect.poll(() => directory.has(agent.id)).toBe(true);
const beforeArchive = await observer.fetchAgents({ scope: "active", sync: {} });
expect(beforeArchive.entries.map((entry) => entry.agent.id)).toContain(agent.id);
await actor.archiveWorkspace(agent.workspaceId!);
const afterArchive = await observer.fetchAgents({ scope: "active", sync: {} });
expect(afterArchive.entries.map((entry) => entry.agent.id)).not.toContain(agent.id);
await expect.poll(() => directory.has(agent.id)).toBe(false);
} finally {
await actor.close();
await observer.close();
await daemon.close();
}
});
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import type { ForgeService } from "../services/forge-service.js";
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
import {
CheckoutSourceForgeMismatchError,
MissingCheckoutTargetError,
@@ -32,14 +33,8 @@ function createResolverHarness(overrides?: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized)
? normalized
: null;
return owner ? `${owner}/${headRef}` : headRef;
},
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
}
: {};
@@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("does not track origin for a same-repo head with no origin branch", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "ford/towel",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("configures the contributor remote for fork PR targets", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
@@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("prefixes the pr number when the fork was deleted", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "towel",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" });
expect(intent).not.toHaveProperty("pushRemoteUrl");
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("uses an explicit PR head ref without calling GitHub", async () => {
const deps = createResolverHarness();
@@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("names the local branch pr-<number> for a gitea pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "pr-3" });
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("names the local branch owner/pr-<number> for a gitea fork pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: "contributor",
headRepositorySshUrl: "git@gitea.test:contributor/repo.git",
headRepositoryUrl: "https://gitea.test/contributor/repo",
isCrossRepository: true,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" });
});
test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "pr-3" });
});
test("reports unsupported cross-repository checkout targets without a checkout ref", async () => {
const deps = createResolverHarness({
forge: "gitea",
@@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent(
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
const trackOriginHead = !checkoutTarget.isCrossRepository;
const trackOriginHead =
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
return {
kind: "checkout-change-request",
@@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget {
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
/** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
headRefKind?: "branch" | "pull-ref";
}
export interface PullRequestCheckoutRef {
@@ -441,9 +443,9 @@ export interface ForgeService {
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
/**
* Refs to fetch for a change-request checkout when the resolved checkout
* target carries none. Adapters that expose a universal change-request head
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
* back to the head branch.
* target carries none. Adapters with a universal change-request head ref
* (refs/pull/N/head) return it here; others let the shell fall back to the
* head branch.
*/
defaultCheckoutRefs?(params: {
changeRequestNumber: number;
@@ -451,7 +453,7 @@ export interface ForgeService {
}): PullRequestCheckoutRef[];
/**
* Local branch name for a checked-out change request when the adapter
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
* disambiguates cross-repository heads (prefixes the fork owner).
* Returns undefined to keep the head ref name as-is.
*/
buildPrLocalBranchName?(params: {
@@ -2297,11 +2297,13 @@ describe("createGiteaService", () => {
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "branch",
});
});
@@ -2332,17 +2334,215 @@ describe("createGiteaService", () => {
headRefName: "feat/sample-change",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
headOwnerLogin: "contributor",
headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git",
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
isCrossRepository: true,
headRefKind: "branch",
});
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
});
it("skips the origin branch fallback and tracking for an AGit pull request", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
flow: 1,
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target).toMatchObject({
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
isCrossRepository: false,
headRefKind: "pull-ref",
});
});
it("names the local branch pr-<number> end to end for an agit pull request", async () => {
// the tea CLI reports head as the raw pull ref too when there's no real
// branch, same as the Forgejo API's head.ref
const { service } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
flow: 1,
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify({ ...STATUS_PR_VIEW, head: "refs/pull/5/head" })),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
const localBranchName = service.buildPrLocalBranchName?.({
headRef: target.headRefName,
checkoutTarget: target,
});
expect(localBranchName).toBe("pr-5");
});
it("skips the origin branch fallback when the head branch was deleted", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
head: { ref: "refs/pull/5/head", repo: { id: 1 } },
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target).toMatchObject({
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
isCrossRepository: false,
headRefKind: "pull-ref",
});
});
it("keeps a pull request from a deleted fork cross-repository", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(JSON.stringify({ head: { repo: null }, base: { repo: { id: 1 } } }))
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target).toMatchObject({
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
],
headOwnerLogin: null,
isCrossRepository: true,
headRefKind: "branch",
});
});
it("keeps the origin branch fallback when the server sends no head ref", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } }))
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 });
expect(target.checkoutRefs).toContainEqual({
remoteName: "origin",
remoteRef: "refs/heads/feat/sample-change",
});
expect(target.headRefKind).toBe("branch");
});
it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "patch-1",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "patch-1",
headOwnerLogin: "contributor",
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
},
});
expect(localBranchName).toBe("contributor/patch-1");
});
it("names the local branch pr-<number> for a same-repo pull-ref-only head", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "refs/pull/5/head",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "refs/pull/5/head",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
},
});
expect(localBranchName).toBe("pr-5");
});
it("prefixes pr-<number> with the fork owner for a pull-ref-only head", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "refs/pull/5/head",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "refs/pull/5/head",
headOwnerLogin: "contributor",
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
},
});
expect(localBranchName).toBe("contributor/pr-5");
});
it("does not double the pr number for a deleted fork's pull-ref-only head", () => {
const { service } = makeService(() => ok(""));
const localBranchName = service.buildPrLocalBranchName?.({
headRef: "refs/pull/5/head",
checkoutTarget: {
number: 5,
baseRefName: "main",
headRefName: "refs/pull/5/head",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
},
});
expect(localBranchName).toBe("pr-5");
});
it("returns the pull head ref for default checkout refs", () => {
const { service } = makeService(() => ok(""));
expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "upstream", remoteRef: "refs/pull/5/head" },
]);
});
it("creates a pull request and parses the resulting URL and index", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "create") {
+50 -11
View File
@@ -5,6 +5,10 @@ import pLimit from "p-limit";
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { runGitCommand } from "../utils/run-git-command.js";
import {
buildForkLocalBranchName,
buildPullHeadCheckoutRefs,
} from "../utils/change-request-checkout.js";
import { execCommand } from "../utils/spawn.js";
import {
createCachedCliPathResolver,
@@ -75,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000;
// the sweep to the 250 most recent Actions tasks.
const GITEA_ACTION_PAGE_LIMIT = 50;
const GITEA_ACTION_MAX_PAGES = 5;
// PullRequestFlowAGit in forgejo models/issues/pull.go
const GITEA_PULL_REQUEST_FLOW_AGIT = 1;
/**
* Fields requested from `tea pr list -o json`. tea's default field set omits the
@@ -225,8 +231,12 @@ const GiteaPullRequestRepoSchema = z
const GiteaPullRequestApiSchema = z
.object({
flow: z.number().optional(),
head: z
.object({ repo: GiteaPullRequestRepoSchema.nullable().optional() })
.object({
ref: z.string().optional(),
repo: GiteaPullRequestRepoSchema.nullable().optional(),
})
.passthrough()
.optional(),
base: z
@@ -1435,17 +1445,30 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
return runJsonParse(args, runOptions, stdout, schema);
}
function resolveHeadRefKind(
pr: z.infer<typeof GiteaPullRequestApiSchema>,
number: number,
): "branch" | "pull-ref" {
// head.ref only becomes the branch name when the branch still exists,
// agit PRs and deleted branches keep the pull ref
const isPullRefOnly =
pr.flow === GITEA_PULL_REQUEST_FLOW_AGIT || pr.head?.ref === `refs/pull/${number}/head`;
return isPullRefOnly ? "pull-ref" : "branch";
}
async function resolveForkCheckoutFacts(
cwd: string,
summary: PullRequestSummary,
): Promise<{
isCrossRepository: boolean;
headRefKind: "branch" | "pull-ref";
headOwnerLogin: string | null;
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
}> {
const sameRepo = {
isCrossRepository: false,
headRefKind: "branch" as const,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
@@ -1464,14 +1487,17 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
);
const headRepo = pr.head?.repo ?? null;
const baseRepo = pr.base?.repo ?? null;
if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) {
return sameRepo;
const headRefKind = resolveHeadRefKind(pr, summary.number);
if (baseRepo?.id == null || headRepo?.id === baseRepo.id) {
return { ...sameRepo, headRefKind };
}
// headRepo is null when the fork was deleted
return {
isCrossRepository: true,
headOwnerLogin: headRepo.owner?.login ?? null,
headRepositorySshUrl: headRepo.ssh_url ?? null,
headRepositoryUrl: headRepo.html_url ?? null,
headRefKind,
headOwnerLogin: headRepo?.owner?.login ?? null,
headRepositorySshUrl: headRepo?.ssh_url ?? null,
headRepositoryUrl: headRepo?.html_url ?? null,
};
}
@@ -1997,17 +2023,29 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
return summary.headRefName;
},
defaultCheckoutRefs({ changeRequestNumber }) {
return buildPullHeadCheckoutRefs(changeRequestNumber);
},
buildPrLocalBranchName({ headRef, checkoutTarget }) {
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
},
async getPullRequestCheckoutTarget(
input: GetPullRequestOptions,
): Promise<PullRequestCheckoutTarget> {
const summary = await this.getPullRequest(input);
const checkoutRefs = [
{ remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` },
];
if (summary.headRefName) {
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number);
const checkoutRefs = [originPullRef];
// for a fork PR a branch with that name on origin belongs to the base repo
const headBranchOnOrigin =
!fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName);
if (headBranchOnOrigin) {
checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` });
}
const fork = await resolveForkCheckoutFacts(input.cwd, summary);
// upstream can hold a different PR with the same number, so it goes last
checkoutRefs.push(upstreamPullRef);
return {
number: summary.number,
baseRefName: summary.baseRefName,
@@ -2017,6 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
headRepositorySshUrl: fork.headRepositorySshUrl,
headRepositoryUrl: fork.headRepositoryUrl,
isCrossRepository: fork.isCrossRepository,
headRefKind: fork.headRefKind,
};
},
@@ -797,6 +797,7 @@ describe("ForgeService", () => {
headRepositorySshUrl: "git@github.com:therainisme/paseo.git",
headRepositoryUrl: "https://github.com/therainisme/paseo",
isCrossRepository: true,
headRefKind: "branch",
});
expect(runner.calls).toHaveLength(2);
+7 -13
View File
@@ -6,6 +6,10 @@ import {
} from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import { runGitCommand } from "../utils/run-git-command.js";
import {
buildForkLocalBranchName,
buildPullHeadCheckoutRefs,
} from "../utils/change-request-checkout.js";
import { execCommand } from "../utils/spawn.js";
import { resolveSshHostname } from "../utils/ssh-hostname.js";
import {
@@ -2104,17 +2108,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
},
defaultCheckoutRefs({ changeRequestNumber }) {
return [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
];
return buildPullHeadCheckoutRefs(changeRequestNumber);
},
buildPrLocalBranchName({ headRef, checkoutTarget }) {
const owner = checkoutTarget.isCrossRepository
? normalizeGitHubOwnerForBranch(checkoutTarget.headOwnerLogin)
: null;
return owner ? `${owner}/${headRef}` : headRef;
return buildForkLocalBranchName({ headRef, ...checkoutTarget });
},
supportsCrossRepoCheckoutWithoutRefs: true,
@@ -2663,11 +2661,6 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return api;
}
function normalizeGitHubOwnerForBranch(owner: string | null): string | null {
const normalized = owner?.trim().toLowerCase() ?? "";
return /^[a-z0-9-]+$/.test(normalized) ? normalized : null;
}
function getGithubStatusFacts(
status: PullRequestCommandStatus | null | undefined,
): GitHubPullRequestStatusFacts | null {
@@ -3529,6 +3522,7 @@ function toPullRequestCheckoutTarget(
headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null,
headRepositoryUrl: pullRequest.headRepository?.url || null,
isCrossRepository: pullRequest.isCrossRepository,
headRefKind: "branch",
};
}
@@ -477,6 +477,7 @@ describe("createGitLabService", () => {
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "branch",
});
expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]);
});
@@ -1204,6 +1204,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
mr.target_project_id !== undefined &&
mr.target_project_id !== null &&
mr.source_project_id !== mr.target_project_id,
headRefKind: "branch",
};
},
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
buildForkLocalBranchName,
normalizeForgeOwnerForBranch,
} from "./change-request-checkout.js";
describe("normalizeForgeOwnerForBranch", () => {
it("lowercases a valid login", () => {
expect(normalizeForgeOwnerForBranch("Contributor")).toBe("contributor");
});
it("allows dots and underscores", () => {
expect(normalizeForgeOwnerForBranch("a.b_c")).toBe("a.b_c");
});
it("rejects a login starting with a dash", () => {
expect(normalizeForgeOwnerForBranch("-abc")).toBeNull();
});
it("rejects a login starting with a dot", () => {
expect(normalizeForgeOwnerForBranch(".hidden")).toBeNull();
});
it("rejects a login containing '..'", () => {
expect(normalizeForgeOwnerForBranch("a..b")).toBeNull();
});
it("rejects a login ending in .lock", () => {
expect(normalizeForgeOwnerForBranch("contributor.lock")).toBeNull();
});
it("rejects a login with a path traversal segment", () => {
expect(normalizeForgeOwnerForBranch("../evil")).toBeNull();
});
it("rejects an empty or null login", () => {
expect(normalizeForgeOwnerForBranch(null)).toBeNull();
expect(normalizeForgeOwnerForBranch("")).toBeNull();
expect(normalizeForgeOwnerForBranch(" ")).toBeNull();
});
});
describe("buildForkLocalBranchName", () => {
const fork = { headRef: "patch-1", number: 42, isCrossRepository: true };
it("keeps the head ref for a same-repository head", () => {
expect(
buildForkLocalBranchName({ ...fork, isCrossRepository: false, headOwnerLogin: "arthur" }),
).toBe("patch-1");
});
it("prefixes a fork head with the lowercased owner", () => {
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur" })).toBe("arthur/patch-1");
});
it("falls back to the pr number when the owner is unknown", () => {
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: null })).toBe("pr-42/patch-1");
});
it("falls back to the pr number when the owner is not a safe ref segment", () => {
expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "-zaphod" })).toBe("pr-42/patch-1");
});
// pull-ref-only heads (agit PRs, deleted branches) report headRef as the
// literal pull ref, e.g. refs/pull/42/head, not a real branch name
it("collapses a same-repository pull-ref-only head to pr-<number>", () => {
expect(
buildForkLocalBranchName({
...fork,
isCrossRepository: false,
headOwnerLogin: "arthur",
headRefKind: "pull-ref",
}),
).toBe("pr-42");
});
it("prefixes pr-<number> with the owner for a fork pull-ref-only head", () => {
expect(
buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur", headRefKind: "pull-ref" }),
).toBe("arthur/pr-42");
});
it("does not double the pr number when the fork owner is unknown and there is no head branch", () => {
expect(
buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }),
).toBe("pr-42");
});
});
@@ -0,0 +1,47 @@
import type { PullRequestCheckoutRef } from "../services/forge-service.js";
// upstream for clones where origin is your fork
export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] {
return [
{ remoteName: "origin", remoteRef: `refs/pull/${number}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${number}/head` },
];
}
// forgejo username pattern, covers github logins too
export function normalizeForgeOwnerForBranch(owner: string | null): string | null {
const normalized = owner?.trim().toLowerCase() ?? "";
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(normalized)) {
return null;
}
// git refuses .. and .lock, trailing dot too
if (normalized.includes("..") || normalized.endsWith(".") || normalized.endsWith(".lock")) {
return null;
}
return normalized;
}
export interface ForkLocalBranchNameInput {
headRef: string;
number: number;
isCrossRepository: boolean;
headOwnerLogin: string | null;
/** "pull-ref" when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
headRefKind?: "branch" | "pull-ref";
}
export function buildForkLocalBranchName(params: ForkLocalBranchNameInput): string {
const pullRefFallback = `pr-${params.number}`;
// headRef is the literal pull ref (e.g. refs/pull/42/head) when there is no
// real branch to name after, so use the pr number instead
const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef;
if (!params.isCrossRepository) {
return headSegment;
}
const owner = normalizeForgeOwnerForBranch(params.headOwnerLogin);
if (!owner) {
// don't double up pr-<n>/pr-<n> when the owner is also unknown
return headSegment === pullRefFallback ? headSegment : `${pullRefFallback}/${headSegment}`;
}
return `${owner}/${headSegment}`;
}
@@ -3,7 +3,8 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it } from "vitest";
import { getCommitFileDiff } from "./checkout-git.js";
import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
const tempDirs: string[] = [];
@@ -44,6 +45,75 @@ function headSha(repoDir: string): string {
}
describe("getCommitFileDiff", () => {
it.each(["new.txt", "with spaces.txt"])("preserves a rename-only commit to %s", async (name) => {
const repoDir = initRepo();
commitFile(repoDir, "old.txt", "same\n", "initial");
git(["mv", "old.txt", name], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
const sha = headSha(repoDir);
const history = await listCheckoutCommits({ cwd: repoDir });
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
{ path: name, status: "renamed", additions: 0, deletions: 0 },
]);
expect(await getCommitFileDiff({ cwd: repoDir, sha, path: name })).toEqual({
path: name,
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
});
});
it.each(["new.txt", "目录.txt"])(
"preserves the edits in a committed rename to %s",
async (name) => {
const repoDir = initRepo();
commitFile(repoDir, "old.txt", "same\nkeep\nlast\n", "initial");
git(["mv", "old.txt", name], repoDir);
writeFileSync(join(repoDir, name), "same\nKEEP\nlast\n");
git(["add", "."], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], repoDir);
const sha = headSha(repoDir);
const history = await listCheckoutCommits({ cwd: repoDir });
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
{ path: name, status: "renamed", additions: 1, deletions: 1 },
]);
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: name });
expect(file).toMatchObject({
path: name,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "keep" }),
expect.objectContaining({ type: "add", content: "KEEP" }),
]),
);
},
);
it.skipIf(process.platform === "win32")("preserves a rename to a quoted filename", async () => {
const repoDir = initRepo();
const name = 'new "draft".txt';
commitFile(repoDir, "old.txt", "same\n", "initial");
git(["mv", "old.txt", name], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
expect(await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: name })).toEqual({
path: name,
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
});
});
it("returns the parsed diff for a modified file in a commit", async () => {
const repoDir = initRepo();
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial");
@@ -78,6 +148,78 @@ describe("getCommitFileDiff", () => {
expect(file?.deletions).toBe(0);
});
it.each([
["exact", "line1\nline2\nline3\nline4\nline5\nline6\n"],
["edited", "line1\nline2\nline3\nline4\nline5\nCHANGED\n"],
])("shows an added %s copy as a new file", async (_kind, addedContent) => {
const repoDir = initRepo();
commitFile(repoDir, "source.txt", "line1\nline2\nline3\nline4\nline5\nline6\n", "initial");
commitFile(repoDir, "added.txt", addedContent, "add similar file");
const sha = headSha(repoDir);
const history = await listCheckoutCommits({ cwd: repoDir });
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
{ path: "added.txt", status: "added", additions: 6, deletions: 0 },
]);
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" });
expect(file).toMatchObject({
path: "added.txt",
isNew: true,
isDeleted: false,
additions: 6,
deletions: 0,
});
expect(
file?.hunks
.flatMap((hunk) => hunk.lines)
.filter((line) => line.type !== "header")
.map(({ type, content }) => ({ type, content })),
).toEqual(
addedContent
.trimEnd()
.split("\n")
.map((content) => ({ type: "add", content })),
);
});
it("shares commit rename discovery across concurrent and later file requests", async () => {
const repoDir = initRepo();
commitFile(repoDir, "old.txt", "same\n", "initial");
git(["mv", "old.txt", "new.txt"], repoDir);
commitFile(repoDir, "added.txt", "added\n", "rename and add");
const sha = headSha(repoDir);
startGitCommandMetrics();
try {
const [renamed, added] = await Promise.all([
getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" }),
getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }),
]);
expect(renamed).toMatchObject({ isNew: false, additions: 0, deletions: 0 });
expect(added).toMatchObject({ isNew: true, additions: 1, deletions: 0 });
expect(await getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" })).toEqual(renamed);
} finally {
const metrics = stopGitCommandMetrics();
expect(metrics.commands.filter(({ args }) => args.includes("--name-status"))).toHaveLength(1);
}
});
it("rediscovers renames when a symbolic commit ref moves", async () => {
const repoDir = initRepo();
commitFile(repoDir, "old.txt", "same\n", "initial");
expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "old.txt" })).toMatchObject({
isNew: true,
additions: 1,
});
git(["mv", "old.txt", "new.txt"], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "new.txt" })).toMatchObject({
isNew: false,
additions: 0,
deletions: 0,
});
});
it("returns null for a path not changed in the commit", async () => {
const repoDir = initRepo();
commitFile(repoDir, "foo.txt", "a\n", "initial");
@@ -89,6 +231,59 @@ describe("getCommitFileDiff", () => {
expect(file).toBeNull();
});
it("does not follow an unchanged file back to an earlier commit", async () => {
const repoDir = initRepo();
commitFile(repoDir, "foo.txt", "same\n", "initial");
commitFile(repoDir, "other.txt", "other\n", "unrelated change");
expect(
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }),
).toBeNull();
});
it("returns the added file in a root commit and the deleted file in its deletion commit", async () => {
const repoDir = initRepo();
commitFile(repoDir, "foo.txt", "same\n", "initial");
expect(
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }),
).toMatchObject({
isNew: true,
isDeleted: false,
additions: 1,
deletions: 0,
});
git(["rm", "foo.txt"], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", "delete"], repoDir);
expect(
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }),
).toMatchObject({
isNew: false,
isDeleted: true,
additions: 0,
deletions: 1,
});
});
it("preserves a rename introduced by a merge against its first parent", async () => {
const repoDir = initRepo();
commitFile(repoDir, "old.txt", "same\n", "initial");
git(["checkout", "-b", "feature"], repoDir);
git(["mv", "old.txt", "new.txt"], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir);
git(["checkout", "main"], repoDir);
commitFile(repoDir, "main.txt", "main\n", "advance main");
git(["merge", "--no-ff", "feature", "-m", "merge feature"], repoDir);
expect(
await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "new.txt" }),
).toMatchObject({
path: "new.txt",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
});
});
it("returns a merge commit diff against its first parent", async () => {
const repoDir = initRepo();
commitFile(repoDir, "README.md", "base\n", "initial");
+70 -4
View File
@@ -2643,6 +2643,60 @@ export async function listCheckoutCommits({
return { baseRef: comparisonBaseRef, commits };
}
const commitRenamesCache = new TTLCache<string, Promise<ReadonlyMap<string, string>>>({
max: 100,
ttl: 60_000,
checkAgeOnGet: true,
});
function getCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> {
// Symbolic refs and abbreviated IDs can resolve to a different commit later.
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
return readCommitRenames(cwd, sha);
}
const key = JSON.stringify([cwd, sha]);
const cached = commitRenamesCache.get(key);
if (cached) {
return cached;
}
const lookup = readCommitRenames(cwd, sha).catch((error: unknown) => {
if (commitRenamesCache.get(key) === lookup) {
commitRenamesCache.delete(key);
}
throw error;
});
// Store the pending lookup too, so concurrent file requests share the scan.
commitRenamesCache.set(key, lookup);
return lookup;
}
// Discover renames across the commit before narrowing the patch to a file.
// --follow also detects copies, which would disagree with history's -M-only list.
async function readCommitRenames(cwd: string, sha: string): Promise<ReadonlyMap<string, string>> {
const result = await runGitCommand(
[
"show",
sha,
"--format=",
"--diff-merges=first-parent",
"--name-status",
"-z",
"-M",
"--diff-filter=R",
],
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
if (result.truncated) {
throw new Error("Commit renames exceeded the git output limit");
}
const renames = new Map<string, string>();
const fields = result.stdout.split("\0");
for (let index = 0; index + 2 < fields.length; index += 3) {
renames.set(fields[index + 2]!, fields[index + 1]!);
}
return renames;
}
/**
* Fetches the unified diff of a single file as introduced by one commit and
* parses it into the same {@link ParsedDiffFile} shape the diff subscription
@@ -2665,8 +2719,20 @@ export async function getCommitFileDiff({
path: string;
}): Promise<ParsedDiffFile | null> {
const worktreeRoot = await requireGitWorktreeRoot(cwd);
const renames = await getCommitRenames(worktreeRoot, sha);
const oldPath = renames.get(path);
const paths = oldPath === undefined ? [path] : [oldPath, path];
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
[
"show",
sha,
"--format=",
"--diff-merges=first-parent",
"-M",
...GIT_PATCH_FORMAT_ARGS,
"--",
...paths.map((filePath) => `:(literal)${filePath}`),
],
{
cwd: worktreeRoot,
envOverlay: READ_ONLY_GIT_ENV,
@@ -2682,8 +2748,8 @@ export async function getCommitFileDiff({
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real
// entry. Pick by path to drop any stray header-only section the parser emits.
// Including the rename source lets Git pair it with the destination. Select
// the requested entry if another change also matches the source path.
const file = parsedFiles.find((candidate) => candidate.path === path) ?? null;
if (!file) {
return null;
@@ -3423,7 +3489,7 @@ export async function getCheckoutDiff(
compare: CheckoutDiffCompare,
context?: CheckoutContext,
): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd);
cwd = await requireGitWorktreeRoot(cwd);
const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context);
if (!refsForDiff) {
@@ -16,6 +16,7 @@ import {
mkdirSync,
rmSync,
existsSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
@@ -94,6 +95,64 @@ describe("paseo worktree manager", () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
it.each([
{ existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" },
{ existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" },
{
existing: ["main-1/patch-1"],
preferred: "main/patch-1",
expected: "main-1/patch-1-2",
},
{
existing: ["bob/patch-1/child"],
preferred: "bob/patch-1",
expected: "bob/patch-1-1",
},
])(
"checks out a fork when $preferred conflicts with existing branch paths",
async ({ existing, preferred, expected }) => {
const git = (...args: string[]) =>
execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim();
git("config", "core.autocrlf", "false");
const baseHead = git("rev-parse", "main");
for (const branch of existing) git("branch", branch);
git("checkout", "-b", "fixture-pr");
writeFileSync(join(repoDir, "file.txt"), "from-pr\n");
git("add", "file.txt");
git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch");
const prHead = git("rev-parse", "HEAD");
git("checkout", "main");
const remote = join(tempDir, "origin.git");
git("clone", "--bare", repoDir, remote);
git("remote", "add", "origin", remote);
git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead);
git("pack-refs", "--all");
const result = await createWorktreePrimitive({
cwd: repoDir,
paseoHome,
worktreeSlug: "fork-review",
runSetup: false,
source: {
kind: "checkout-change-request",
forge: "gitea",
changeRequestNumber: 1,
headRef: "patch-1",
localBranchName: preferred,
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }],
},
});
expect(result.branchName).toBe(expected);
expect(git("rev-parse", result.branchName)).toBe(prHead);
expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n");
for (const branch of ["main", ...existing]) {
expect(git("rev-parse", branch)).toBe(baseHead);
}
},
);
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
const created = await createLegacyWorktreeForTest({
branchName: "orphan-admin-branch",
+22 -5
View File
@@ -1778,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise<boole
}
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
let newBranchName = candidateBranch;
const { stdout } = await runGitCommand(
["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"],
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean));
const candidateSegments = candidateBranch.split("/");
const segments = [...candidateSegments];
let suffix = 1;
while (await localBranchExists(cwd, newBranchName)) {
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
while (true) {
const branchName = segments.join("/");
const parentConflict = segments.findIndex((_, index) =>
existingBranches.has(segments.slice(0, index + 1).join("/")),
);
const childConflict = [...existingBranches].some((branch) =>
branch.startsWith(`${branchName}/`),
);
if (parentConflict === -1 && !childConflict) {
return branchName;
}
// Git refs form a namespace: a branch cannot also be another branch's
// parent directory. Rename the conflicting segment, preserving the rest.
const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict;
segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`;
}
return newBranchName;
}
async function isBranchCheckedOut(cwd: string, branchName: string): Promise<boolean> {
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from "playwright/test";
const PAGES = ["/", "/docs/connectivity", "/changelog"];
for (const path of PAGES) {
test(`${path} shows the sponsors and invites the next one`, async ({ page }) => {
await page.goto(path);
const sponsors = page.getByRole("region", { name: "Sponsors", exact: true });
await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute(
"href",
"https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor",
);
await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible();
await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute(
"href",
"/sponsor#spot",
);
});
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -1,3 +1,4 @@
import { ExternalLink } from "lucide-react";
import type { Doc } from "~/docs";
const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main";
@@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) {
const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`;
return (
<footer className="docs-source-footer">
<a href={sourceUrl} target="_blank" rel="noreferrer">
View this page on GitHub
<footer className="mt-12 border-t border-white/10 py-8 text-sm">
<a
href={sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-white/60 underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
View on GitHub
<ExternalLink aria-hidden="true" className="size-3.5" />
</a>
</footer>
);
+128 -126
View File
@@ -132,13 +132,13 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<TurnkeySection />
<AutomationSection />
<ExtensibleSection />
<FAQ />
<PhilosophySection />
<SponsorsSection placement="page" />
<SponsorSection />
<SponsorsSection />
<PhilosophySection />
<FAQ />
</div>
</main>
<SiteFooter />
<SiteFooter sponsors={false} />
</div>
</CursorFieldProvider>
);
@@ -468,46 +468,35 @@ function TurnkeySection() {
title="Run it anywhere"
description="Use Paseo locally, from another machine, or with a team"
>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
<div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8">
<div className="flex items-start gap-4">
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
<Monitor className="h-6 w-6" strokeWidth={1.5} />
</div>
<div className="space-y-0.5">
<h3 className="text-xl font-medium text-white/90">Desktop app</h3>
<p className="max-w-lg text-sm leading-relaxed text-white/50">
The one click experience, download the app and it just works, no server required
</p>
</div>
</div>
</div>
<div className="p-6 md:p-8">
<div className="grid gap-4 md:grid-cols-3">
<TurnkeyExtensionCard
icon={Smartphone}
title="Mobile"
description="Full client and native, not a WebView"
ctaHref="/download"
ctaLabel="Download"
/>
<TurnkeyExtensionCard
icon={Laptop}
title="Remote machines"
description="Run Paseo on a home lab, or a cloud machine"
ctaHref="/docs#server--cli"
ctaLabel="Docs"
/>
<TurnkeyExtensionCard
icon={Users}
title="Teams and triggers"
description="Share access or start work from GitHub, Slack, and Discord"
ctaHref="/hub"
ctaLabel="Paseo Hub"
showIntegrationIcons
/>
</div>
<div className="space-y-8">
<ProductLead
icon={Monitor}
title="Desktop app"
description="The one click experience, download the app and it just works, no server required"
/>
<div className="grid gap-4 md:grid-cols-3">
<TurnkeyExtensionCard
icon={Smartphone}
title="Mobile"
description="Full client and native, not a WebView"
ctaHref="/download"
ctaLabel="Download"
/>
<TurnkeyExtensionCard
icon={Laptop}
title="Remote machines"
description="Run Paseo on a home lab, or a cloud machine"
ctaHref="/docs#server--cli"
ctaLabel="Docs"
/>
<TurnkeyExtensionCard
icon={Users}
title="Teams and triggers"
description="Share access or start work from GitHub, Slack, and Discord"
ctaHref="/hub"
ctaLabel="Paseo Hub"
showIntegrationIcons
/>
</div>
</div>
</FeatureSection>
@@ -530,20 +519,8 @@ function TurnkeyExtensionCard({
showIntegrationIcons?: boolean;
}) {
return (
<div className="flex min-h-48 flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
<div className="mb-5 flex items-center gap-3 text-muted-foreground">
<Icon className="h-5 w-5" strokeWidth={1.5} />
{showIntegrationIcons ? (
<>
<GitHubIcon className="h-4 w-4" />
<SlackIcon className="h-4 w-4" />
<DiscordIcon className="h-4 w-4" />
</>
) : null}
</div>
<h3 className="font-medium text-white/85">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p>
<div className="mt-auto pt-5">
<FeatureCard icon={Icon} title={title} description={description}>
<div className="flex items-center justify-between">
<a
href={ctaHref}
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-2.5 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
@@ -551,7 +528,70 @@ function TurnkeyExtensionCard({
{ctaLabel}
<ArrowRight className="h-3.5 w-3.5" />
</a>
{showIntegrationIcons ? INTEGRATION_ICONS : null}
</div>
</FeatureCard>
);
}
const INTEGRATION_ICONS = (
<span className="flex items-center gap-2 text-extra-muted-foreground">
<GitHubIcon className="h-4 w-4" />
<SlackIcon className="h-4 w-4" />
<DiscordIcon className="h-4 w-4" />
</span>
);
/** The lead product of a section: an icon tile and title on the page background. */
function ProductLead({
icon: Icon,
title,
description,
actions,
}: {
icon: LucideIcon;
title: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
<Icon className="h-6 w-6" strokeWidth={1.5} />
</div>
<div className="space-y-0.5">
<h3 className="text-xl font-medium text-white/90">{title}</h3>
{description ? (
<p className="text-sm leading-relaxed text-white/50">{description}</p>
) : null}
</div>
</div>
{actions ? <div className="flex shrink-0 items-center gap-3">{actions}</div> : null}
</div>
);
}
/** A small card: icon and title on one line, the blurb below, an optional action last. */
function FeatureCard({
icon: Icon,
title,
description,
children,
}: {
icon: LucideIcon;
title: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 shrink-0 text-extra-muted-foreground" strokeWidth={1.5} />
<h3 className="font-medium text-white/85">{title}</h3>
</div>
<p className="mt-3 text-sm leading-relaxed text-white/45">{description}</p>
{children ? <div className="mt-auto pt-5">{children}</div> : null}
</div>
);
}
@@ -797,41 +837,14 @@ function ExtensibleSection() {
title="Extend it with plugins"
description="Add new panels and functionality that work in desktop, web and mobile"
>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02]">
<div className="flex flex-col gap-6 border-b border-white/10 p-6 sm:flex-row sm:items-center sm:justify-between md:p-8">
<div className="flex items-center gap-4">
<div className="rounded-xl border border-white/10 bg-white/[0.06] p-3 text-muted-foreground">
<Puzzle className="h-6 w-6" strokeWidth={1.5} />
</div>
<h3 className="text-xl font-medium text-white/90">Plugins</h3>
</div>
<div className="flex shrink-0 items-center gap-3">
<a
href={PLUGINS_BROWSE.href}
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
>
Browse plugins
<ArrowRight className="h-3.5 w-3.5" />
</a>
<a
href="/docs/plugins"
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
>
Plugin docs
</a>
</div>
<div className="space-y-8">
<ProductLead icon={Puzzle} title="Plugins" actions={PLUGIN_ACTIONS} />
<div className="grid gap-4 md:grid-cols-3">
{PLUGIN_CAPABILITIES.map((capability) => (
<FeatureCard key={capability.title} {...capability} />
))}
</div>
<div className="p-6 md:p-8">
<div className="grid gap-4 md:grid-cols-3">
{PLUGIN_CAPABILITIES.map((capability) => (
<PluginCapabilityCard key={capability.title} {...capability} />
))}
</div>
</div>
<div className="flex flex-col gap-3 border-t border-white/10 px-6 py-5 sm:flex-row sm:items-center sm:justify-between md:px-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/45">Paseo is Apache 2.0.</p>
<a
href="https://github.com/getpaseo/paseo"
@@ -853,6 +866,25 @@ const PLUGINS_BROWSE = PLUGINS_LINKED
? { href: "/plugins", external: false }
: { href: "https://paseo.cafe", external: true };
const PLUGIN_ACTIONS = (
<>
<a
href={PLUGINS_BROWSE.href}
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
>
Browse plugins
<ArrowRight className="h-3.5 w-3.5" />
</a>
<a
href="/docs/plugins"
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
>
Plugin docs
</a>
</>
);
const PLUGIN_CAPABILITIES: ReadonlyArray<{
icon: LucideIcon;
title: string;
@@ -866,36 +898,15 @@ const PLUGIN_CAPABILITIES: ReadonlyArray<{
{
icon: Workflow,
title: "Agent lifecycle",
description:
"Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends",
description: "Change config and MCP servers, answer permissions, and follow up",
},
{
icon: Bot,
title: "Providers",
description: "Add a coding agent as a provider, used the same way as the built-in ones",
description: "Add a coding agent as a provider, used like the built-in ones",
},
];
function PluginCapabilityCard({
icon: Icon,
title,
description,
}: {
icon: LucideIcon;
title: string;
description: string;
}) {
return (
<div className="flex flex-col rounded-xl border border-white/10 bg-white/[0.025] p-5">
<div className="mb-5 text-muted-foreground">
<Icon className="h-5 w-5" strokeWidth={1.5} />
</div>
<h3 className="font-medium text-white/85">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/45">{description}</p>
</div>
);
}
const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [
{
icon: Lock,
@@ -920,7 +931,7 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description:
{
icon: Blocks,
title: "Composable",
description: "Run agents on your laptop or a server, and connect from any device.",
description: "Run agents on a laptop or a server, and connect from any device.",
},
{
icon: Puzzle,
@@ -937,16 +948,7 @@ function PhilosophySection() {
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PRINCIPLES.map((principle) => (
<div
key={principle.title}
className="rounded-xl border border-white/10 bg-white/[0.025] p-5"
>
<div className="flex items-center gap-3">
<principle.icon className="h-5 w-5 text-extra-muted-foreground" strokeWidth={1.5} />
<h3 className="font-medium text-white/85">{principle.title}</h3>
</div>
<p className="mt-3 text-sm leading-relaxed text-white/45">{principle.description}</p>
</div>
<FeatureCard key={principle.title} {...principle} />
))}
</div>
</FeatureSection>
@@ -1,9 +1,12 @@
import { PLUGINS_LINKED } from "~/plugins/launch";
import { getAlternativePages } from "~/data/alternative-pages";
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
import { SponsorsSection } from "~/components/sponsorship";
interface SiteFooterProps {
width?: "default" | "prose" | "wide";
/** Pages that show the sponsors in their content turn the footer band off. */
sponsors?: boolean;
}
const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
@@ -12,11 +15,12 @@ const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
wide: "max-w-7xl p-6 md:p-20 md:pt-0",
};
export function SiteFooter({ width = "default" }: SiteFooterProps) {
export function SiteFooter({ width = "default", sponsors = true }: SiteFooterProps) {
const widthClasses = WIDTH_CLASSES[width];
const alternatives = getAlternativePages();
return (
<footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}>
{sponsors ? <SponsorsSection placement="footer" /> : null}
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
<div className="space-y-3">
<p className="text-extra-muted-foreground">Product</p>
+78 -41
View File
@@ -1,14 +1,15 @@
import { ArrowRight, Check, ExternalLink } from "lucide-react";
import { ArrowRight, Check, ExternalLink, Heart } from "lucide-react";
import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons";
import {
BUY_ME_A_COFFEE_URL,
GITHUB_SPONSORS_URL,
HOMEPAGE_SPONSORS,
HOMEPAGE_SPOT_PRICE,
OPEN_COLLECTIVE_URL,
SPONSORS,
SPONSOR_CONTACT_EMAIL,
SPONSOR_SPOT_CHECKOUT_URL,
SPONSOR_SPOT_PRICE,
openSpotCount,
sponsorHref,
} from "~/data/sponsors";
function SectionHeading({
@@ -148,30 +149,11 @@ export function BackingOptions() {
);
}
function SponsorLogoRow() {
return (
<ul className="grid grid-cols-2 gap-4">
{HOMEPAGE_SPONSORS.map((sponsor) => (
<li key={sponsor.href}>
<a
href={sponsor.href}
target="_blank"
rel="noopener noreferrer sponsored"
className="flex h-32 items-center justify-center rounded-xl border border-white/10 bg-white/[0.02] px-6 transition-colors hover:border-white/20 hover:bg-white/[0.05]"
>
<img src={sponsor.logo} alt={sponsor.name} className="max-h-10 max-w-full opacity-80" />
</a>
</li>
))}
</ul>
);
}
const SPOT_FEATURES: ReadonlyArray<string> = [
"Your logo on the homepage of paseo.sh, around 70,000 visitors a month",
"Your logo on the paseo.sh homepage and docs, around 70,000 visitors a month",
"Your logo in the GitHub README, around 30,000 visitors a month",
"An announcement in the Discord, around 2,000 members",
"A mention in the changelog",
"An announcement from me on X",
];
export function SponsorSpotSection() {
@@ -181,11 +163,11 @@ export function SponsorSpotSection() {
<SectionHeading
as="h2"
title="Sponsor as a company"
description="If your team relies on Paseo, a monthly sponsorship funds its development directly. As a thank you, your logo goes on the homepage and in the README."
description="Support Paseo financially and get visibility for your company"
/>
<div className="space-y-8">
<div className="flex items-end gap-2">
<span className="text-4xl font-medium tracking-tight">{HOMEPAGE_SPOT_PRICE}</span>
<span className="text-4xl font-medium tracking-tight">{SPONSOR_SPOT_PRICE}</span>
<span className="mb-1 text-sm text-white/45">a month</span>
</div>
<ul className="grid max-w-2xl gap-3 text-white/70">
@@ -259,23 +241,78 @@ export function SponsorSection() {
);
}
/** Homepage: the companies sponsoring Paseo. Renders nothing until there is one. */
export function SponsorsSection() {
if (HOMEPAGE_SPONSORS.length === 0) return null;
const SPONSORS_PLACEMENTS = {
page: {
section: "space-y-10",
heading: "gap-2.5 text-3xl font-medium tracking-tight",
heart: "size-6",
link: "",
logo: "h-14",
description: "mt-2 text-sm text-white/55",
button: "h-12 px-5 text-sm",
},
footer: {
section: "space-y-5 border-t border-white/10 py-8",
heading: "gap-1.5 text-sm text-extra-muted-foreground",
heart: "size-3",
link: "opacity-85 transition-opacity hover:opacity-100",
logo: "h-10",
description: "mt-1.5 text-xs text-white/45",
button: "h-9 px-3.5 text-sm",
},
} as const;
interface SponsorsSectionProps {
/** `page` is a homepage section; `footer` is the band above other pages' footers. */
placement: keyof typeof SPONSORS_PLACEMENTS;
}
/**
* The companies sponsoring Paseo: one monochrome logo per sponsor and, while
* spots are open, a quiet invitation. Every page shows it once.
*/
export function SponsorsSection({ placement }: SponsorsSectionProps) {
const style = SPONSORS_PLACEMENTS[placement];
const open = openSpotCount();
return (
<section>
<SectionHeading as="h2" title="Sponsors" />
<div className="space-y-4">
<SponsorLogoRow />
{openSpotCount() > 0 ? (
<a
href="/sponsor#spot"
className="inline-block text-sm text-white/50 underline transition-colors hover:text-white/80"
>
Become a sponsor
</a>
<section aria-labelledby="sponsors-heading" className={style.section}>
<h2 id="sponsors-heading" className={`flex items-center ${style.heading}`}>
<Heart
aria-hidden="true"
className={`fill-rose-300/80 text-rose-300/80 ${style.heart}`}
strokeWidth={1.5}
/>
Sponsors
</h2>
<ul className="flex flex-wrap items-center gap-x-14 gap-y-8">
{SPONSORS.map((sponsor) => (
<li key={sponsor.url}>
<a
href={sponsorHref(sponsor, "website")}
target="_blank"
rel="noopener noreferrer"
className={`block ${style.link}`}
>
<img
src={sponsor.logo.onDark}
alt={sponsor.name}
className={`${style.logo} w-auto`}
/>
</a>
<p className={style.description}>{sponsor.description}</p>
</li>
))}
{open > 0 ? (
<li>
<a
href="/sponsor#spot"
className={`inline-flex items-center rounded-md border border-dashed border-white/15 text-white/45 transition-colors hover:border-white/30 hover:text-white/75 ${style.button}`}
>
Become a sponsor
</a>
</li>
) : null}
</div>
</ul>
</section>
);
}
@@ -0,0 +1,64 @@
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { SPONSORS, sponsorHref } from "./sponsors";
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
const WEBSITE_PUBLIC = fileURLToPath(new URL("../../public", import.meta.url));
const READMES = ["README.md", "README.zh-CN.md", "README.ja.md", "README.ko.md"];
const README_SPONSOR_CELLS = 3;
const SPONSOR_PAGE_URL = "https://paseo.sh/sponsor";
describe("sponsorHref", () => {
it("tags the link with the placement it is shown in", () => {
const sponsor = {
name: "Acme",
url: "https://acme.dev",
logo: { onDark: "", onLight: "" },
description: "",
};
expect(sponsorHref(sponsor, "website")).toBe(
"https://acme.dev/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor",
);
expect(sponsorHref(sponsor, "x")).toBe(
"https://acme.dev/?utm_source=paseo&utm_medium=x&utm_campaign=sponsor",
);
});
});
function readmeSponsorCells(readme: string) {
const text = readFileSync(`${REPO_ROOT}${readme}`, "utf8");
const table = text.slice(text.indexOf("<!-- Sponsors:")).split("</table>")[0];
return [...table.matchAll(/<td[^>]*>(.*?)<\/td>/g)].map(([, cell]) => {
const logo = cell.match(
/^<a href="([^"]+)"><picture><source media="\(prefers-color-scheme: dark\)" srcset="([^"]+)"><img src="([^"]+)"[^>]*><\/picture><\/a><br><sub>([^<]+)<\/sub>$/,
);
if (logo) {
const [, href, onDark, onLight, description] = logo;
return { href, onDark, onLight, description };
}
return { invite: cell.match(/^<a href="([^"]+)">/)?.[1] };
});
}
describe("SPONSORS", () => {
it.each(SPONSORS)("ships both logo inks for $name", (sponsor) => {
expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onDark}`)).toBe(true);
expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onLight}`)).toBe(true);
});
it.each(READMES)("fill the sponsors table in %s, followed by invitations", (readme) => {
const openCells = Math.max(0, README_SPONSOR_CELLS - SPONSORS.length);
expect(readmeSponsorCells(readme)).toEqual([
...SPONSORS.map((sponsor) => ({
href: sponsorHref(sponsor, "readme"),
onDark: `packages/website/public${sponsor.logo.onDark}`,
onLight: `packages/website/public${sponsor.logo.onLight}`,
description: sponsor.description,
})),
...Array.from({ length: openCells }, () => ({ invite: SPONSOR_PAGE_URL })),
]);
});
});
+42 -14
View File
@@ -1,31 +1,59 @@
/**
* Homepage sponsor spots. This file is the inventory: the site shows one open
* placeholder for every spot not listed in `HOMEPAGE_SPONSORS`.
* Company sponsors. This file is the inventory: every page of the site shows
* these logos above the footer, and the open spots invite the next sponsor.
*
* Approving a sponsor means three edits, or the site promises something it does
* not deliver:
*
* 1. Add the entry below and drop its logo in `public/sponsors/`.
* 2. Add the logo under the `## Sponsors` marker in `README.md`,
* `README.zh-CN.md`, `README.ja.md` and `README.ko.md`, in this same order.
* 1. Add the entry below and drop both logo inks in `public/sponsors/`.
* 2. In the sponsors table after the intro of `README.md`, `README.zh-CN.md`,
* `README.ja.md` and `README.ko.md`, replace a "become a sponsor" cell with
* the logo and its description, in this same order, linked to `sponsorHref(sponsor, "readme")`
* with both inks referenced from `packages/website/public`, so the README
* renders from the repository and never waits on a site deploy.
* `sponsors.test.ts` fails until they match.
* 3. Remove all of it when the subscription ends.
*
* The Stripe payment link caps itself at `HOMEPAGE_SPOT_COUNT` completed
* The Stripe payment link caps itself at `SPONSOR_SPOT_COUNT` completed
* checkouts, and a checkout you decline and refund still counts against that
* cap. When you refund one, raise the link's completed-session limit by one, or
* the page will offer a spot the link refuses to sell.
*/
export interface HomepageSponsor {
export interface Sponsor {
name: string;
href: string;
/** Path under `public/`, e.g. `/sponsors/acme.svg`. Keep logos monochrome white. */
logo: string;
/** The sponsor's own URL, without tracking. Links go through `sponsorHref`. */
url: string;
/** Paths under `public/`, one per ground. Use the sponsor's official lockup. */
logo: { onDark: string; onLight: string };
/** What the product is, in a few words from the sponsor's own site. Shown under the logo. */
description: string;
}
export const HOMEPAGE_SPONSORS: ReadonlyArray<HomepageSponsor> = [];
export const SPONSORS: ReadonlyArray<Sponsor> = [
{
name: "Hoplite",
url: "https://hoplite.sh",
logo: {
onDark: "/sponsors/hoplite-on-dark.svg",
onLight: "/sponsors/hoplite-on-light.svg",
},
description: "Cloud coding agents",
},
];
export const HOMEPAGE_SPOT_COUNT = 4;
export const HOMEPAGE_SPOT_PRICE = "$500";
/** Where a sponsor link is shown. Becomes the link's `utm_medium`. */
export type SponsorPlacement = "website" | "readme" | "discord" | "x";
export function sponsorHref(sponsor: Sponsor, placement: SponsorPlacement): string {
const url = new URL(sponsor.url);
url.searchParams.set("utm_source", "paseo");
url.searchParams.set("utm_medium", placement);
url.searchParams.set("utm_campaign", "sponsor");
return url.toString();
}
export const SPONSOR_SPOT_COUNT = 4;
export const SPONSOR_SPOT_PRICE = "$500";
/** Stripe Payment Link for the monthly spot subscription. */
export const SPONSOR_SPOT_CHECKOUT_URL = "https://buy.stripe.com/8x24gBczR7LNaokcve2sM00";
@@ -38,5 +66,5 @@ export const OPEN_COLLECTIVE_URL =
export const BUY_ME_A_COFFEE_URL = "https://buymeacoffee.com/paseo";
export function openSpotCount(): number {
return Math.max(0, HOMEPAGE_SPOT_COUNT - HOMEPAGE_SPONSORS.length);
return Math.max(0, SPONSOR_SPOT_COUNT - SPONSORS.length);
}
+2
View File
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState } from "react";
import { DocsBreadcrumbs } from "~/components/docs-breadcrumbs";
import { DocsNav } from "~/components/docs-nav";
import { DocsOutline } from "~/components/docs-outline";
import { SponsorsSection } from "~/components/sponsorship";
import { buildDocsNavTree, getDoc, getDocs } from "~/docs";
import "~/styles.css";
@@ -63,6 +64,7 @@ function DocsLayout() {
<div className="max-w-prose mx-auto">
{doc && <DocsBreadcrumbs doc={doc} tree={tree} />}
<Outlet />
<SponsorsSection placement="footer" />
</div>
</main>
-17
View File
@@ -561,23 +561,6 @@
margin-bottom: 2rem;
}
.docs-source-footer {
border-top: 1px solid var(--color-border);
margin-top: 3rem;
padding-top: 1.25rem;
font-size: 0.875rem;
}
.docs-source-footer a {
color: rgba(255, 255, 255, 0.5);
font-weight: 500;
text-decoration: none;
}
.docs-source-footer a:hover {
color: var(--color-foreground);
}
/* Blog post prose styles — adapted from moboudra.com */
.blog-prose {
color: rgba(250, 250, 250, 0.82);

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