Compare commits

..
Author SHA1 Message Date
Mohamed Boudra ad67c7030c docs(plugins): publish client entry guides
Restore the public client-entry reference, migration guide, links, and migration-table drift test when the feature is ready to launch.
2026-09-02 23:08:09 +02:00
Mohamed Boudra 2250148e3a docs(plugins): hold public entry docs until launch
Keep the shipped plugin runtime implementation documented internally while removing the public launch material until the feature is available to users. The companion add-back PR restores these pages and their drift check at launch.
2026-09-02 23:06:09 +02:00
Mohamed Boudra f75dff2322 Require explicit setup for fork pull requests (#4215)
* feat(workspaces): gate fork repository automation

Cross-repository change requests can execute contributor-controlled package hooks even when the setup command comes from the base repository. Persist the checkout provenance and require one explicit workspace-level setup action before repository automation runs.

* fix(workspaces): clarify blocked script errors

* fix(app): keep long toasts readable

* test(server): expect fork repository provenance

* test(server): make setup marker portable

* test(app): retry transient sidebar layout
2026-09-02 20:50:22 +02:00
Mohamed Boudra 2d8ff6f1cf Restore copy actions in web diffs (#4229)
* fix(app): restore diff copy actions on web

The canvas-backed diff owns selection without creating a DOM range, so browser context menus cannot expose copy actions themselves. Route keyboard and context-menu actions through the renderer's canonical source selection instead.

* fix(app): harden web diff copy actions

* fix(app): default split header selection to new pane

* fix(app): isolate nested diff context menus

* fix(app): select available split diff pane
2026-09-02 20:25:55 +02:00
Mohamed Boudra 53d824a320 feat(plugins): split client and server plugin entries (#4206)
* feat(plugins): phase 1 split runtime entries after green gate

The compiler now builds explicit client and server entries, so runtime boundaries are source-owned instead of registration-name filtering. Compiler and runtime tests prove the entry split, directory and suffix boundaries, client Node import errors, migration failure, and client-only operation without a subprocess.

* feat(plugins): phase 2 run client entries after green gate

Explicit client and server contexts replace the mixed plugin context. The app runner owns every client registration and its idempotent removal, including late contributions and composer pills; focused contribution tests prove the removal contract, and all five migrated examples reached running on the isolated worktree daemon.

* feat(cli): phase 3 scaffold runtime split after green gate

The generated project demonstrates the required shared RPC contract, server handler, client surface, and sidebar wiring. The scaffold test and an isolated init, typecheck, install, and running check prove the phase acceptance path.

* docs(plugins): phase 4 publish migration after green gate

Gate: current docs and the paseo-plugin skill describe only explicit client/server entries. The standalone migration guide maps every former registration, and migration-doc.test.ts proves every client add* method remains represented.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Gate: compiler 13, runtime 24, typecheck, lint, and format passed. The dependency fixture proved node_modules/client was incorrectly treated as a plugin boundary.

* fixup! feat(plugins): phase 2 run client entries after green gate

Gate: client runtime and registry tests, typecheck, lint, format

* fixup! docs(plugins): phase 4 publish migration after green gate

Gate: migration docs test, paragraph audit, typecheck, lint, format

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Gate: compiler organization tests, typecheck, lint, targeted format

* fixup! feat(plugins): phase 2 run client entries after green gate

Gate: shared SDK tests, runtime tests, typecheck, lint, targeted format

* fixup! feat(cli): phase 3 scaffold runtime split after green gate

* fixup! docs(plugins): phase 4 publish migration after green gate

* fixup! feat(plugins): phase 1 split runtime entries after green gate

* docs(plugins): rewrite public plugin docs for runtime entries and mobile guardrails

* fixup! feat(cli): phase 3 scaffold runtime split after green gate

The scaffold now keeps DOM globals out of the program and declares only window.open inside client/web.ts. The scaffold test invokes tsc in a fresh process, proves the generated project passes, and proves a stray document access fails.

* fixup! docs(plugins): phase 4 publish migration after green gate

The internal guide and plugin skill now forbid both the DOM lib and triple-slash DOM references. They direct web adapters to declare only the globals used by client/web.ts.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Removing the obsolete Babel parser changes the locked npm dependency graph. The macOS Nix desktop gate reported the new fixed-output hash, which this commit records.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Reject relative imports that escape the plugin root while continuing to skip resolved node_modules internals. The compiler regression test proves the escaped import is rejected and the dependency-internal fixture remains accepted.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Classify absolute imports and reject plugin-authored paths that escape into node_modules. Dependency internals remain exempt based on their importer path. Red-first compiler regressions cover both bypasses, and the existing dependency fixture remains green.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Classify canonical esbuild resolution results so dependency-relative and symlink imports cannot escape client/server boundaries. The two compiler regressions failed before the fix and pass afterward.

* test(ci): synchronize flaky state transitions

Wait for repository watcher registration before emitting buffered ref events, let sidebar order polling retry unlaid-out rows, and wait for the inactive browser parking state before screenshot capture. These changes directly address the three observed CI failures; the affected server, Playwright, and desktop browser tests pass locally.

* test(ci): synchronize Mermaid completion layout assertion

The Playwright streaming acceptance test exposed a completion remount between visibility and layout sampling. Check the existing completion promise around the measurement so that transition is not reported as diagram loss.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Resolve bare package imports before boundary classification so symlinked dependency entries cannot expose server modules to the client bundle. The focused regression proves the bypass and the compiler file passes 20/20.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Allow canonical paths only within a matching linked package root while preserving runtime and containment checks for imports that leave it. The linked-dependency regression fails before the fix and compiler tests pass 21/21 afterward.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Reject matching package manifests that contain the plugin or live inside it, so linked-dependency roots cannot exempt plugin or workspace files. The ancestor-manifest regression fails before the fix and compiler tests pass 22/22 afterward.

* fixup! feat(plugins): phase 1 split runtime entries after green gate

Validate plugin-local lexical boundaries before canonical linked-root exemptions and require remembered-root imports to originate inside that root. The linked-root bypass regression fails before the fix and compiler tests pass 23/23 afterward.
2026-09-02 19:11:45 +02:00
fa1f01ebbc feat(app): add fullscreen viewer for Mermaid diagrams on web (#4107)
* feat(app): add fullscreen viewer for Mermaid diagrams on web

Add a fullscreen action to the diagram toolbar that opens the diagram in
a modal ZoomableViewport with free zoom/pan, closable via the close
button, Escape, or clicking outside the diagram. The sandboxed iframe
runtime moves into a shared MermaidIframeRuntime so the inline host and
the fullscreen viewer drive the same renderer.

Fixes uncovered while verifying the feature end to end:

- The runtime measured the rendered SVG with getBoundingClientRect(),
  which depends on the iframe size the host derives from that very
  measurement. Each streaming re-render shrank the reported size by the
  diagram box padding, ratcheting diagrams down to a few pixels. Measure
  the viewBox instead, which is the diagram's own size, and let the SVG
  fill whatever frame the host provides.
- ZoomableViewport's root is flex: 1, so as a flex item its 0% basis
  overrode the inline diagram box height and the box collapsed, scaling
  the diagram tiny and clipping the toolbar unclickable. Size the box
  from its height and keep a minimum tall enough for the toolbar.
- react-native-web's Modal focus trap focuses the first descendant that
  takes focus, which was the diagram iframe; every keystroke then landed
  in the sandboxed frame and Escape never reached the window listener.
  Mark the presentational iframe inert and focus the viewer's content
  layer on mount.

Closes #4106

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

* test(app): exercise Mermaid fullscreen interactions

Cover zoom, pan, outside-click dismissal, and streaming layout through the real browser path. Remove the mocked DOM test that duplicated those behaviors without exercising the viewport or iframe runtime.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-02 16:10:26 +02:00
Mohamed Boudra d4841b68ff fix(website): detect the visitor platform during SSR (#4222)
The homepage download CTA was chosen by a client-only hook that started at
"mac-silicon" and corrected itself in an effect, so the server-rendered HTML
said "Download for Mac" for every visitor and only became right after
hydration. On a phone that window is long enough to photograph. Worse, the
detection had no mobile branch at all: Android user agents also say "Linux",
so once hydration did land, a Pixel was offered an AppImage.

Resolve the platform from the request user agent during SSR instead, so the
first paint is already correct. Mobile visitors get the App Store or Play
Store as the primary action and a link to /download for the desktop build,
rather than a .dmg they cannot open.

The markup now varies by user agent, so HTML and server-function responses
carry `Cache-Control: private, no-store` and `Vary: User-Agent`. An absent
cache-control is not no-store — a shared cache may assign heuristic freshness,
and turning on Workers Cache would store a headerless 200 for two hours under
a key that ignores the user agent, letting one Android visitor pin the Play
Store button for every Mac visitor after them.

Also tighten the mobile layout the wrong CTA was sitting in: the primary
action gets its own row so the icon buttons stop orphaning one of themselves,
provider cards drop to a two-column grid, and the hardcoded mobile line break
in the phone showcase gives way to a balanced measure.
2026-09-02 15:46:16 +02:00
Mohamed Boudra ac085d50c6 Make import session one tap away and make the sheet find your session (#4216)
* feat(app): make import session a first-class entry point

* feat(server): improve import session discovery resilience

Search up to 500 persisted candidates per provider and isolate listing failures behind eight-second deadlines so healthy results remain usable. Preserve native persisted title precedence while keeping discovery read-only.

Parsing now continues through each transcript so native titles and the true last prompt are retained; the old early exit made lastPromptPreview equal firstPromptPreview. A cheap record prefilter avoids JSON.parse for unrelated lines during the full scan.

* feat(app): searchable, grouped import session sheet with per-provider errors

The sheet now carries a debounced search field gated on the host's
importSessionSearch feature, grows its per-provider page on demand
(15 -> 45 -> 90 -> 200) instead of capping at the 15 most recent, and groups
unscoped rows by folder under the project name the app knows for that
directory. Worktrees of one project would otherwise all head up as the same
bare name, so a directory below its project root carries the path under that
root beside the name.

A workspace-scoped sheet says so and offers "Show all sessions". Importing a
row from another directory once that is on drops the workspace from the
request, which the daemon would otherwise reject, and routes the user to the
agent's own workspace rather than opening a dead tab in the current one. That
decision lives in resolveImportTarget: a scoped listing is trusted wholesale
because the daemon already matched it realpath-aware, which the client cannot.

A provider that fails now gets its own error row with a Retry that refetches
only that provider, sourced from both a failed request and the daemon's
providerErrors. React Query no longer retries these, so the header spinner
settles once every provider has answered or given up (#2512).

* test(e2e): import session flow with fixture Claude sessions

* fix(app): shorten import sheet scope copy so it fits at phone width

At 390px the scoped subtitle rendered "Sessions in this wor..." — the label,
its action, and the sheet's refresh and close buttons do not share one line at
phone width. The label already sits under the sheet title "Import session", so
"Sessions in this" was repeating context the header had: it becomes "This
workspace" and the action becomes "Show all". The unscoped label still names
the host, which nothing else on the sheet does.

* refactor(app): flat recency-ordered import list with per-row folder label

Grouping by folder buried recency: a busy directory's block of fifteen pushed
a two-minute-old session in a quiet directory below the fold, and the axis a
user is scanning on is when they last touched the session, not where it lives.
The unscoped sheet is now one list in the order aggregateSessionEntries already
produces, and each row carries its folder as a muted third line.

resolveDirectoryLabel stays — it is what keeps two worktrees of one project
apart — and formatDirectoryLabel renders its two parts as the single ellipsised
line a row has room for. A scoped sheet lists one directory, so its rows carry
no folder line at all.

* fix(app): close import sheet before refreshing sessions

Provider-list invalidation waited for every active query before the sheet could close. Mark the disabled picker queries stale after close and navigation so reopening refreshes without delaying the imported agent.

* fix(server): reuse workspaces for session imports

Host-wide imports should join the oldest active workspace for their directory, or restore its archived workspace, instead of minting duplicates. Rollback remains limited to workspace records created by the failed import.

* test(e2e): express the import session journey through a helper DSL

* test(e2e): align sidebar journeys with import entry point
2026-09-02 15:44:44 +02:00
1c960261ac feat(providers): add Gajae Code to the ACP provider catalog (#3471)
* feat(providers): add Gajae Code to the ACP provider catalog

Gajae Code (gjc) ships a conformance-tested ACP agent (`gjc acp`), so it can be
a one-click catalog entry instead of a hand-written custom provider in
config.json. Command and env match what the agent's own installer writes.

* feat(providers): add Gajae Code icon

---------

Co-authored-by: Yeachan-Heo <yeachan.heo@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-02 15:32:23 +02:00
Mohamed Boudra 7838b534a8 Show self-hosted and hosted Hub plans (#4213)
* feat(website): publish Hub pricing options

Present self-hosted and hosted Hub choices together while reading the hosted offer from Hub's public catalog.

* fix(website): require a valid hosted offer

* fix(website): preserve Hub page on catalog failure
2026-09-02 15:05:28 +02:00
Mohamed Boudra ff0171cf7b Make agent tab tooltips compact and informative (#4214)
Join and truncate prompt-derived titles for the visual tooltip, add agent ID and live human activity metadata on a second row, and keep activity subscriptions local to the open tooltip. Preserve the full normalized title as the accessible tab name and cover the behavior through Playwright.
2026-09-02 14:27:18 +02:00
Mohamed Boudra 77aff0f4fb fix(app): stabilize Mermaid streaming lifecycle (#4210)
Drive the streaming assertion from the actual turn lifecycle so iframe replacement at completion is not mistaken for diagram loss. Preserve the cached timeline range during reload reconciliation so a projected message spanning the cache cursor replaces its partial prefix instead of being appended to it.
2026-09-02 13:37:32 +02:00
Mohamed Boudra 708f9260b1 fix(server): disable repository fsmonitor commands (#4208)
A repository-controlled core.fsmonitor command can execute when the daemon refreshes Git state. Opening or reconciling a malicious checkout therefore allows code execution as the daemon user. Force core.fsmonitor=false on daemon-owned Git invocations before repository config is considered.
2026-09-02 13:15:51 +02:00
Mohamed Boudra e34aea206e feat(plugins): add live timeline rows and client slash commands
Plugins can transform live timeline source items at render time, append durable plugin rows from daemon handlers, and register client-only slash commands in the composer.

Related to #3940 and #3952.
2026-09-02 13:12:09 +02:00
Mohamed Boudra 49784fac15 Keep Changes and working diff comparisons in sync (#4199)
* fix(app): keep Changes comparison and diff in sync

Own the working comparison at the workspace checkout so the Changes tree and adjacent working diff share it. Immutable commit diffs remain scoped to their commit SHA.

* docs(app): align working diff ownership notes
2026-09-02 12:38:16 +02:00
Mohamed Boudra 4aab00f267 fix(desktop): use CLI shim for packaged Linux installs (#4207)
Non-AppImage packages install the Electron executable at a stable path, but invoking it through the CLI symlink cannot restart the daemon. Link the bundled CLI shim for those targets while preserving AppImage links.\n\nFixes #4184
2026-09-02 12:30:28 +02:00
Mohamed Boudra de8535c38b Customize top-level sidebar items (#4203)
* feat(app): customize top-level sidebar items

Let users reorder and hide built-in and plugin sidebar rows from Appearance settings while keeping the preference device-local. Add Search as a first-class row and standardize section explanations as header info tooltips.

* fix(app): preserve consecutive sidebar preference edits

Derive each sidebar change from the latest cached settings and retain unavailable plugin slots while editing the visible list. Keep E2E interaction selectors tied to accessible control names.

* test(app): isolate provider settings unit boundary

Keep the legacy provider-section test focused on provider behavior now that its shared section shell can render the settings info control.
2026-09-02 11:42:39 +02:00
Mohamed Boudra d67a6f5f6c fix(release): keep releases draft until manifests are ready (#4201)
Create tag-triggered GitHub releases as drafts and publish them only after the desktop updater manifests are complete, stamped, and uploaded. Preserve beta prerelease semantics and safe retry behavior.\n\nCloses #4183
2026-09-02 11:29:41 +02:00
paseo-ai[bot] 78f7796eb4 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-02 00:19:07 +00:00
282 changed files with 12217 additions and 3311 deletions
+45 -27
View File
@@ -20,7 +20,50 @@ env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
ensure-release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
--draft
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" >/dev/null
fi
publish-android-apk:
needs: [ensure-release]
permissions:
contents: write
packages: read
@@ -36,32 +79,6 @@ jobs:
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Ensure GitHub release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
exit 0
fi
release_args=(
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
release_args+=(--prerelease)
fi
if ! gh "${release_args[@]}"; then
echo "Release creation raced with another workflow; continuing."
fi
- name: Setup Node
uses: actions/setup-node@v4
with:
@@ -129,4 +146,5 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
+64 -7
View File
@@ -75,7 +75,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
if node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
@@ -86,8 +86,10 @@ jobs:
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--notes "" \
--draft \
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" > /dev/null
}
fi
@@ -191,7 +193,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -299,7 +302,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -404,7 +408,8 @@ jobs:
echo "::error::No release artifacts found in $release_dir"
exit 1
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
- name: Upload desktop artifacts to workflow
if: env.SHOULD_PUBLISH != 'true'
@@ -424,7 +429,7 @@ jobs:
finalize-rollout:
needs: [publish-macos, publish-linux, publish-windows]
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
if: ${{ always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
permissions:
contents: write
runs-on: ubuntu-latest
@@ -480,7 +485,7 @@ jobs:
name: windows-manifest
path: release-manifests/windows-manifest
- name: Assemble and upload stamped manifests
- name: Assemble manifests and publish release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -491,6 +496,33 @@ jobs:
manifests_dir="$PWD/final"
mkdir -p "$manifests_dir"
expected_manifests=(
"${RELEASE_CHANNEL}-mac.yml"
"${RELEASE_CHANNEL}-linux.yml"
"${RELEASE_CHANNEL}.yml"
)
release_lookup="$(node ../scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG" --cleanup-duplicates)"
existing_assets="$(gh release view "$release_lookup" --repo "${{ github.repository }}" --json assets --jq '.assets[].name')"
for manifest_name in "${expected_manifests[@]}"; do
if grep -Fxq "$manifest_name" <<< "$existing_assets"; then
case "$manifest_name" in
"${RELEASE_CHANNEL}-mac.yml") platform_result="${{ needs.publish-macos.result }}" ;;
"${RELEASE_CHANNEL}-linux.yml") platform_result="${{ needs.publish-linux.result }}" ;;
"${RELEASE_CHANNEL}.yml") platform_result="${{ needs.publish-windows.result }}" ;;
esac
if [[ "$platform_result" =~ ^(success|skipped)$ ]]; then
gh release download "$release_lookup" \
--repo "${{ github.repository }}" \
--pattern "$manifest_name" \
--dir "$manifests_dir"
else
gh release delete-asset "$release_lookup" "$manifest_name" \
--repo "${{ github.repository }}" \
--yes
fi
fi
done
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
manifest_name="${RELEASE_CHANNEL}-mac.yml"
node ../scripts/merge-mac-manifest.mjs \
@@ -515,6 +547,7 @@ jobs:
fi
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
# shellcheck disable=SC2016
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
const yaml = require("js-yaml");
const fs = require("fs");
@@ -536,4 +569,28 @@ jobs:
}
}
' "${files[@]}"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
failed_builds=()
[[ "${{ needs.publish-macos.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(macOS)
[[ "${{ needs.publish-linux.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Linux)
[[ "${{ needs.publish-windows.result }}" =~ ^(success|skipped)$ ]] || failed_builds+=(Windows)
if (( ${#failed_builds[@]} > 0 )); then
echo "::error::Desktop builds did not complete for ${failed_builds[*]}; the release remains a draft"
exit 1
fi
missing_manifests=()
for manifest_name in "${expected_manifests[@]}"; do
[[ -f "$manifests_dir/$manifest_name" ]] || missing_manifests+=("$manifest_name")
done
if (( ${#missing_manifests[@]} > 0 )); then
echo "::error::Missing updater manifests: ${missing_manifests[*]}; the release remains a draft"
exit 1
fi
publish_args=(release edit "$release_lookup" --tag "$RELEASE_TAG" --draft=false --repo "${{ github.repository }}")
if [[ "$IS_PRERELEASE" == "true" ]]; then
publish_args+=(--prerelease)
fi
gh "${publish_args[@]}"
+1 -2
View File
@@ -15,7 +15,7 @@ on:
required: false
type: string
create_if_missing:
description: "Create release if missing (normally only needed for tag events)."
description: "Create a draft release if missing (normally only needed for tag events)."
required: false
default: false
type: boolean
@@ -29,7 +29,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
+2
View File
@@ -50,6 +50,8 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
+3 -3
View File
@@ -355,13 +355,13 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
| State | Key builder / store | Source |
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------- |
| Review draft comments | `buildReviewDraftKey` / `buildReviewDraftScopeKey` | `packages/app/src/review/store.ts` |
| Diff mode override | review-draft scope key (in-memory) | `packages/app/src/review/state.ts` |
| Review draft comments | `buildReviewDraftKey` | `packages/app/src/review/store.ts` |
| Working diff comparison | `workingDiffComparisonKey` (in-memory) | `packages/app/src/git/working-diff-comparison/state.ts` |
| Composer attachments | `buildWorkspaceAttachmentScopeKey` | `packages/app/src/attachments/workspace-attachments-store.ts` |
| File explorer nav/open state | `fileExplorer` map keyed `workspace:{workspaceId}` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| File explorer expanded paths | `expandedPathsByWorkspace[workspaceStateKey]` | `packages/app/src/stores/panel-store/state.ts` |
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/override/attachment scope keys.
`diff-pane.tsx` is the canonical wiring site: it passes `{ serverId, cwd }` to the git queries and `{ serverId, workspaceId, cwd }` to the draft/comparison/attachment scope keys.
**Do not "fix" the sharing away.** Re-keying a directory-backed query by `workspaceId` makes same-`cwd` workspaces diverge (two windows onto the same git tree showing different diffs). Re-keying owned state (drafts, expanded paths) by `cwd` makes them leak between distinct workspaces on the same folder. The `workspaceId`-keyed builders carry a `// workspaceId is opaque; do not parse this key back into a path.` comment — the opaque-id fallback to `cwd` exists only for old payloads without a `workspaceId`, not as a content-sharing mechanism.
+20 -19
View File
@@ -439,25 +439,26 @@ workspace together with its owning project.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| Field | Type | Description |
| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
+3
View File
@@ -120,6 +120,8 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
@@ -248,6 +250,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
+1
View File
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **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 top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows 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).
- **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).
+141 -57
View File
@@ -1,8 +1,8 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime.
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
@@ -41,15 +41,19 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
directories, and local typechecking support. At least one entry is required.
```text
my-plugin/
paseo-plugin.json
index.ts
main.client.tsx
package.json
tsconfig.json
index.client.tsx
index.server.ts
client/greeting.tsx
server/greeting.ts
shared/greeting.ts
```
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
@@ -135,16 +139,19 @@ log credentials or tokens.
## Contribute behavior and UI
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
code lives behind filename boundaries:
Default export one contribution function from each runtime entry. Keep the entries to registration
wiring. Runtime code lives behind directory boundaries:
| Suffix | Owns |
| -------------- | -------------------------------------------------------------------- |
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
| Path | Owns |
| -------------------------------- | -------------------------------------------------------------------- |
| `index.client.tsx` and `client/` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
| `index.server.ts` and `server/` | Node APIs, filesystem and process access, credentials, and handlers. |
| `shared/` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
@@ -153,28 +160,45 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text`
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ------------------------------- | -------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
| Module | Use it for |
| ------------------------------- | ----------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
server bundle.
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
Native calls such as `StyleSheet.create` belong in `client/`.
The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference lib="dom" />`, so
browser globals are not available across the plugin. Put sanctioned web-only APIs in
`client/web.ts`, declare only the globals that module uses, gate each export with
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
pattern.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { Greeting } from "./greeting.client";
import { createGreeting } from "./greeting.server";
import { greetRpc } from "./greeting.shared";
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { createGreeting } from "./server/greeting";
import { greetRpc } from "./shared/greeting";
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, createGreeting);
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
export default function contribute(server: PluginServerContext) {
server.handle(greetRpc, createGreeting);
return () => {};
}
```
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { Greeting } from "./client/greeting";
export default function contribute(client: PluginClientContext) {
client.addSurface("main", Greeting);
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
return () => {};
}
```
@@ -209,13 +233,13 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
Workspace panels and Command Center items remain client contributions. The daemon transports their
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
app state or a memoized selection. Panels use one persisted
Workspace panels, Command Center items, and client slash commands are client contributions. The
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
contain workspace and agent IDs. Required-selector hooks read normalized client state synchronously
and use shallow equality, so a panel does not subscribe to fields it does not render. Command
callbacks materialize their snapshots only when invoked. Contribution discovery and panel opening
never fetch active context through plugin RPC. Snapshot DTOs are deeply readonly and frozen at
runtime so plugin code cannot mutate normalized app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
@@ -231,10 +255,10 @@ Plugins do not receive Expo Router or workspace-layout store access.
## Contribute composer pills
Register a headless client entrypoint, then add and remove targeted pills from that client
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
client state without mounting a panel or surface.
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
surface.
```tsx
export function contributeClient(client: PluginClientContext) {
@@ -265,8 +289,8 @@ export function contributeClient(client: PluginClientContext) {
}
```
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
`addComposerPill` returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
@@ -275,20 +299,67 @@ the host, or unloading the app tears down the contribution.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
built-in projection stay unchanged. The app transforms fetched projected history before building
its render model. A matching live event requests a fresh projected tail, so lifecycle deltas are
collapsed before the transformer replaces anything.
built-in projection stay unchanged. The app transforms each source item while building the render
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
streaming updates preserve mounted component identity.
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
recognition and returns plain plugin item objects. `undefined` keeps the source item, `items`
replaces it, and an empty array removes it. Output `data` must be JSON-compatible. Paseo adds the
runtime plugin ID, preserves the source timeline cursor, validates renderer data with its Zod
schema, and mounts the component inside the normal plugin runtime and error boundary.
runtime plugin ID, preserves the source timeline cursor and identity, validates renderer data with
its Zod schema, and mounts the component inside the normal plugin runtime and error boundary. An
optional output `id` distinguishes several stable replacements from the same source item; its output
index is the default.
Transformers run synchronously and must be deterministic. When several transformers match, the
first one that returns a result owns that source item. Plugin and registration ordering is stable.
See `plugin-examples/timeline-items` for the complete contract.
A plugin subprocess can also append a canonical plugin row from a server handler:
```ts
await paseo.agents.ref(agentId).timeline.append({
type: "plugin",
id: "review",
kind: "review-result",
version: 1,
data: { status: "ready" },
});
```
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
Command Center callback.
```ts
client.addSlashCommand({
name: "review",
description: "Run the review bot",
argumentHint: "[scope]",
context: "agent",
async onSubmit({ args, agent, rpc }) {
await rpc(startReview, { agentId: agent.id, scope: args });
},
});
```
Paseo owns the autocomplete row, input clearing, and error toast. It never sends the command text to
the agent. Built-in client commands win name and alias collisions, plugin commands win
provider-command collisions, and the first plugin in stable catalog order wins collisions between
plugins. Plugin slash commands do not run when the composer has attachments.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
@@ -296,13 +367,24 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
credentials and vendor API calls stay in the daemon handler.
```ts
import type { PluginContext } from "@getpaseo/plugin";
import { search } from "./issues.server";
import { issues, searchIssues } from "./issues.shared";
// index.server.ts
import type { PluginServerContext } from "@getpaseo/plugin";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, search);
plugin.addAttachmentSource(issues);
export default function contribute(server: PluginServerContext) {
server.handle(searchIssues, search);
return () => {};
}
```
```tsx
// index.client.tsx
import type { PluginClientContext } from "@getpaseo/plugin";
import { issues } from "./shared/issues";
export default function contribute(client: PluginClientContext) {
client.addAttachmentSource(issues);
return () => {};
}
```
@@ -321,8 +403,8 @@ contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
for the runtime-patching rules the appearance settings share.
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
that predates it does not, and the plugin fails to start there. Daemons advertise
`addTheme` is a client registration and belongs in `index.client.tsx`. A client that predates it
cannot evaluate that entry. Daemons advertise
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
a host without it contributes no themes.
@@ -335,6 +417,8 @@ stable rather than arrival-ordered. The app resolves that id
against the installed catalog on every change; an id nothing contributes falls back to the default
preference instead of painting the reserved slot's placeholder colors.
Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md).
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
`plugin-examples/catppuccin` for a theme.
+30 -7
View File
@@ -112,7 +112,7 @@ npm run release:patch
npm run release:minor
```
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. The workflows create the GitHub Release as a draft while builds and release-note sync run. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
@@ -180,10 +180,10 @@ The rollout is driven by a `rolloutHours` field stamped into the GitHub Release
Desktop release builds now publish in two phases:
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
- The GitHub Release stays a draft while platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.).
- The final job merges and stamps every channel manifest, uploads them with the final `releaseDate` and `rolloutHours`, then publishes the GitHub Release.
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
Drafts do not appear in GitHub's releases feed. Updater clients continue to see the previous complete release until all three manifests are available. If a desktop build or manifest upload fails, the new release stays a draft.
### Default behavior
@@ -375,7 +375,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
## Fixing a failed release build
@@ -404,6 +404,13 @@ you want to build. Reusing the same tag name is expected: move it with
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
you actually want.
A failed desktop build leaves the GitHub Release as a draft. `finalize-rollout`
uploads manifests from successful platforms before it fails. A later
single-platform retry reuses those manifests, stamps the complete set with one
release date, and publishes the draft. Use `desktop-vX.Y.Z` when more than one
platform failed. A `workflow_dispatch` rebuild with publishing enabled follows
the same path against the existing draft.
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
@@ -433,6 +440,22 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
- `android-vX.Y.Z` rebuilds the Android APK release only
If you decide to publish a release without working desktop builds, inspect its
assets first, then publish it manually:
```bash
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z)
gh release view "$RELEASE_LOOKUP" --json isDraft,isPrerelease,assets
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z --draft=false
# Keep a beta marked as a prerelease:
RELEASE_LOOKUP=$(node scripts/github-release.mjs --repo getpaseo/paseo --tag vX.Y.Z-beta.N)
gh release edit "$RELEASE_LOOKUP" --tag vX.Y.Z-beta.N --draft=false --prerelease
```
This bypasses the updater-manifest guarantee. Use it only when the release is
intentionally unavailable to desktop updater clients.
## Notes
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
@@ -585,7 +608,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
- [ ] The GitHub prerelease exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] The GitHub prerelease was published only after the three beta manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
- [ ] The GitHub prerelease contains `beta-mac.yml`, `beta-linux.yml`, and `beta.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
@@ -608,7 +631,7 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Every GitHub Actions run for the complete release commit and tag is green
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] The published GitHub Release exists with the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
- [ ] GitHub `Android APK Release` workflow for the same tag is green
+1 -1
View File
@@ -1 +1 @@
sha256-j3lk7o4W10hcXeNIN3kGlqSHn8aCr+GCDkKYZNXNSrw=
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
-47
View File
@@ -39056,7 +39056,6 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.2",
"@getpaseo/highlight": "0.7.2",
"@getpaseo/plugin": "0.7.2",
@@ -39106,52 +39105,6 @@
"vitest": "^4.1.6"
}
},
"packages/server/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"packages/server/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+181 -2
View File
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
@@ -312,6 +312,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
await expect(seeCommitted).toBeVisible();
await seeCommitted.click();
@@ -319,6 +320,21 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
});
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
page,
}) => {
const workspace = await createWorkspaceWithScrollableComparisons();
await openWorkspaceChangesSurface(page, workspace);
await selectChangesComparison(page, "Committed");
await expectWorkingComparisonFiles(page, "committed");
await selectChangedFileAndExpectFocused(page, "committed/50-target.ts");
await selectChangesComparison(page, "Uncommitted");
await expectWorkingComparisonFiles(page, "uncommitted");
await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts");
});
test("changes file actions open below the right-click without a reserved kebab", async ({
page,
}) => {
@@ -1003,6 +1019,16 @@ test("autofocusing an inline review keeps the Changes tab focused", async ({ pag
.toBe(focusedBackground);
});
test("inline reviews keep the browser text context menu", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await startReviewOnFirstChangedLine(page);
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
});
test("split canvas creates a review on the changed side and keeps it in that column", async ({
page,
}) => {
@@ -1126,6 +1152,33 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
const workspace = await createWorkspaceWithTwoSelectionDiffs();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }, 1);
await page.getByTestId("diff-file-0-toggle").click();
await rightClickFirstChangedLine(page, 1);
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
});
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await useUnwrappedDiffLines(page);
@@ -1466,6 +1519,46 @@ async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithScrollableComparisons(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-comparison-focus-", {
files: [{ path: "tracked.ts", content: "export const tracked = true;\n" }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const longFile = Array.from(
{ length: 120 },
(_, index) => `export const line${index} = ${index};`,
).join("\n");
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
await writeComparisonFiles(repo.path, "committed", longFile);
execFileSync("git", ["add", "committed"], { cwd: repo.path });
execFileSync("git", ["commit", "-m", "Add committed comparison files"], { cwd: repo.path });
await writeComparisonFiles(repo.path, "uncommitted", longFile);
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create comparison workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function writeComparisonFiles(
repoPath: string,
directory: "committed" | "uncommitted",
longFile: string,
): Promise<void> {
await mkdir(path.join(repoPath, directory), { recursive: true });
await Promise.all([
writeFile(path.join(repoPath, directory, "00-before.ts"), `${longFile}\n`),
writeFile(path.join(repoPath, directory, "50-target.ts"), "export const target = true;\n"),
writeFile(path.join(repoPath, directory, "99-after.ts"), `${longFile}\n`),
]);
}
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-canvas-selection-", {
files: [{ path: "src/selection.ts", content: "" }],
@@ -1483,6 +1576,29 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("changes-canvas-selection-shift-", {
files: [
{ path: "src/first.ts", content: "" },
{ path: "src/second.ts", content: "" },
],
});
await Promise.all([
writeFile(path.join(repo.path, "src/first.ts"), "FIRST\n"),
writeFile(path.join(repo.path, "src/second.ts"), "ABCDEFGHIJ\n"),
]);
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
},
});
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
return { id: created.workspace.id, repoPath: repo.path };
}
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
const files = Array.from({ length: fileCount }, (_, index) => ({
path: `src/file-${String(index).padStart(4, "0")}.ts`,
@@ -1583,6 +1699,54 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
});
}
async function selectChangesComparison(
page: Page,
comparison: "Committed" | "Uncommitted",
): Promise<void> {
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
await tree.getByTestId("changes-diff-status-trigger").click();
await page.getByTestId(`changes-diff-mode-${comparison.toLowerCase()}`).click();
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText(comparison);
}
async function expectWorkingComparisonFiles(
page: Page,
comparison: "committed" | "uncommitted",
): Promise<void> {
const tree = changesTree(page);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const paths = ["00-before.ts", "50-target.ts", "99-after.ts"];
await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText(
paths,
);
for (const fileName of paths) {
await expect(diffHeaderForPath(panel, `${comparison}/${fileName}`)).toBeAttached();
}
}
async function selectChangedFileAndExpectFocused(page: Page, filePath: string): Promise<void> {
const tree = changesTree(page);
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const scroller = panel.getByTestId("git-diff-scroll");
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await tree.getByText(path.basename(filePath), { exact: true }).click();
const header = diffHeaderForPath(panel, filePath);
await expect(header).toBeVisible();
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
const [scrollBounds, headerBounds] = await Promise.all([
scroller.boundingBox(),
header.boundingBox(),
]);
if (!scrollBounds || !headerBounds)
throw new Error("Focused diff header geometry is unavailable");
expect(headerBounds.y - scrollBounds.y).toBeCloseTo(0, 0);
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
@@ -1649,6 +1813,20 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, fontSize] = await Promise.all([
body.boundingBox(),
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
]);
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
const lineHeight = Math.round(fontSize * 1.5);
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5, {
button: "right",
});
}
async function hoverFirstChangedGutter(page: Page): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const canvas = page.getByTestId("git-diff-canvas");
@@ -1689,8 +1867,9 @@ async function deleteInlineReview(page: Page): Promise<void> {
async function dragExactAddedText(
page: Page,
offsets: { startOffset: number; endOffset: number },
fileIndex = 0,
): Promise<void> {
const body = page.getByTestId("diff-file-0-body");
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
const [bodyBounds, metrics] = await Promise.all([
body.boundingBox(),
@@ -0,0 +1,112 @@
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
startWorkspaceScriptFromMenu,
} from "../support/helpers/workspace-setup";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const evidenceDir = "/tmp/fork-setup-gate";
test("fork workspace setup stays blocked until the user runs it", async ({ page }) => {
test.setTimeout(90_000);
mkdirSync(evidenceDir, { recursive: true });
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("fork-setup-gate-", {
withRemote: true,
originUrl: "https://github.com/paseo-e2e/local-fixture.git",
branches: ["pr-branch-2"],
paseoConfig: {
worktree: {
setup: ["node -e \"setTimeout(() => console.log('setup complete'), 1500)\""],
},
scripts: { dev: { command: 'node -e "setTimeout(() => {}, 30000)"' } },
},
});
try {
const localRemote = path.join(repo.path, "remote.git");
execFileSync("git", ["update-ref", "refs/pull/2/head", "refs/heads/pr-branch-2"], {
cwd: localRemote,
});
execFileSync(
"git",
["config", `url.${localRemote}.insteadOf`, "git@github.com:fork-owner/local-fixture.git"],
{ cwd: repo.path },
);
execFileSync(
"git",
[
"config",
"--add",
`url.${localRemote}.insteadOf`,
"https://github.com/paseo-e2e/local-fixture.git",
],
{ cwd: repo.path },
);
await seedProjectForWorkspaceSetup(client, repo.path);
const result = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repo.path,
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 2 },
},
});
if (!result.workspace || result.error)
throw new Error(result.error ?? "Workspace creation failed");
expect(result.setupSkippedReason).toContain("Scripts are blocked for PR #2");
await openHomeWithProject(page, repo.path);
await page.goto(buildHostWorkspaceRoute(getServerId(), result.workspace.id));
await waitForWorkspaceTabsVisible(page);
await test.step("the blocked setup tab opens with the fork warning", async () => {
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("alert")).toContainText("fork-owner/local-fixture");
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
await page.screenshot({
path: path.join(evidenceDir, "blocked-fork-setup.png"),
fullPage: true,
});
});
await test.step("starting a script explains why it is blocked", async () => {
await openWorkspaceScriptsMenu(page);
await startWorkspaceScriptFromMenu(page, "dev");
await expect(page.getByText(/Scripts are blocked for PR #2/)).toBeVisible();
await page.screenshot({
path: path.join(evidenceDir, "blocked-script-error.png"),
fullPage: true,
});
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
});
await test.step("running setup replaces the warning with normal progress", async () => {
await page.getByRole("button", { name: "Run setup" }).click();
await expect(page.getByTestId("workspace-setup-status")).toContainText(/Running|Completed/, {
timeout: 30_000,
});
await page.screenshot({
path: path.join(evidenceDir, "fork-setup-running.png"),
fullPage: true,
});
await expect(page.getByTestId("workspace-setup-status")).toContainText("Completed", {
timeout: 30_000,
});
});
} finally {
await client.close();
await repo.cleanup();
}
});
@@ -0,0 +1,293 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { test, type Page } from "../support/fixtures";
import { ImportSessionFlow } from "../support/helpers/import-session";
import {
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
type OpenedProject,
} from "../support/helpers/new-workspace";
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
const SCREENSHOT_DIRECTORY = path.join(
process.env.HOME ?? tmpdir(),
".paseo/plans/import-session-ux",
);
const claudeConfigDirectory = mkdtempSync(path.join(tmpdir(), "paseo-import-flow-claude-"));
const brokenProvider = "broken-acp";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: { enabled: false },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
[brokenProvider]: {
extends: "acp",
label: "Broken ACP",
command: ["missing-agent-command", "acp"],
},
},
},
},
e2eDaemonEnvironment: { CLAUDE_CONFIG_DIR: claudeConfigDirectory },
});
interface ImportFlowScenario {
project: OpenedProject;
reuseTarget: OpenedProject;
projectName: string;
projectRoot: string;
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
repoCleanup(): Promise<void>;
unrelatedCleanup(): Promise<void>;
}
let scenario: ImportFlowScenario;
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
test.setTimeout(120_000);
test.beforeAll(async () => {
await mkdir(SCREENSHOT_DIRECTORY, { recursive: true });
const repo = await createTempGitRepo("isf-", {
originUrl: "https://github.com/paseo-e2e/import-session-fixture.git",
});
const unrelated = await createTempDirectory("isf-other-");
const worktreeDirectory = path.join(repo.path, "worktrees", "review-fix");
await mkdir(path.dirname(worktreeDirectory), { recursive: true });
execFileSync("git", ["worktree", "add", "-b", "review-fix", worktreeDirectory], {
cwd: repo.path,
stdio: "ignore",
});
client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const project = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!project.workspace) {
throw new Error(project.error ?? "Failed to create the import-session fixture workspace");
}
const projects = await client.listProjects();
const projectDescriptor = projects.projects.find(
(candidate) => candidate.projectId === project.workspace?.projectId,
);
if (!projectDescriptor?.projectKey) {
throw new Error("Fixture workspace has no project key");
}
const openedProject: OpenedProject = {
workspaceId: project.workspace.id,
projectId: project.workspace.projectId,
projectKey: projectDescriptor.projectKey,
projectDisplayName: project.workspace.projectDisplayName,
workspaceName: project.workspace.name,
workspaceDirectory: project.workspace.workspaceDirectory,
};
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
const importSessionId = "fixture-custom-title";
await seedClaudeSessions({
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
});
scenario = {
project: openedProject,
reuseTarget,
projectName: project.workspace.projectDisplayName,
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
repoCleanup: repo.cleanup,
unrelatedCleanup: unrelated.cleanup,
};
});
test.afterAll(async () => {
await client?.removeProject(scenario?.project.projectId).catch(() => undefined);
await client?.removeProject(scenario?.reuseTarget.projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await scenario?.repoCleanup().catch(() => undefined);
await scenario?.unrelatedCleanup().catch(() => undefined);
await rm(claudeConfigDirectory, { recursive: true, force: true });
});
test("captures the compact import-session journey", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await test.step("the mobile sidebar exposes import in its footer", async () => {
await flow.revealMobileEntryPoint();
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
});
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
await flow.openGlobally();
await flow.expectScope("Sessions on", false);
await flow.expectRows({
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
folders: [
[scenario.importSessionId, scenario.projectName],
["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`],
["fixture-unrelated", scenario.reuseTarget.projectDisplayName],
],
});
await flow.expectProviderError("Broken ACP");
await flow.expectProviderFilterFits(390);
await flow.revealSession("fixture-unrelated");
await capture(page, testInfo, "02-mobile-sheet-unscoped.png");
});
await test.step("search narrows across the fixture corpus", async () => {
await flow.search("invoice");
await capture(page, testInfo, "03-mobile-search-narrowed.png");
});
await test.step("load more grows the result set and then disappears", async () => {
await flow.resetSearch();
await capture(page, testInfo, "04-mobile-load-more-visible.png");
await flow.loadMore();
await capture(page, testInfo, "05-mobile-load-more-complete.png");
});
await test.step("one provider fails inline and Retry settles", async () => {
await flow.retryProvider(brokenProvider, "Broken ACP");
await capture(page, testInfo, "06-mobile-provider-error-retry.png");
});
await test.step("the selected row imports and opens the hydrated transcript", async () => {
await flow.importSession(scenario.importSessionId);
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
await capture(page, testInfo, "08-mobile-agent-after-import.png");
});
await test.step("workspace actions start scoped and can widen to the host", async () => {
await flow.openFromWorkspaceHeader();
await capture(page, testInfo, "09-mobile-workspace-scoped.png");
await flow.showAll();
await flow.expectRows({
folders: [["fixture-unrelated", scenario.reuseTarget.projectDisplayName]],
});
await capture(page, testInfo, "10-mobile-workspace-show-all.png");
await flow.importSession("fixture-root-03");
await flow.expectImportedIntoWorkspace(
scenario.reuseTarget.workspaceId,
"Review fixture item 3",
);
});
});
test("captures the desktop import sheet and command-center entry", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, {
width: 1280,
height: 800,
});
await test.step("desktop shows the flat host-wide sheet", async () => {
await flow.openGlobally();
// The compact test may already have imported the newest fixture row, so this
// asserts recency as an ordering between two rows nothing imports.
await flow.expectRows({
before: ["fixture-worktree", "fixture-unrelated"],
folders: [["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`]],
});
await flow.revealSession("fixture-unrelated");
await capture(page, testInfo, "11-desktop-sheet-unscoped.png");
await flow.close();
});
await test.step("import matches the command but not Home", async () => {
await flow.expectCommandCenterMatch();
await capture(page, testInfo, "12-desktop-command-center-import.png");
});
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
await copyFile(outputPath, path.join(SCREENSHOT_DIRECTORY, name));
}
async function seedClaudeSessions(input: {
projectRoot: string;
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
}): Promise<void> {
const sessions = [
{
cwd: input.projectRoot,
id: input.importSessionId,
title: "Invoice migration plan",
prompt: "Review the invoice migration",
answer: "The fixture transcript is ready.",
},
{
cwd: input.worktreeDirectory,
id: "fixture-worktree",
title: "Review worktree fix",
prompt: "Check the worktree import flow",
},
{
cwd: input.unrelatedDirectory,
id: "fixture-unrelated",
title: "Unrelated directory notes",
prompt: "Review the unrelated directory",
},
...Array.from({ length: 20 }, (_, index) => ({
cwd: [input.projectRoot, input.worktreeDirectory, input.unrelatedDirectory][index % 3]!,
id: `fixture-root-${String(index + 1).padStart(2, "0")}`,
title: `Root session ${String(index + 1).padStart(2, "0")}`,
prompt: index === 7 ? "Investigate invoice rendering" : `Review fixture item ${index + 1}`,
})),
];
const newest = Date.now() - 60_000;
for (const [index, session] of sessions.entries()) {
const projectDirectory = path.join(
claudeConfigDirectory,
"projects",
session.cwd.replace(/[^a-zA-Z0-9]/g, "-"),
);
await mkdir(projectDirectory, { recursive: true });
const sessionPath = path.join(projectDirectory, `${session.id}.jsonl`);
const records = [
{
type: "user",
uuid: `${session.id}-user`,
message: { role: "user", content: session.prompt },
cwd: session.cwd,
sessionId: session.id,
},
...(session.answer
? [
{
type: "assistant",
uuid: `${session.id}-assistant`,
message: {
role: "assistant",
content: [{ type: "text", text: session.answer }],
},
cwd: session.cwd,
sessionId: session.id,
},
]
: []),
{ type: "custom-title", customTitle: session.title, sessionId: session.id },
];
await writeFile(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
}
@@ -0,0 +1,74 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
expectFullscreenDiagramClosed,
openDiagramFullscreen,
requestDiagram,
waitForDiagramTurnToComplete,
zoomAndPanFullscreenDiagram,
} from "../support/helpers/diagram";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const MERMAID_DIAGRAM = [
"```mermaid",
"flowchart LR",
" Start --> Middle",
" Middle --> Review",
" Review --> Ship",
"```",
].join("\n");
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-fullscreen-",
title: "Fullscreen Mermaid",
featureValues: {
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await test.step("Open the conversation and wait for the rendered diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
});
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
await zoomAndPanFullscreenDiagram(page);
});
await test.step("The close action returns to the conversation", async () => {
await closeDiagramFullscreen(page);
await expectFullscreenDiagramClosed(page);
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Escape closes the fullscreen diagram", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Ship"]);
await page.keyboard.press("Escape");
await expectFullscreenDiagramClosed(page);
});
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Ship"]);
await closeDiagramFullscreenFromOutside(page);
await expectFullscreenDiagramClosed(page);
});
} finally {
await agent.cleanup();
}
});
@@ -35,6 +35,7 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
page,
}) => {
test.setTimeout(120_000);
let diagramTurnCompleted!: Promise<void>;
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
@@ -48,15 +49,16 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
await test.step("Open the conversation and request a diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
});
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
await expectDiagramWithLabels(page, ["Start", "Middle"]);
await expectDiagramRemainsRenderedWhileStreaming(page);
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
});
await test.step("The completed diagram shows the final streamed content", async () => {
await waitForDiagramTurnToComplete(agent);
await diagramTurnCompleted;
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
@@ -27,7 +27,10 @@ test("chooses a metadata model and can return to automatic selection", async ({
}, testInfo) => {
await openMetadataGenerationSettings(page);
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("metadata-generation-settings-info")).toHaveAccessibleName(
"About Metadata generation",
);
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
@@ -119,7 +119,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -121,7 +121,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
try {
const catalog = observePluginCatalog(page);
@@ -143,14 +143,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(
path.join(directory, "index.tsx"),
path.join(directory, "index.client.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
@@ -159,13 +159,13 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(
page.getByRole("button", { name: "Broken surface", exact: true }),
).not.toBeVisible();
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
@@ -173,7 +173,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
@@ -54,7 +54,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
@@ -12,10 +12,7 @@ import { buildAgentRoute } from "../support/helpers/mock-agent";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
} from "../support/helpers/sidebar";
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
@@ -29,18 +26,11 @@ function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginSource(input: { workspaceId: string; agentId: string }): string {
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});
import { recordComposerOpen } from "./shared/rpc";
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
@@ -114,23 +104,49 @@ function contributeClient(client) {
};
}
export default function contribute(plugin) {
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
export default function contribute(client) {
client.addSurface("collision", DirectCollisionSurface);
client.addSurface("sidebar-destination", SidebarCollisionSurface);
client.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
client.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
client.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
client.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
client.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
client.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
client.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
return contributeClient(client);
}`;
}
const pluginSharedSource = `import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});`;
const pluginServerSource = `import { recordComposerOpen } from "./shared/rpc";
export default function contribute(server) {
server.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
return { opened: true };
});
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
plugin.addClientSide(contributeClient);
return () => {};
}`;
async function writePluginSources(
directory: string,
input: { workspaceId: string; agentId: string },
): Promise<void> {
await mkdir(path.join(directory, "shared"), { recursive: true });
await Promise.all([
writeFile(path.join(directory, "index.client.tsx"), pluginClientSource(input)),
writeFile(path.join(directory, "index.server.ts"), pluginServerSource),
writeFile(path.join(directory, "shared", "rpc.ts"), pluginSharedSource),
]);
}
async function searchCommands(page: Page, query: string) {
@@ -159,7 +175,7 @@ async function runCommand(page: Page, title: string): Promise<void> {
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
@@ -184,10 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => {
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(
path.join(directory, "index.tsx"),
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
});
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
@@ -263,10 +279,10 @@ test.describe("plugin workspace panels and Command Center", () => {
modeId: "load-test",
});
const navigationAgentId = agent.id;
await writeFile(
path.join(directory, "index.tsx"),
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
);
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: navigationAgentId,
});
await primaryClient.reloadPlugin(PLUGIN_ID);
await runCommand(page, "Open direct collision surface");
@@ -331,8 +347,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
await openCompactSidebar(page);
// The sidebar's Search row dismisses the compact sidebar on its way to the
// command center, so nothing has to close it after the command runs.
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(
@@ -0,0 +1,108 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectSidebarItemHidden,
expectSidebarNavSettingsOrder,
expectSidebarNavSettingsRow,
expectSidebarOrder,
expectStoredSidebarNav,
leaveSettings,
moveSidebarNavItemUp,
openSidebarNavSettings,
seedSidebarNavPreferences,
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
test.describe("Sidebar items in Appearance settings", () => {
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
await gotoAppShell(page);
await test.step("the sidebar starts in the default order", async () => {
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
});
await test.step("the Sidebar section lists every item in the same order", async () => {
await openSidebarNavSettings(page);
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
"About Sidebar",
);
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"history",
"search",
"schedules",
]);
await expectSidebarNavSettingsRow(page, {
key: "history",
label: "History",
visible: true,
});
// Items with a keyboard shortcut badge it next to their name. Chords render
// with Ctrl off macOS, which is what the browser project runs on.
await expect(
page.getByTestId("sidebar-nav-item-new-workspace").getByText("Ctrl+N", { exact: true }),
).toBeVisible();
});
await test.step("moving Schedules up twice lifts it above History", async () => {
await moveSidebarNavItemUp(page, "schedules");
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"history",
"schedules",
"search",
]);
await moveSidebarNavItemUp(page, "schedules");
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"schedules",
"history",
"search",
]);
await leaveSettings(page);
await expectSidebarOrder(page, ["new-workspace", "schedules", "history", "search"]);
});
await test.step("turning History off removes it from the sidebar", async () => {
await openSidebarNavSettings(page);
await setSidebarNavItemVisible(page, "history", false);
await expectStoredSidebarNav(page, [
{ key: "new-workspace", visible: true },
{ key: "schedules", visible: true },
{ key: "history", visible: false },
{ key: "search", visible: true },
]);
await leaveSettings(page);
await expectSidebarItemHidden(page, "history");
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
});
await test.step("the sidebar keeps that shape across a reload", async () => {
await page.reload();
await expectSidebarItemHidden(page, "history");
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
});
});
test("renders no top-level items when every one is turned off", async ({ page }) => {
await seedSidebarNavPreferences(page, [
{ key: "new-workspace", visible: false },
{ key: "history", visible: false },
{ key: "search", visible: false },
{ key: "schedules", visible: false },
]);
await gotoAppShell(page);
// The sidebar itself still renders; only its top-level nav items are gone.
await expect(page.locator('[data-testid="sidebar-settings"]:visible')).toBeVisible({
timeout: 30_000,
});
await expectSidebarItemHidden(page, "new-workspace");
await expectSidebarItemHidden(page, "history");
await expectSidebarItemHidden(page, "search");
await expectSidebarItemHidden(page, "schedules");
});
});
@@ -0,0 +1,77 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { createIdleAgent } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
const TITLE_MAX_LENGTH = 80;
function normalizedTitle(title: string): string {
return title.replace(/\s+/g, " ").trim();
}
function expectedTooltipTitle(title: string): string {
const singleLineTitle = normalizedTitle(title);
return singleLineTitle.length <= TITLE_MAX_LENGTH
? singleLineTitle
: `${singleLineTitle.slice(0, TITLE_MAX_LENGTH - 1).trimEnd()}…`;
}
async function openAgent(page: Page, agent: { id: string; workspaceId: string }): Promise<void> {
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
}
async function openAgentTabTooltip(page: Page, agentId: string, title: string): Promise<Locator> {
await page.getByRole("button", { name: normalizedTitle(title), exact: true }).hover();
const tooltip = page.getByTestId(`workspace-tab-tooltip-agent_${agentId}`);
await expect(tooltip).toBeVisible({ timeout: 10_000 });
return tooltip;
}
async function expectTwoRowAgentSummary(
tooltip: Locator,
input: { title: string; agentId: string },
): Promise<void> {
const title = tooltip.getByText(expectedTooltipTitle(input.title), { exact: true });
const id = tooltip.getByText(input.agentId.slice(0, 7), { exact: true });
const activity = tooltip.getByText(/^(just now|\d+[mhd] ago|[A-Z][a-z]{2} \d{1,2})$/);
await expect(title).toBeVisible();
await expect(id).toBeVisible();
await expect(tooltip.getByText("·", { exact: true })).toBeVisible();
await expect(activity).toBeVisible();
expect((await title.boundingBox())?.y).toBeLessThan((await id.boundingBox())?.y ?? 0);
}
test.describe("Workspace agent tab tooltip", () => {
test("summarizes a multiline agent title above its ID and last activity", async ({ page }) => {
test.setTimeout(120_000);
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-tooltip-" });
try {
const title = [
"Short first line",
"followed by a much longer continuation that should be joined before the complete title is truncated to a useful tooltip length",
].join("\n");
const agent = await createIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
});
await openAgent(page, agent);
const tooltip = await openAgentTabTooltip(page, agent.id, title);
await expectTwoRowAgentSummary(tooltip, { title, agentId: agent.id });
} finally {
await workspace.cleanup();
}
});
});
@@ -22,7 +22,7 @@ import {
expectSetupPanel,
openHomeWithProject,
navigateToWorkspaceViaSidebar,
returnHomeFromWorkspace,
leaveWorkspaceViaHistory,
openWorkspaceScriptsMenu,
startWorkspaceScriptFromMenu,
closeWorkspaceScriptsMenu,
@@ -195,7 +195,7 @@ test.describe("Workspace setup streaming", () => {
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
await closeSetupTab(page, workspace.id);
await returnHomeFromWorkspace(page);
await leaveWorkspaceViaHistory(page);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupTabNotSeeded(page, workspace.id);
} finally {
+11 -1
View File
@@ -35,6 +35,8 @@ const metroTest = base.extend({
const daemonTest = metroTest.extend<
{ projectOwnership: void },
{
e2eDaemonConfig: Record<string, unknown> | undefined;
e2eDaemonEnvironment: Record<string, string>;
e2eForkProviders: string[];
e2eInjectPaseoTools: boolean;
e2eWorker: void;
@@ -43,9 +45,17 @@ const daemonTest = metroTest.extend<
>({
e2eForkProviders: [[], { scope: "worker", option: true }],
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
e2eDaemonConfig: [undefined, { scope: "worker", option: true }],
e2eDaemonEnvironment: [{}, { scope: "worker", option: true }],
e2eWorker: [
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
async (
{ e2eDaemonConfig, e2eDaemonEnvironment, e2eForkProviders, e2eInjectPaseoTools },
provide,
workerInfo,
) => {
const worker = await startE2EWorker(workerInfo.workerIndex, {
daemonConfig: e2eDaemonConfig,
environment: e2eDaemonEnvironment,
forkProviders: e2eForkProviders,
injectPaseoTools: e2eInjectPaseoTools,
});
@@ -58,7 +58,7 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
}
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
await page.keyboard.press("Meta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
@@ -83,6 +83,7 @@ export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise<v
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
const expectedActions = [
{ title: "New workspace", shortcut: "⌘N" },
{ title: "Import session" },
{ title: "History" },
{ title: "Schedules" },
{ title: "Settings", shortcut: "⌘," },
@@ -105,6 +106,9 @@ export async function expectPrimaryCommandCenterActions(panel: Locator): Promise
await input.fill("home");
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
await input.fill("import");
await expect(panel.getByRole("button").filter({ hasText: "Import session" })).toBeVisible();
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
await input.fill("");
}
@@ -2,7 +2,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
await page.getByTestId("sidebar-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
+109 -7
View File
@@ -25,13 +25,28 @@ export async function expectDiagramWithLabels(
}
}
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
const { diagram, svg } = renderedDiagram(page);
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 500 });
await page.waitForTimeout(25);
export async function expectDiagramRemainsRenderedWhileStreaming(
page: Page,
completion: Promise<void>,
): Promise<void> {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
let turnCompleted = false;
const completed = completion.then(() => {
turnCompleted = true;
return true;
});
for (;;) {
expect(await diagram.isVisible()).toBe(true);
if (turnCompleted) return completion;
const box = await diagram.boundingBox();
if (turnCompleted) return completion;
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
expect(
box?.height,
"the inline diagram should remain tall enough to read",
).toBeGreaterThanOrEqual(56);
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
if (didComplete) return completion;
}
}
@@ -46,3 +61,90 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
export async function reloadConversation(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
}
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
function diagramSvg(page: Page, viewportTestId: string) {
return page
.getByTestId(`${viewportTestId}-canvas`)
.last()
.locator("iframe")
.contentFrame()
.locator("#diagram svg");
}
/**
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
* step left the cursor inside the viewport.
*/
export async function openDiagramFullscreen(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await page.getByTestId("mermaid-viewport-canvas").last().hover();
await page.getByTestId("mermaid-fullscreen").last().click();
}
export async function closeDiagramFullscreen(page: Page): Promise<void> {
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
await page.getByTestId("mermaid-fullscreen-close").click();
}
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
const initial = await svg.boundingBox();
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
await page.mouse.wheel(0, -100);
const zoomed = await svg.boundingBox();
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
const viewport = await canvas.boundingBox();
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
await page.mouse.down();
await page.mouse.move(
viewport!.x + viewport!.width / 2 - 120,
viewport!.y + viewport!.height / 2,
);
await page.mouse.up();
const panned = await svg.boundingBox();
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
}
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
const viewport = await canvas.boundingBox();
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
const x = viewport!.x + viewport!.width / 2;
const y =
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
? viewport!.y + viewport!.height - 4
: viewport!.y + 4;
await page.mouse.click(x, y);
}
export async function expectFullscreenDiagram(
page: Page,
labels: readonly string[],
): Promise<void> {
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
await expect(viewport).toBeVisible({ timeout: 30_000 });
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
await expect(svg).toBeVisible({ timeout: 30_000 });
for (const label of labels) {
await expect(svg).toContainText(label);
}
}
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
@@ -34,7 +34,7 @@ async function createRunningMockAgent(
}
async function openCommandCenter(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open command center" }).click();
await page.getByTestId("sidebar-search").click();
}
export class DirectoryBootstrapScenario {
+28 -10
View File
@@ -10,6 +10,13 @@ export interface E2EWorker {
close(): Promise<void>;
}
export interface E2EWorkerOptions {
forkProviders?: string[];
injectPaseoTools?: boolean;
daemonConfig?: Record<string, unknown>;
environment?: Record<string, string>;
}
function resolveOptionalHome(value: string | undefined): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
@@ -77,31 +84,33 @@ if (origin === fixtureRemote) {
process.exit(0);
}
if (command === "pr list" || command === "pr view") {
const isFork = args.includes("2");
const pr = {
number: 1,
number: isFork ? 2 : 1,
title: "Use pasted PR as start ref",
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
state: "OPEN",
body: null,
labels: [],
baseRefName: "main",
headRefName: "pr-branch-1",
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
updatedAt: "2026-01-01T00:00:00Z"
};
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
process.exit(0);
}
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
const isFork = args.some((arg) => arg === "number=2");
process.stdout.write(JSON.stringify({
data: { repository: { pullRequest: {
number: 1,
number: isFork ? 2 : 1,
baseRefName: "main",
headRefName: "pr-branch-1",
isCrossRepository: false,
headRepositoryOwner: { login: "paseo-e2e" },
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
isCrossRepository: isFork,
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
headRepository: {
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
url: fixtureRemote
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
}
} } }
}));
@@ -156,7 +165,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
export async function startE2EWorker(
workerIndex: number,
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
options: E2EWorkerOptions = {},
): Promise<E2EWorker> {
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
const paseoHome = requestedRoot
@@ -169,6 +178,14 @@ export async function startE2EWorker(
try {
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
// Worker-scoped fixture config lets a spec exercise provider discovery without
// reading the developer's provider state or sharing configuration with other specs.
if (options.daemonConfig) {
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify(options.daemonConfig, null, 2)}\n`,
);
}
if (options.injectPaseoTools) {
await enablePaseoTools(paseoHome);
}
@@ -179,6 +196,7 @@ export async function startE2EWorker(
NODE_ENV: "development",
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
...options.environment,
},
});
@@ -0,0 +1,145 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { gotoAppShell } from "./app";
import { openCommandCenter } from "./command-center";
import { getServerId } from "./server-id";
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
const rowTestId = (id: string) => `import-session-session-claude-${id}`;
const rowFolderTestId = (id: string) => `import-session-row-folder-claude-${id}`;
export class ImportSessionFlow {
constructor(private readonly page: Page) {}
async openWorkspace(workspaceId: string, viewport: { width: number; height: number }) {
await this.page.setViewportSize(viewport);
await gotoAppShell(this.page);
await this.page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await expect(this.page.getByRole("button", { name: "Workspace actions" })).toBeVisible({
timeout: 30_000,
});
}
async revealMobileEntryPoint() {
await openMobileAgentSidebar(this.page);
await expectMobileAgentSidebarVisible(this.page);
const button = this.page.getByTestId("sidebar-import-session");
await expect(button).toHaveAccessibleName("Import session");
await button.hover();
await expect(this.page.getByText("Import session", { exact: true })).toBeVisible();
}
async openGlobally() {
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
await this.page.getByTestId("sidebar-import-session").click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
await this.page.getByRole("button", { name: "Workspace actions" }).click();
await this.page.getByTestId("workspace-header-import-agent").click();
await this.expectScope("This workspace");
await expect(this.page.getByTestId("import-session-show-all")).toBeVisible();
await expect(this.page.getByText("Workspace actions", { exact: true })).not.toBeVisible();
}
async expectScope(text: string, exact = true) {
const scope = this.page.getByTestId("import-session-scope");
if (exact) await expect(scope).toHaveText(text);
else await expect(scope).toContainText(text);
await expect(scope).toBeVisible();
}
async expectRows(input: {
first?: string[];
before?: [string, string];
folders?: Array<[string, string]>;
}) {
const ids = await this.rowIds();
if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId));
if (input.before) {
expect(ids.indexOf(rowTestId(input.before[0]))).toBeGreaterThanOrEqual(0);
expect(ids.indexOf(rowTestId(input.before[0]))).toBeLessThan(
ids.indexOf(rowTestId(input.before[1])),
);
}
for (const [id, folder] of input.folders ?? []) {
await expect(this.page.getByTestId(rowFolderTestId(id))).toHaveText(folder);
}
}
async expectProviderError(label: string) {
await expect(this.page.getByTestId("import-session-provider-errors")).toContainText(
`Could not load ${label} sessions`,
);
}
async expectProviderFilterFits(width: number) {
await expect(this.page.getByTestId("import-session-scope")).toBeInViewport();
const filter = this.page.getByRole("button", { name: "Filter: All" });
await expect(filter).toBeInViewport();
const bounds = await filter.boundingBox();
expect(bounds?.x ?? width).toBeGreaterThanOrEqual(0);
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
}
async revealSession(id: string) {
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
}
async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query);
await expect(this.page.getByText("Invoice migration plan", { exact: true })).toBeVisible();
await expect(this.page.getByText("Root session 01", { exact: true })).toHaveCount(0);
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async resetSearch() {
await this.page.getByTestId("import-session-search").fill("");
await expect(this.page.getByTestId("import-session-load-more")).toBeVisible();
}
async loadMore() {
await this.page.getByTestId("import-session-load-more").click();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async retryProvider(provider: string, label: string) {
await this.expectProviderError(label);
await this.page.getByTestId(`import-session-retry-${provider}`).click();
await expect(this.page.getByTestId(`import-session-retry-${provider}`)).toBeEnabled();
await this.expectProviderError(label);
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
}
async importSession(id: string) {
await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000,
});
}
async expectTranscript(userText: string, assistantText: string) {
await expect(this.page.getByTestId("user-message")).toContainText(userText);
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
}
async showAll() {
await this.page.getByTestId("import-session-show-all").click();
await expect(this.page.getByTestId("import-session-scope")).toContainText("Sessions on");
}
async expectImportedIntoWorkspace(workspaceId: string, userText: string) {
await expect(this.page).toHaveURL(buildHostWorkspaceRoute(getServerId(), workspaceId), {
timeout: 30_000,
});
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
await expect(workspace.getByTestId("user-message")).toContainText(userText);
}
async close() {
await this.page.keyboard.press("Escape");
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
}
async expectCommandCenterMatch() {
const panel = await openCommandCenter(this.page);
await panel.getByTestId("command-center-input").fill("import");
await expect(panel.getByText("Import session", { exact: true })).toBeVisible();
await expect(panel.getByText("Home", { exact: true })).toHaveCount(0);
}
private async expectSheetReady() {
const sheet = this.page.getByTestId("import-session-sheet");
await expect(sheet).toBeVisible({ timeout: 30_000 });
await expect(sheet.getByText("Loading sessions...", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
}
private async rowIds() {
return await this.page
.locator('[data-testid^="import-session-session-claude-"]')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
}
}
@@ -0,0 +1,150 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Persisted nav key -> the testID the app shell renders that item with. */
const SHELL_ROW_TEST_IDS = {
"new-workspace": "sidebar-global-new-workspace",
history: "sidebar-sessions",
search: "sidebar-search",
schedules: "sidebar-schedules",
} as const;
export type SidebarNavKey = keyof typeof SHELL_ROW_TEST_IDS;
export interface SidebarNavPreference {
key: string;
visible: boolean;
}
function shellRow(page: Page, key: SidebarNavKey): Locator {
// `:visible` rather than a plain testID: the shell keeps a compact copy of the
// sidebar mounted, so the pinned row is the first visible match.
return page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`).first();
}
function settingsRow(page: Page, key: SidebarNavKey): Locator {
return page.getByTestId(`sidebar-nav-item-${key}`);
}
function itemLabel(key: SidebarNavKey): string {
return {
"new-workspace": "New workspace",
history: "History",
search: "Search",
schedules: "Schedules",
}[key];
}
async function rowTop(locator: Locator): Promise<number | null> {
const box = await locator.boundingBox();
return box?.y ?? null;
}
export async function seedSidebarNavPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarNavItems }) => {
localStorage.setItem(key, JSON.stringify({ sidebarNavItems }));
},
{ key: APP_SETTINGS_KEY, sidebarNavItems: preferences },
);
}
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
}
export async function leaveSettings(page: Page): Promise<void> {
await clickSettingsBackToWorkspace(page);
}
export async function moveSidebarNavItemUp(page: Page, key: SidebarNavKey): Promise<void> {
await settingsRow(page, key).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function setSidebarNavItemVisible(
page: Page,
key: SidebarNavKey,
visible: boolean,
): Promise<void> {
const toggle = settingsRow(page, key).getByRole("switch", {
name: itemLabel(key),
exact: true,
});
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
export async function expectSidebarNavSettingsRow(
page: Page,
expected: { key: SidebarNavKey; label: string; visible: boolean },
): Promise<void> {
const row = settingsRow(page, expected.key);
await expect(row).toBeVisible();
await expect(row.getByText(expected.label, { exact: true })).toBeVisible();
const toggle = row.getByRole("switch", { name: expected.label, exact: true });
await expect(toggle).toHaveAccessibleName(expected.label);
await expect(toggle).toHaveAttribute("aria-checked", String(expected.visible));
}
export async function expectSidebarNavSettingsOrder(
page: Page,
keys: SidebarNavKey[],
): Promise<void> {
await expectVerticalOrder(keys, (key) => settingsRow(page, key), "sidebar nav settings rows");
}
export async function expectSidebarOrder(page: Page, keys: SidebarNavKey[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellRow(page, key), "app shell sidebar rows");
}
export async function expectSidebarItemHidden(page: Page, key: SidebarNavKey): Promise<void> {
await expect(page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`)).toHaveCount(0);
}
export async function expectStoredSidebarNav(
page: Page,
expected: SidebarNavPreference[],
): Promise<void> {
await expect
.poll(
() =>
page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw).sidebarNavItems ?? null) : null;
}, APP_SETTINGS_KEY),
{ timeout: 15_000 },
)
.toEqual(expected);
}
async function expectVerticalOrder(
keys: SidebarNavKey[],
locate: (key: SidebarNavKey) => Locator,
subject: string,
): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
if (
!measured.every(
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
)
)
return null;
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
{ message: `Expected ${subject} in order`, timeout: 15_000 },
)
.toEqual(keys);
}
@@ -163,7 +163,7 @@ class StartupAssertions {
await expect(hostRow).toBeVisible({ timeout: 15_000 });
await expect(hostRow).toContainText(input.label);
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
return this;
@@ -276,9 +276,9 @@ export async function navigateToWorkspaceViaSidebar(
});
}
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
await page.getByRole("button", { name: "Home" }).click();
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
export async function leaveWorkspaceViaHistory(page: Page): Promise<void> {
await page.getByRole("button", { name: "History", exact: true }).click();
await expect(page).toHaveURL(/\/sessions$/, { timeout: 30_000 });
}
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { findMountedWindowStart } from "./history-window";
import { buildAgentStreamRenderModel } from "./model";
function createTimestamp(seed: number): Date {
@@ -74,6 +76,31 @@ describe("buildAgentStreamRenderModel", () => {
});
});
it("keeps the mounted boundary stable when a transformer filters an earlier item", () => {
const tail = [
userMessage("filtered", 1),
assistantMessage("hidden", 2),
userMessage("visible-u", 3),
assistantMessage("visible-a", 4),
];
const projectedTail = projectPluginTimelineItems(tail, ({ sourceId }) =>
sourceId === "filtered" ? [] : undefined,
);
const historyStart = findMountedWindowStart({ items: projectedTail, minMountedCount: 2 });
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail: projectedTail,
head: [],
platform: "native",
isMobileBreakpoint: false,
historyStart,
});
expect(model.history.map((item) => item.id)).toEqual(["visible-a", "visible-u"]);
});
it("keeps head separate from committed history on desktop web", () => {
const tail: StreamItem[] = [];
for (let index = 0; index < 60; index += 1) {
+15 -6
View File
@@ -107,7 +107,8 @@ import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useStreamHistoryWindow } from "./use-stream-history-window";
import { PluginTimelineItemView } from "@/plugins/timeline";
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -375,6 +376,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? context.serverId ?? "";
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const sessionStreamHead = useSessionStore((state) =>
@@ -557,6 +559,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toolCallDetailLevel,
],
);
const projectedPlugins = useMemo(
() => ({
tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem),
head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem),
}),
[projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem],
);
const {
start: historyWindowStart,
hasLocalHistory,
@@ -564,7 +573,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: projectedToolCalls.tail,
items: projectedPlugins.tail,
loadRemoteOlder,
});
const isLoadingOlder = remoteIsLoadingOlder;
@@ -575,8 +584,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
tail: projectedToolCalls.tail,
head: projectedToolCalls.head,
tail: projectedPlugins.tail,
head: projectedPlugins.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
historyStart: historyWindowStart,
@@ -584,8 +593,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [
isMobile,
isTurnActive,
projectedToolCalls.head,
projectedToolCalls.tail,
projectedPlugins.head,
projectedPlugins.tail,
effectiveTurnPresentation.startedAt,
historyWindowStart,
]);
@@ -42,6 +42,7 @@ export const ACP_PROVIDER_ICON_SVGS = {
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>\n</svg>\n',
"github-copilot-cli":
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="currentColor"/>\n<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="currentColor"/>\n<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="currentColor"/>\n</svg>\n',
gjc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="76 62 872 872">\n <path fill="currentColor" d="M284 493C307 423 337 375 377 349C416 318 463 303 512 303C561 303 608 318 647 349C687 375 717 423 740 493C615 454 409 454 284 493Z"/>\n <path fill="none" stroke="currentColor" stroke-width="38" stroke-linecap="round" d="M371 478C355 367 304 260 197 229M653 478C669 367 720 260 827 229"/>\n <path fill="currentColor" d="M98 613C109 510 295 451 512 451C729 451 915 510 926 613C935 681 850 744 759 765L763 717C833 698 883 655 881 613C875 554 711 513 512 513C313 513 149 554 143 613C141 655 191 698 261 717L265 765C174 744 89 681 98 613Z"/>\n <path fill="currentColor" fill-rule="evenodd" d="M356 560H668A94 94 0 0 1 762 654V692A94 94 0 0 1 668 786H356A94 94 0 0 1 262 692V654A94 94 0 0 1 356 560ZM364 586H660A71 71 0 0 1 731 657V689A71 71 0 0 1 660 760H364A71 71 0 0 1 293 689V657A71 71 0 0 1 364 586Z"/>\n <path fill="currentColor" d="M365.5 654H450.5A15.5 15.5 0 0 1 466 669.5A15.5 15.5 0 0 1 450.5 685H365.5A15.5 15.5 0 0 1 350 669.5A15.5 15.5 0 0 1 365.5 654ZM573.5 654H658.5A15.5 15.5 0 0 1 674 669.5A15.5 15.5 0 0 1 658.5 685H573.5A15.5 15.5 0 0 1 558 669.5A15.5 15.5 0 0 1 573.5 654Z"/>\n</svg>\n',
"glm-acp-agent":
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
goose:
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="76 62 872 872">
<path fill="currentColor" d="M284 493C307 423 337 375 377 349C416 318 463 303 512 303C561 303 608 318 647 349C687 375 717 423 740 493C615 454 409 454 284 493Z"/>
<path fill="none" stroke="currentColor" stroke-width="38" stroke-linecap="round" d="M371 478C355 367 304 260 197 229M653 478C669 367 720 260 827 229"/>
<path fill="currentColor" d="M98 613C109 510 295 451 512 451C729 451 915 510 926 613C935 681 850 744 759 765L763 717C833 698 883 655 881 613C875 554 711 513 512 513C313 513 149 554 143 613C141 655 191 698 261 717L265 765C174 744 89 681 98 613Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M356 560H668A94 94 0 0 1 762 654V692A94 94 0 0 1 668 786H356A94 94 0 0 1 262 692V654A94 94 0 0 1 356 560ZM364 586H660A71 71 0 0 1 731 657V689A71 71 0 0 1 660 760H364A71 71 0 0 1 293 689V657A71 71 0 0 1 364 586Z"/>
<path fill="currentColor" d="M365.5 654H450.5A15.5 15.5 0 0 1 466 669.5A15.5 15.5 0 0 1 450.5 685H365.5A15.5 15.5 0 0 1 350 669.5A15.5 15.5 0 0 1 365.5 654ZM573.5 654H658.5A15.5 15.5 0 0 1 674 669.5A15.5 15.5 0 0 1 658.5 685H573.5A15.5 15.5 0 0 1 558 669.5A15.5 15.5 0 0 1 573.5 654Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -8,6 +8,7 @@ import {
FolderPlus,
History,
Home,
Import,
Keyboard,
PanelLeft,
Plus,
@@ -17,6 +18,7 @@ import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -52,6 +54,7 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedImport = withUnistyles(Import, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
color: theme.colors.foregroundMuted,
@@ -88,6 +91,10 @@ function HomeIcon({ size }: CommandCenterIconProps) {
return <ThemedHome size={size} strokeWidth={2.2} />;
}
function ImportIcon({ size }: CommandCenterIconProps) {
return <ThemedImport size={size} strokeWidth={2.2} />;
}
function FolderIcon({ size }: CommandCenterIconProps) {
return <ThemedFolder size={size} strokeWidth={2.2} />;
}
@@ -106,6 +113,7 @@ export function CommandCenterRootActions() {
const { overrides } = useKeyboardShortcutOverrides();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAddProject = useOpenAddProject();
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
@@ -166,11 +174,29 @@ export function CommandCenterRootActions() {
},
},
{
id: "home",
id: "import-session",
group: "actions",
groupRank: 0,
rank: 2,
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
keywords: ["import", "session", "terminal"],
visibility: "always",
run: () => {
clearCommandCenterFocusRestoreElement();
openImportSession();
},
presentation: {
kind: "action",
title: t("importSession.title"),
sectionTitle: t("shell.commandCenter.actions"),
icon: ImportIcon,
},
},
{
id: "home",
group: "actions",
groupRank: 0,
rank: 3,
keywords: ["home", "start", "pair", "device", "providers"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
@@ -187,7 +213,7 @@ export function CommandCenterRootActions() {
id: "history",
group: "actions",
groupRank: 0,
rank: 3,
rank: 4,
keywords: ["history", "sessions", "recent"],
visibility: "always",
run: () => {
@@ -205,7 +231,7 @@ export function CommandCenterRootActions() {
id: "schedules",
group: "actions",
groupRank: 0,
rank: 4,
rank: 5,
keywords: ["schedules", "scheduled", "automation", "recurring"],
visibility: "always",
run: () => {
@@ -223,7 +249,7 @@ export function CommandCenterRootActions() {
id: "settings",
group: "actions",
groupRank: 0,
rank: 5,
rank: 6,
keywords: ["settings", "preferences", "config", "configuration"],
visibility: "always",
run: () => {
@@ -250,7 +276,7 @@ export function CommandCenterRootActions() {
id: "toggle-left-sidebar",
group: "actions",
groupRank: 0,
rank: 7,
rank: 8,
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
visibility: "query",
run: () => {
@@ -274,7 +300,7 @@ export function CommandCenterRootActions() {
id: "keyboard-shortcuts",
group: "actions",
groupRank: 0,
rank: 6,
rank: 7,
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
visibility: "always",
run: () => setShortcutsDialogOpen(true),
@@ -309,6 +335,7 @@ export function CommandCenterRootActions() {
homeRoute,
keyboardActionDispatcher,
openAddProject,
openImportSession,
overrides,
schedulesRoute,
sessionsRoute,
@@ -322,5 +349,5 @@ export function CommandCenterRootActions() {
]);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null;
return importSessionSheet;
}
@@ -462,7 +462,6 @@ function ChangedFilesPane({
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
modeScope="compact-explorer"
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
state={changesState}
@@ -4,10 +4,16 @@ import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
buildProviderLabelMap,
collectErroredProviderLabels,
collectProviderErrorRows,
computeEmptyState,
formatDirectoryLabel,
getPromptPreview,
getSessionTitle,
hasMoreSessions,
nextPageLimit,
PER_PROVIDER_LIMIT,
resolveDirectoryLabel,
resolveImportTarget,
resolveProvidersToFetch,
requiresImportSessionsHostUpgrade,
type SessionsQueryResult,
@@ -167,27 +173,176 @@ describe("sumFilteredAlreadyImportedCount", () => {
});
});
describe("collectErroredProviderLabels", () => {
it("returns no labels when no providers are being fetched", () => {
expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
describe("collectProviderErrorRows", () => {
it("returns no rows when no providers are being fetched", () => {
expect(collectProviderErrorRows(null, [], new Map())).toEqual([]);
});
it("returns labels for each errored provider, falling back to provider id", () => {
const labels = collectErroredProviderLabels(
it("returns a row for each provider whose request failed", () => {
const rows = collectProviderErrorRows(
["claude", "codex"],
[settled(undefined, { isError: true }), settled({ entries: [] })],
new Map([["claude", "Claude Code"]]),
);
expect(labels).toEqual(["Claude Code"]);
expect(rows).toEqual([{ provider: "claude", label: "Claude Code" }]);
});
it("returns a row for a provider the daemon reported as failed in a good response", () => {
const rows = collectProviderErrorRows(
["codex"],
[
settled({
entries: [],
providerErrors: [{ provider: "codex", message: "timed out" }],
}),
],
new Map([["codex", "Codex"]]),
);
expect(rows).toEqual([{ provider: "codex", label: "Codex" }]);
});
it("uses provider id when the label map has no entry", () => {
const labels = collectErroredProviderLabels(
const rows = collectProviderErrorRows(
["codex"],
[settled(undefined, { isError: true })],
new Map(),
);
expect(labels).toEqual(["codex"]);
expect(rows).toEqual([{ provider: "codex", label: "codex" }]);
});
});
describe("nextPageLimit", () => {
it("grows the per-provider limit one step at a time", () => {
expect(nextPageLimit(PER_PROVIDER_LIMIT)).toBe(45);
expect(nextPageLimit(45)).toBe(90);
expect(nextPageLimit(90)).toBe(200);
});
it("stops at the protocol ceiling", () => {
expect(nextPageLimit(200)).toBe(200);
});
});
describe("hasMoreSessions", () => {
it("reports more when a provider filled the requested page", () => {
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 2)).toBe(true);
});
it("reports no more once every provider returned a short page", () => {
expect(hasMoreSessions([settled({ entries: [entry()] })], 2)).toBe(false);
});
it("reports no more at the last page size, however full the page is", () => {
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 200)).toBe(false);
});
it("keeps reporting more while the next page replaces placeholder rows", () => {
expect(
hasMoreSessions([settled({ entries: [entry()] }, { isPlaceholderData: true })], 45),
).toBe(true);
});
});
describe("resolveDirectoryLabel", () => {
const projects = [
{ rootPath: "/home/me/paseo", name: "paseo" },
{ rootPath: "/home/me/paseo/packages/app", name: "paseo app" },
];
it("names the project root after the project alone", () => {
expect(resolveDirectoryLabel("/home/me/paseo", projects)).toEqual({ name: "paseo" });
});
it("qualifies a worktree under the project with its path below the root", () => {
expect(
resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]),
).toEqual({ name: "paseo", detail: ".dev/worktrees/abc/zebra" });
});
it("tells two worktrees of the same project apart", () => {
const zebra = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]);
const otter = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/def/otter", [projects[0]!]);
expect(zebra).not.toEqual(otter);
expect([zebra.detail, otter.detail]).toEqual([
".dev/worktrees/abc/zebra",
".dev/worktrees/def/otter",
]);
});
it("picks the most specific project root containing the directory", () => {
expect(resolveDirectoryLabel("/home/me/paseo/packages/app/src", projects)).toEqual({
name: "paseo app",
detail: "src",
});
});
it("ignores trailing slashes on both sides", () => {
expect(
resolveDirectoryLabel("/home/me/paseo/", [{ rootPath: "/home/me/paseo/", name: "p" }]),
).toEqual({ name: "p" });
});
it("does not match a project root that is only a string prefix", () => {
expect(resolveDirectoryLabel("/home/me/paseo-fork", projects)).toEqual({
name: "/home/me/paseo-fork",
});
});
it("falls back to the path when no project owns the directory", () => {
expect(resolveDirectoryLabel("/tmp/scratch", projects)).toEqual({ name: "/tmp/scratch" });
});
});
describe("formatDirectoryLabel", () => {
it("shows the project name alone at its root", () => {
expect(formatDirectoryLabel({ name: "paseo" })).toBe("paseo");
});
it("appends the path under the root so worktrees of one project read apart", () => {
expect(formatDirectoryLabel({ name: "paseo", detail: ".dev/worktrees/zebra" })).toBe(
"paseo · .dev/worktrees/zebra",
);
});
});
describe("resolveImportTarget", () => {
it("trusts a scoped listing, whose rows the daemon already matched realpath-aware", () => {
expect(
resolveImportTarget({
entryCwd: "/private/repo/paseo",
workspaceCwd: "/repo/paseo",
workspaceId: "ws-1",
isScopedListing: true,
}),
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
});
it("keeps the current workspace for a Show-all row in that workspace's directory", () => {
expect(
resolveImportTarget({
entryCwd: "/repo/paseo/",
workspaceCwd: "/repo/paseo",
workspaceId: "ws-1",
isScopedListing: false,
}),
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
});
it("drops the workspace for a Show-all row from another directory, which the daemon rejects", () => {
expect(
resolveImportTarget({
entryCwd: "/repo/other",
workspaceCwd: "/repo/paseo",
workspaceId: "ws-1",
isScopedListing: false,
}),
).toEqual({ crossWorkspace: true });
});
it("treats a sheet with no workspace as cross-workspace", () => {
expect(resolveImportTarget({ entryCwd: "/repo/paseo", isScopedListing: false })).toEqual({
crossWorkspace: true,
});
});
});
@@ -236,6 +391,7 @@ describe("computeEmptyState", () => {
isQueryingProviders: true,
allQueriesSettled: true,
selectedProvider: ALL_FILTER_VALUE,
hasQuery: false,
aggregatedCount: 0,
visibleCount: 0,
totalAlreadyImportedCount: 0,
@@ -274,6 +430,15 @@ describe("computeEmptyState", () => {
});
});
it("says nothing matched when a query narrowed the list to nothing", () => {
const result = computeEmptyState({
...baseInputs,
hasQuery: true,
totalAlreadyImportedCount: 4,
});
expect(result.emptyStateTitle).toBe("No sessions match your search.");
});
it("shows the already-imported message when imported entries were filtered out", () => {
const result = computeEmptyState({
...baseInputs,
@@ -5,6 +5,18 @@ import { i18n } from "@/i18n/i18next";
export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__";
/**
* Paging grows the per-provider limit instead of carrying an offset. The daemon
* ranks the whole candidate set on every request, so an offset into a previous
* ranking would drift as sessions move; refetching a larger page cannot. 200 is
* the protocol ceiling on `limit`.
*/
const PAGE_LIMITS: readonly number[] = [PER_PROVIDER_LIMIT, 45, 90, 200];
export function nextPageLimit(limit: number): number {
return PAGE_LIMITS.find((candidate) => candidate > limit) ?? limit;
}
export function requiresImportSessionsHostUpgrade(input: {
supportsSnapshot: boolean;
workspaceId?: string | null;
@@ -18,11 +30,13 @@ export interface SessionsQueryResult {
| {
entries: FetchRecentProviderSessionEntry[];
filteredAlreadyImportedCount?: number;
providerErrors?: Array<{ provider: string; message: string }>;
}
| undefined;
isError: boolean;
isLoading: boolean;
isPending: boolean;
isPlaceholderData?: boolean;
}
export function resolveProvidersToFetch(
@@ -81,20 +95,132 @@ export function sumFilteredAlreadyImportedCount(
return total;
}
export function collectErroredProviderLabels(
/**
* A provider that returned a full page may have more behind it. The daemon
* slices to `limit` after dropping already-imported sessions, so a short page is
* the end of that provider's list. Placeholder rows belong to the previous page,
* so the button holds its place instead of blinking out while the next loads.
*/
export function hasMoreSessions(
queries: ReadonlyArray<SessionsQueryResult>,
limit: number,
): boolean {
if (nextPageLimit(limit) === limit) return false;
return queries.some((entryQuery) => {
const count = entryQuery.data?.entries.length ?? 0;
return entryQuery.isPlaceholderData ? count > 0 : count >= limit;
});
}
export interface ProviderErrorRow {
provider: string;
label: string;
}
/**
* One row per provider that could not be listed, whether the request itself
* failed or the daemon reported the provider as failed in an otherwise good
* response. Both mean the same thing to the user, and both retry the same way.
*/
export function collectProviderErrorRows(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): string[] {
): ProviderErrorRow[] {
if (providersToFetch === null) return [];
const labels: string[] = [];
for (let index = 0; index < queries.length; index++) {
if (queries[index]?.isError) {
const provider = providersToFetch[index];
labels.push(providerLabelById.get(provider) ?? provider);
}
const rows: ProviderErrorRow[] = [];
for (let index = 0; index < providersToFetch.length; index++) {
const query = queries[index];
const provider = providersToFetch[index];
if (!query || provider === undefined) continue;
const failed = query.isError || (query.data?.providerErrors?.length ?? 0) > 0;
if (!failed) continue;
rows.push({ provider, label: providerLabelById.get(provider) ?? provider });
}
return labels;
return rows;
}
export interface DirectoryProject {
rootPath: string;
name: string;
}
export interface DirectoryLabel {
/** The owning project's name, or the whole path when no project owns it. */
name: string;
/**
* The directory's path under its project root, absent at the root itself.
* Worktrees of one project all resolve to the same name, so without this the
* sheet would show several identical headings.
*/
detail?: string;
}
function withoutTrailingSlash(path: string): string {
return path.length > 1 ? path.replace(/\/+$/, "") : path;
}
/** The project's name when the app knows the directory, else the directory itself. */
export function resolveDirectoryLabel(
directory: string,
projects: ReadonlyArray<DirectoryProject>,
): DirectoryLabel {
const normalized = withoutTrailingSlash(directory);
let bestRoot: string | null = null;
let bestName: string | null = null;
for (const project of projects) {
const root = withoutTrailingSlash(project.rootPath);
if (!root) continue;
if (bestRoot !== null && root.length <= bestRoot.length) continue;
if (normalized !== root && !normalized.startsWith(`${root}/`)) continue;
bestRoot = root;
bestName = project.name;
}
if (bestRoot === null || bestName === null) {
return { name: normalized };
}
const relative = normalized.slice(bestRoot.length + 1);
return relative ? { name: bestName, detail: relative } : { name: bestName };
}
/** The one-line form a row shows under its prompt preview. */
export function formatDirectoryLabel(label: DirectoryLabel): string {
return label.detail ? `${label.name} · ${label.detail}` : label.name;
}
export interface ImportTarget {
/**
* Omitted unless the row's directory is the scoped workspace's own: the daemon
* rejects an import whose cwd does not match the requested workspace.
*/
workspaceId?: string;
/**
* The agent lands in a workspace other than the one the sheet was opened from,
* so the caller has to open that workspace instead of adding a tab here.
*/
crossWorkspace: boolean;
}
export function resolveImportTarget(input: {
entryCwd: string;
/** The directory the sheet was opened for, absent when it was opened host-wide. */
workspaceCwd?: string | null;
workspaceId?: string | null;
/** The listed rows came from a fetch scoped to `workspaceCwd`. */
isScopedListing: boolean;
}): ImportTarget {
if (!input.workspaceId || !input.workspaceCwd) {
return { crossWorkspace: true };
}
// A scoped listing only holds rows the daemon already matched to this
// workspace's directory with realpaths resolved, which the client cannot do.
// "Show all" drops that guarantee, so each row is compared by path instead.
const belongsToWorkspace =
input.isScopedListing ||
withoutTrailingSlash(input.entryCwd) === withoutTrailingSlash(input.workspaceCwd);
return belongsToWorkspace
? { workspaceId: input.workspaceId, crossWorkspace: false }
: { crossWorkspace: true };
}
export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
@@ -123,6 +249,7 @@ export interface EmptyStateInputs {
isQueryingProviders: boolean;
allQueriesSettled: boolean;
selectedProvider: string;
hasQuery: boolean;
aggregatedCount: number;
visibleCount: number;
totalAlreadyImportedCount: number;
@@ -142,6 +269,11 @@ export function computeEmptyState(input: EmptyStateInputs): {
if (!showEmptyState) {
return { showEmptyState, emptyStateTitle: "" };
}
// A query narrowing the list to nothing is a different answer from a host with
// nothing to import, and the recovery is different too.
if (input.hasQuery) {
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noMatches") };
}
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
if (isFilteredEmpty) {
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
@@ -14,12 +14,12 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 },
spacing: { 1: 4, 1.5: 6, 2: 8, 2.5: 10, 3: 12, 4: 16, 6: 24, 8: 32 },
borderWidth: { 1: 1 },
borderRadius: { md: 6, lg: 8, full: 9999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
iconSize: { sm: 14, md: 16 },
iconSize: { sm: 14, md: 16, lg: 24 },
opacity: { 50: 0.5 },
colors: {
foreground: "#fff",
@@ -30,6 +30,8 @@ const { theme } = vi.hoisted(() => ({
surface3: "#333",
border: "#444",
borderAccent: "#555",
interactionHighlight: "rgba(255,255,255,0.08)",
palette: { red: { 300: "#f87171" } },
},
},
}));
@@ -75,6 +77,8 @@ vi.mock("lucide-react-native", () => {
Inbox: icon("Inbox"),
Layers: icon("Layers"),
RotateCw: icon("RotateCw"),
Search: icon("Search"),
X: icon("X"),
};
});
@@ -117,6 +121,22 @@ vi.mock("@/components/ui/combobox", () => ({
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
}));
interface SheetSearchProps {
onChange: (value: string) => void;
placeholder?: string;
testID?: string;
}
function SheetSearchInput({ search }: { search: SheetSearchProps }) {
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => search.onChange(event.target.value),
[search],
);
return (
<input data-testid={search.testID} placeholder={search.placeholder} onChange={handleChange} />
);
}
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
@@ -125,14 +145,21 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
testID,
}: {
visible: boolean;
header?: { title: string; actions?: ReactNode };
header?: {
title: string;
subtitle?: ReactNode;
actions?: ReactNode;
search?: { onChange: (value: string) => void; placeholder?: string; testID?: string };
};
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{header?.title}</h1>
{header?.subtitle}
{header?.actions}
{header?.search ? <SheetSearchInput search={header.search} /> : null}
{children}
</section>
) : null,
@@ -163,26 +190,62 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({
}),
}));
const mockHostFeatures = vi.hoisted(() => ({
current: { importSessionSearch: true, importSessionWorkspaceTarget: true } as Record<
string,
boolean
>,
}));
vi.mock("@/runtime/host-features", () => ({
useHostFeature: (_serverId: string | null, feature: string) =>
mockHostFeatures.current[feature] === true,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => [{ serverId: "server-1", label: "workbench" }],
}));
const mockHostProjects = vi.hoisted(() => ({
current: [] as Array<{ iconWorkingDir: string; projectName: string }>,
}));
vi.mock("@/projects/host-projects", () => ({
useHostProjects: () => mockHostProjects.current,
}));
interface RenderOptions {
visible?: boolean;
onClose?: () => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
cwd?: string | null;
workspaceId?: string;
supportsSearch?: boolean;
projects?: Array<{ iconWorkingDir: string; projectName: string }>;
snapshot?: {
entries?: ProviderSnapshotEntry[];
supportsSnapshot?: boolean;
};
}
function renderSheet(
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
options?: RenderOptions,
) {
function applyHostMocks(options?: RenderOptions) {
mockSnapshot.current = {
entries: options?.snapshot?.entries,
supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false,
};
mockHostFeatures.current = {
importSessionSearch: options?.supportsSearch ?? true,
importSessionWorkspaceTarget: true,
};
mockHostProjects.current = options?.projects ?? [];
}
function renderSheet(
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
options?: RenderOptions,
) {
applyHostMocks(options);
const queryClient = new QueryClient({
defaultOptions: {
@@ -200,6 +263,7 @@ function renderSheet(
client={client}
serverId="server-1"
cwd={cwd}
workspaceId={options?.workspaceId}
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
onImported={options?.onImported}
@@ -245,6 +309,16 @@ function createImportedAgentSnapshot(id: string): Awaited<ReturnType<DaemonClien
};
}
function createPageOfEntries(count: number): FetchRecentProviderSessionEntry[] {
return Array.from({ length: count }, (_unused, index) =>
createProviderSessionEntry({
providerId: "claude",
providerHandleId: `thread-${index}`,
title: `Session ${index}`,
}),
);
}
function createProviderSessionEntry(
overrides?: Partial<FetchRecentProviderSessionEntry>,
): FetchRecentProviderSessionEntry {
@@ -377,7 +451,8 @@ describe("ImportSessionSheet", () => {
},
);
await screen.findByText("Could not load recent sessions.");
await screen.findByText("Could not load Claude Code sessions");
screen.getByTestId("import-session-retry-claude");
});
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
@@ -478,19 +553,34 @@ describe("ImportSessionSheet", () => {
});
it("imports a selected session by provider handle and reports the imported agent", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/repo/paseo-realpath",
const events: string[] = [];
let fetchCount = 0;
const fetchRecentProviderSessions = vi.fn(async () => {
fetchCount += 1;
events.push("fetch");
if (fetchCount > 1) {
return await new Promise<never>(() => {});
}
return {
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/repo/paseo-realpath",
}),
],
};
});
let resolveImport!: (agent: ReturnType<typeof createImportedAgentSnapshot>) => void;
const importAgent = vi.fn(
() =>
new Promise<ReturnType<typeof createImportedAgentSnapshot>>((resolve) => {
resolveImport = resolve;
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onClose = vi.fn();
const onImportedAgent = vi.fn();
);
const onClose = vi.fn(() => events.push("close"));
const onImportedAgent = vi.fn(() => events.push("navigate"));
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
@@ -500,21 +590,31 @@ describe("ImportSessionSheet", () => {
{
onClose,
onImportedAgent,
workspaceId: "ws-current",
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
const row = await screen.findByTestId("import-session-session-claude-provider-thread-1");
expect(events).toEqual(["fetch"]);
fireEvent.click(row);
await screen.findByText("Importing...");
expect(events).toEqual(["fetch"]);
resolveImport(createImportedAgentSnapshot("agent-imported"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/repo/paseo-realpath",
workspaceId: "ws-current",
});
expect(events).toEqual(["fetch", "close", "navigate"]);
});
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onClose).toHaveBeenCalledTimes(1);
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
});
it("shows an import error state without closing when selected session import fails", async () => {
@@ -649,7 +749,7 @@ describe("ImportSessionSheet", () => {
);
await screen.findByText("Session codex");
await screen.findByText("Could not load sessions for Claude Code.");
await screen.findByText("Could not load Claude Code sessions");
});
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
@@ -758,7 +858,7 @@ describe("ImportSessionSheet", () => {
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
it("omits cwd from fetch and names each row's folder when cwd is unset", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
@@ -793,6 +893,9 @@ describe("ImportSessionSheet", () => {
expect.objectContaining({ cwd: expect.anything() }),
);
await screen.findByText("/home/me/work/other-project");
expect(
screen.getByTestId("import-session-row-folder-claude-provider-thread-1").textContent,
).toBe("/home/me/work/other-project");
});
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
@@ -836,10 +939,344 @@ describe("ImportSessionSheet", () => {
});
expect(onImported).toHaveBeenCalledTimes(1);
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onImportedAgent).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("names the scope after the host when unscoped and after the workspace when scoped", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [],
}));
const importAgent = vi.fn();
const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent);
renderSheet(client, {
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
});
await screen.findByText("Sessions on workbench");
expect(screen.queryByTestId("import-session-show-all")).toBeNull();
cleanup();
renderSheet(client, {
workspaceId: "ws-current",
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
});
await screen.findByText("This workspace");
screen.getByTestId("import-session-show-all");
});
it("lists unscoped rows newest first and names each row's folder", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerHandleId: "newest",
cwd: "/home/me/paseo",
title: "Newest in paseo",
lastActivityAt: "2026-04-30T12:00:00.000Z",
}),
createProviderSessionEntry({
providerId: "claude",
providerHandleId: "elsewhere",
cwd: "/tmp/scratch",
title: "Scratch session",
lastActivityAt: "2026-04-30T11:00:00.000Z",
}),
createProviderSessionEntry({
providerId: "claude",
providerHandleId: "older",
cwd: "/home/me/paseo",
title: "Older in paseo",
lastActivityAt: "2026-04-30T10:00:00.000Z",
}),
createProviderSessionEntry({
providerId: "claude",
providerHandleId: "worktree",
cwd: "/home/me/paseo/.dev/worktrees/zebra",
title: "Worktree session",
lastActivityAt: "2026-04-30T09:00:00.000Z",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
projects: [{ iconWorkingDir: "/home/me/paseo", projectName: "paseo" }],
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByTestId("import-session-session-claude-newest");
expect(
screen
.getAllByTestId(/^import-session-session-/)
.map((row) => row.getAttribute("data-testid")),
).toEqual([
"import-session-session-claude-newest",
"import-session-session-claude-elsewhere",
"import-session-session-claude-older",
"import-session-session-claude-worktree",
]);
expect(
screen.getAllByTestId(/^import-session-row-folder-/).map((folder) => folder.textContent),
).toEqual(["paseo", "/tmp/scratch", "paseo", "paseo · .dev/worktrees/zebra"]);
});
it("leaves the folder off every row when the sheet is scoped to one workspace", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerHandleId: "scoped",
cwd: "/repo/paseo",
title: "Scoped session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
workspaceId: "ws-current",
projects: [{ iconWorkingDir: "/repo/paseo", projectName: "paseo" }],
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Scoped session");
expect(screen.queryAllByTestId(/^import-session-row-folder-/)).toHaveLength(0);
});
it("sends the debounced search query to every provider", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
fireEvent.change(screen.getByTestId("import-session-search"), {
target: { value: "invoice" },
});
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 15,
query: "invoice",
});
});
});
it("hides the search field when the host cannot search sessions", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
supportsSearch: false,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("import-session-search")).toBeNull();
});
it("asks for a larger page when Load more is pressed, and stops offering it on a short page", async () => {
const fetchRecentProviderSessions = vi.fn(async (options: { limit?: number } | undefined) => ({
requestId: "recent-provider-sessions",
entries: createPageOfEntries(options?.limit === 15 ? 15 : 20),
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("import-session-load-more"));
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 45,
});
});
await waitFor(() => {
expect(screen.queryByTestId("import-session-load-more")).toBeNull();
});
});
it("stops the refresh spinner and retries only the failed provider", async () => {
const fetchRecentProviderSessions = vi.fn(
async (options: { providers?: string[] } | undefined) => {
const provider = options?.providers?.[0];
if (provider === "claude") {
throw new Error("claude offline");
}
return {
requestId: `recent-${provider}`,
entries: [
createProviderSessionEntry({
providerId: provider ?? "codex",
providerHandleId: `${provider}-thread`,
title: `Session ${provider}`,
}),
],
};
},
);
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: {
supportsSnapshot: true,
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
},
},
);
await screen.findByText("Session codex");
await screen.findByText("Could not load Claude Code sessions");
await waitFor(() => {
expect(screen.queryByTestId("import-session-loading-spinner")).toBeNull();
});
fetchRecentProviderSessions.mockClear();
fireEvent.click(screen.getByTestId("import-session-retry-claude"));
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
cwd: "/repo/paseo",
providers: ["claude"],
limit: 15,
});
});
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
});
it("shows an error row for a provider the daemon reported as failed", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })],
providerErrors: [{ provider: "codex", message: "timed out" }],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("codex")] },
},
);
await screen.findByText("Could not load Codex sessions");
});
it("imports a foreign-directory row without the current workspace once Show all is on", async () => {
const fetchRecentProviderSessions = vi.fn(async (options: { cwd?: string } | undefined) => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerHandleId: options?.cwd ? "scoped-thread" : "foreign-thread",
cwd: options?.cwd ?? "/home/me/work/other-project",
title: options?.cwd ? "Scoped session" : "Foreign session",
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onImported = vi.fn();
const onImportedAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
workspaceId: "ws-current",
onImported,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Scoped session");
fireEvent.click(screen.getByTestId("import-session-show-all"));
await screen.findByText("Foreign session");
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 15,
});
fireEvent.click(screen.getByTestId("import-session-session-claude-foreign-thread"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "foreign-thread",
cwd: "/home/me/work/other-project",
});
});
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
expect(onImportedAgent).not.toHaveBeenCalled();
});
it("refetches sessions when the refresh button is clicked", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type {
DaemonClient,
@@ -10,22 +10,33 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostProjects } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import { useHosts } from "@/runtime/host-runtime";
import { i18n } from "@/i18n/i18next";
import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
buildProviderLabelMap,
collectErroredProviderLabels,
collectProviderErrorRows,
computeEmptyState,
type DirectoryProject,
formatDirectoryLabel,
getPromptPreview,
getSessionTitle,
hasMoreSessions,
resolveDirectoryLabel,
nextPageLimit,
PER_PROVIDER_LIMIT,
type ProviderErrorRow,
resolveImportTarget,
resolveProvidersToFetch,
requiresImportSessionsHostUpgrade,
sumFilteredAlreadyImportedCount,
@@ -33,6 +44,8 @@ import {
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
/** Long enough that a typed word is one request, short enough to feel live. */
const SEARCH_DEBOUNCE_MS = 200;
type RecentProviderSessionsClient = Pick<
DaemonClient,
@@ -48,7 +61,9 @@ interface ImportSessionSheetProps {
cwd?: string | null;
workspaceId?: string | null;
onClose: () => void;
/** The agent belongs to the workspace the sheet was opened from; open it here. */
onImportedAgent?: (agentId: string) => void;
/** The agent belongs to its own workspace; open that workspace and navigate. */
onImported?: (agent: ImportedAgent) => void;
}
@@ -56,27 +71,51 @@ type RecentSessionsResponse = Awaited<
ReturnType<RecentProviderSessionsClient["fetchRecentProviderSessions"]>
>;
type SessionsQueryKey = ReadonlyArray<string | number | null>;
function buildSessionsQueryKey(input: {
cwd: string | null;
query: string;
limit: number;
provider?: string;
}): SessionsQueryKey {
return [
"recent-provider-sessions",
input.cwd,
input.query,
input.limit,
...(input.provider === undefined ? [] : [input.provider]),
];
}
interface SessionsQueryConfig {
queryKey: ReadonlyArray<string | null>;
queryKey: SessionsQueryKey;
enabled: boolean;
// A provider that cannot answer stays broken until the user asks again. React
// Query's default retry would keep the header spinner turning and pile up
// in-flight daemon requests behind a dead provider (#2512).
retry: false;
placeholderData: typeof keepPreviousData;
queryFn: () => Promise<RecentSessionsResponse>;
}
function buildSessionsQueriesConfig(args: {
providersToFetch: AgentProvider[] | null;
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
cwd: string | null | undefined;
cwd: string | null;
query: string;
limit: number;
hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
args;
const { providersToFetch, visible, client, cwd, query, limit, hostDisconnectedMessage } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
queryKey: buildSessionsQueryKey({ cwd, query, limit, provider }),
enabled,
retry: false as const,
placeholderData: keepPreviousData,
queryFn: async () => {
if (!client) {
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
@@ -84,7 +123,8 @@ function buildSessionsQueriesConfig(args: {
return await client.fetchRecentProviderSessions({
...(cwd ? { cwd } : {}),
providers: [provider],
limit: PER_PROVIDER_LIMIT,
limit,
...(query ? { query } : {}),
});
},
}));
@@ -96,8 +136,6 @@ interface SheetStatusMessagesProps {
hasNoImportableProviders: boolean;
isLoadingSessions: boolean;
hasRows: boolean;
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
}
@@ -107,8 +145,6 @@ function SheetStatusMessages({
hasNoImportableProviders,
isLoadingSessions,
hasRows,
allQueriesErrored,
erroredProviderLabels,
importErrored,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
@@ -130,16 +166,6 @@ function SheetStatusMessages({
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
</View>
) : null}
{allQueriesErrored ? (
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
) : null}
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
<Text style={styles.statusText}>
{t("importSession.status.failedProviders", {
providers: erroredProviderLabels.join(", "),
})}
</Text>
) : null}
{importErrored ? (
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
) : null}
@@ -147,6 +173,48 @@ function SheetStatusMessages({
);
}
function ProviderErrorBanner({
rows,
onRetry,
}: {
rows: ReadonlyArray<ProviderErrorRow>;
onRetry: (provider: string) => void;
}) {
return (
<View style={styles.errorBanner} testID="import-session-provider-errors">
{rows.map((row) => (
<ProviderErrorBannerRow key={row.provider} row={row} onRetry={onRetry} />
))}
</View>
);
}
function ProviderErrorBannerRow({
row,
onRetry,
}: {
row: ProviderErrorRow;
onRetry: (provider: string) => void;
}) {
const { t } = useTranslation();
const handleRetry = useCallback(() => onRetry(row.provider), [onRetry, row.provider]);
return (
<View style={styles.errorRow}>
<Text style={styles.errorText}>
{t("importSession.status.failedProvider", { provider: row.label })}
</Text>
<Button
variant="ghost"
size="xs"
onPress={handleRetry}
testID={`import-session-retry-${row.provider}`}
>
{t("common.actions.retry")}
</Button>
</View>
);
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -177,6 +245,38 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
);
}
function ScopeSubtitle({
hostLabel,
isScoped,
onShowAll,
}: {
hostLabel: string;
isScoped: boolean;
onShowAll: () => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.subtitleRow}>
<Text style={styles.subtitleText} numberOfLines={1} testID="import-session-scope">
{isScoped
? t("importSession.scope.workspace")
: t("importSession.scope.host", { host: hostLabel })}
</Text>
{isScoped ? (
<Button
variant="ghost"
size="xs"
onPress={onShowAll}
testID="import-session-show-all"
style={styles.subtitleAction}
>
{t("importSession.actions.showAll")}
</Button>
) : null}
</View>
);
}
function SheetEmptyState({ title }: { title: string }) {
const { theme } = useUnistyles();
return (
@@ -193,13 +293,14 @@ function ImportSessionSheetRow({
entry,
disabled,
importing,
showCwd,
folder,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
showCwd: boolean;
/** The row's directory, shown only when rows can come from more than one. */
folder: string | null;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
@@ -248,9 +349,13 @@ function ImportSessionSheetRow({
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
</Text>
{showCwd && entry.cwd ? (
<Text style={styles.rowCwd} numberOfLines={1}>
{entry.cwd}
{folder ? (
<Text
style={styles.rowFolder}
numberOfLines={1}
testID={`import-session-row-folder-${entry.providerId}-${entry.providerHandleId}`}
>
{folder}
</Text>
) : null}
</View>
@@ -258,6 +363,35 @@ function ImportSessionSheetRow({
);
}
function SessionRows({
entries,
disabled,
importingSessionKey,
resolveFolder,
onImportSession,
}: {
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
disabled: boolean;
importingSessionKey: string | null;
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
return (
<View style={styles.list}>
{entries.map((entry) => (
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={disabled}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
))}
</View>
);
}
export function ImportSessionSheet({
visible,
client,
@@ -272,8 +406,32 @@ export function ImportSessionSheet({
const queryClient = useQueryClient();
const { theme } = useUnistyles();
// "Show all" widens a workspace-scoped sheet to the whole host. The sheet's own
// `cwd` stays the scope it was opened with, because that is what decides where
// an imported agent lands.
const [isShowingAllDirectories, setIsShowingAllDirectories] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [pageLimit, setPageLimit] = useState(PER_PROVIDER_LIMIT);
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
const scopeCwd = isShowingAllDirectories ? null : (cwd ?? null);
const supportsSearch = useHostFeature(serverId, "importSessionSearch");
const query = useDebouncedValue(supportsSearch ? searchInput : "", SEARCH_DEBOUNCE_MS).trim();
useEffect(() => {
if (visible) return;
setIsShowingAllDirectories(false);
setSearchInput("");
}, [visible]);
// A narrower or wider list starts at page one; keeping a grown limit would
// fetch 200 rows for a query that matches three.
useEffect(() => {
setPageLimit(PER_PROVIDER_LIMIT);
}, [query, scopeCwd]);
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd,
cwd: scopeCwd,
enabled: visible,
});
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
@@ -293,22 +451,20 @@ export function ImportSessionSheet({
[snapshotEntries],
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
);
const sessionsQueryRoot = useMemo(() => ["recent-provider-sessions", scopeCwd], [scopeCwd]);
const queriesConfig = useMemo(
() =>
buildSessionsQueriesConfig({
providersToFetch,
sessionsQueryRoot,
visible,
client,
cwd,
cwd: scopeCwd,
query,
limit: pageLimit,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
[providersToFetch, visible, client, scopeCwd, query, pageLimit, t],
);
const queries = useQueries({ queries: queriesConfig });
@@ -320,8 +476,6 @@ export function ImportSessionSheet({
);
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
@@ -339,6 +493,27 @@ export function ImportSessionSheet({
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
}, [aggregatedEntries, selectedProvider]);
const projectServerIds = useMemo(() => (serverId ? [serverId] : []), [serverId]);
const hostProjects = useHostProjects(projectServerIds);
const directoryProjects = useMemo<DirectoryProject[]>(
() =>
hostProjects.map((project) => ({
rootPath: project.iconWorkingDir,
name: project.projectName,
})),
[hostProjects],
);
// A scoped sheet only lists one directory, so naming it on every row is noise.
const showRowFolders = scopeCwd === null;
const resolveFolder = useCallback(
(entry: FetchRecentProviderSessionEntry) =>
showRowFolders
? formatDirectoryLabel(resolveDirectoryLabel(entry.cwd, directoryProjects))
: null,
[showRowFolders, directoryProjects],
);
const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => [
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
@@ -414,19 +589,31 @@ export function ImportSessionSheet({
if (!entry.cwd) {
throw new Error("Session is missing a working directory");
}
const target = resolveImportTarget({
entryCwd: entry.cwd,
workspaceCwd: cwd,
workspaceId,
isScopedListing: scopeCwd !== null,
});
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: entry.cwd,
...(workspaceId ? { workspaceId } : {}),
...(target.workspaceId ? { workspaceId: target.workspaceId } : {}),
});
return agent;
return { agent, target };
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onSuccess: ({ agent, target }) => {
onClose();
onImportedAgent?.(agent.id);
onImported?.(agent);
if (target.crossWorkspace) {
onImported?.(agent);
} else {
onImportedAgent?.(agent.id);
}
void queryClient.invalidateQueries({
queryKey: sessionsQueryRoot,
refetchType: "none",
});
},
});
@@ -442,23 +629,61 @@ export function ImportSessionSheet({
[importMutation],
);
const erroredProviderLabels = useMemo(
() => collectErroredProviderLabels(providersToFetch, queries, providerLabelById),
const providerErrorRows = useMemo(
() => collectProviderErrorRows(providersToFetch, queries, providerLabelById),
[queries, providersToFetch, providerLabelById],
);
const isRefreshing = queries.some((query) => query.isFetching);
// Every query settles, errors included, so this stops turning (#2512).
const isRefreshing = queries.some((providerQuery) => providerQuery.isFetching);
const handleRefresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
}, [queryClient, sessionsQueryRoot]);
const handleRetryProvider = useCallback(
(provider: string) => {
void queryClient.refetchQueries({
queryKey: buildSessionsQueryKey({ cwd: scopeCwd, query, limit: pageLimit, provider }),
});
},
[pageLimit, query, queryClient, scopeCwd],
);
const handleShowAll = useCallback(() => setIsShowingAllDirectories(true), []);
const handleLoadMore = useCallback(() => setPageLimit(nextPageLimit), []);
const hosts = useHosts();
const hostLabel = useMemo(
() => hosts.find((host) => host.serverId === serverId)?.label ?? serverId ?? "",
[hosts, serverId],
);
const header = useMemo<SheetHeader>(
() => ({
title: t("importSession.title"),
subtitle: (
<ScopeSubtitle
hostLabel={hostLabel}
isScoped={scopeCwd !== null}
onShowAll={handleShowAll}
/>
),
...(supportsSearch
? {
search: {
onChange: setSearchInput,
placeholder: t("importSession.searchPlaceholder"),
// The compact sheet keeps its content mounted while hidden, so the
// field has to be told to drop the text the state already dropped.
resetKey: visible ? "open" : "closed",
testID: "import-session-search",
},
}
: {}),
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh, t],
[handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible],
);
const isSnapshotUnsupported = requiresHostUpgrade;
@@ -467,22 +692,27 @@ export function ImportSessionSheet({
const isQueryingProviders = queries.length > 0;
const isLoadingSessions =
isWaitingForSnapshot ||
(isQueryingProviders && queries.some((query) => query.isLoading || query.isPending));
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
(isQueryingProviders &&
queries.some((providerQuery) => providerQuery.isLoading || providerQuery.isPending));
const allQueriesErrored =
isQueryingProviders && queries.every((providerQuery) => providerQuery.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
isQueryingProviders &&
queries.every((providerQuery) => !providerQuery.isLoading && !providerQuery.isPending);
const { showEmptyState, emptyStateTitle } = computeEmptyState({
isLoadingSessions,
allQueriesErrored,
isQueryingProviders,
allQueriesSettled,
selectedProvider,
hasQuery: query.length > 0,
aggregatedCount: aggregatedEntries.length,
visibleCount: visibleEntries.length,
totalAlreadyImportedCount,
providerLabelById,
});
const showFilter = filterProviders.length > 1;
const showLoadMore = hasMoreSessions(queries, pageLimit);
return (
<AdaptiveModalSheet
@@ -536,22 +766,30 @@ export function ImportSessionSheet({
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
showCwd={!cwd}
onImportSession={handleImportSession}
/>
))}
<SessionRows
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
) : null}
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
@@ -560,6 +798,21 @@ export function ImportSessionSheet({
}
const styles = StyleSheet.create((theme) => ({
subtitleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minWidth: 0,
},
subtitleText: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
subtitleAction: {
marginHorizontal: -theme.spacing[2],
},
filterTriggerWrap: {
paddingBottom: theme.spacing[2],
},
@@ -568,6 +821,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
maxWidth: "100%",
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
@@ -582,6 +836,8 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
@@ -589,6 +845,10 @@ const styles = StyleSheet.create((theme) => ({
list: {
gap: theme.spacing[1],
},
footer: {
paddingTop: theme.spacing[2],
alignItems: "center",
},
row: {
flexDirection: "row",
alignItems: "flex-start",
@@ -636,7 +896,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
lineHeight: 20,
},
rowCwd: {
rowFolder: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
@@ -650,6 +910,26 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
errorBanner: {
marginBottom: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
},
errorRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
errorText: {
flexShrink: 1,
minWidth: 0,
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
},
emptyState: {
alignItems: "center",
justifyContent: "center",
+59 -274
View File
@@ -1,16 +1,5 @@
import { router, usePathname } from "expo-router";
import {
CalendarClock,
FolderPlus,
GitBranch,
History,
Home,
Plus,
Search,
Server,
Settings,
X,
} from "lucide-react-native";
import { router } from "expo-router";
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -33,17 +22,16 @@ import {
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -54,28 +42,16 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsRoute,
} from "@/utils/host-routes";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -99,23 +75,19 @@ interface SidebarSharedProps {
toggleProjectCollapsed: (projectViewKey: string) => void;
handleRefresh: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleImportSession: () => void;
handleSettings: () => void;
labels: SidebarLabels;
newWorkspaceKeys: ShortcutKey[][] | null;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
addProject: string;
newWorkspace: string;
hosts: string;
home: string;
importSession: string;
settings: string;
searchHosts: string;
sessions: string;
schedules: string;
closeSidebar: string;
}
@@ -124,15 +96,11 @@ interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
active: boolean;
handleViewMore: () => void;
handleViewSchedules: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
@@ -174,6 +142,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenAddProject();
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
@@ -214,34 +183,18 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
openHostOverview(serverId);
}, []);
const handleHomeMobile = useCallback(() => {
const handleImportSessionMobile = useCallback(() => {
showMobileAgent();
router.push(buildOpenProjectRoute());
}, [showMobileAgent]);
openImportSession();
}, [openImportSession, showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
router.push(buildOpenProjectRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
router.push(buildSessionsRoute());
}, []);
const handleViewSchedulesNavigate = useCallback(() => {
router.push(buildSchedulesRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
newWorkspace: t("sidebar.actions.newWorkspace"),
hosts: t("sidebar.actions.hosts"),
home: t("sidebar.actions.home"),
importSession: t("importSession.title"),
settings: t("sidebar.actions.settings"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
schedules: t("sidebar.sections.schedules"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
@@ -265,45 +218,46 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
toggleProjectCollapsed,
handleRefresh,
labels,
newWorkspaceKeys,
};
if (isCompactLayout) {
return (
<RetainedPanelActivity active={active}>
<MobileSidebar
{...sharedProps}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
<>
<RetainedPanelActivity active={active}>
<MobileSidebar
{...sharedProps}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleImportSession={handleImportSessionMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
/>
</RetainedPanelActivity>
{importSessionSheet}
</>
);
}
return (
<RetainedPanelActivity active={active}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
active={active}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
<>
<RetainedPanelActivity active={active}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
active={active}
handleOpenProject={handleOpenProjectDesktop}
handleImportSession={openImportSession}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
/>
</RetainedPanelActivity>
{importSessionSheet}
</>
);
});
@@ -487,65 +441,10 @@ function IconTooltipContent({
);
}
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
label,
testID,
variant,
shortcutKeys,
onBeforeNavigate,
}: {
label: string;
testID: string;
variant: "header" | "compact";
shortcutKeys: ShortcutKey[][] | null;
onBeforeNavigate?: () => void;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(
activeWorkspaceServerId,
"workspaceMultiplicity",
);
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(
activeWorkspaceServerId
? buildNewWorkspaceRoute(
activeWorkspace && canUseActiveWorkspaceContext
? {
serverId: activeWorkspaceServerId,
sourceDirectory: activeWorkspace.projectRootPath,
projectId: activeWorkspace.projectId,
}
: { serverId: activeWorkspaceServerId },
)
: buildNewWorkspaceRoute(),
);
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={Plus}
label={label}
onPress={handlePress}
testID={testID}
variant={variant}
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarFooter({
theme,
handleOpenProject,
handleHome,
handleImportSession,
handleSettings,
labels,
handleAddHost,
@@ -553,12 +452,12 @@ function SidebarFooter({
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleHome: () => void;
handleImportSession: () => void;
handleSettings: () => void;
labels: {
addProject: string;
hosts: string;
home: string;
importSession: string;
settings: string;
searchHosts: string;
};
@@ -584,10 +483,10 @@ function SidebarFooter({
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
label={labels.home}
icon={Home}
onPress={handleImportSession}
testID="sidebar-import-session"
label={labels.importSession}
icon={Import}
theme={theme}
/>
<SidebarHelpMenu />
@@ -622,9 +521,8 @@ function MobileSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleOpenProject,
handleHome,
handleImportSession,
handleSettings,
labels,
handleAddHost,
@@ -632,25 +530,10 @@ function MobileSidebar({
insetsTop,
insetsBottom,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const handleViewMore = useCallback(() => {
closeSidebar();
handleViewMoreNavigate();
}, [closeSidebar, handleViewMoreNavigate]);
const handleViewSchedules = useCallback(() => {
closeSidebar();
handleViewSchedulesNavigate();
}, [closeSidebar, handleViewSchedulesNavigate]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
@@ -672,32 +555,7 @@ function MobileSidebar({
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View>
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
style={styles.mobileCloseButton}
@@ -737,6 +595,7 @@ function MobileSidebar({
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
@@ -746,7 +605,7 @@ function MobileSidebar({
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
@@ -774,23 +633,17 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleOpenProject,
handleHome,
handleImportSession,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
active,
handleViewMore,
handleViewSchedules,
}: DesktopSidebarProps) {
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
@@ -898,31 +751,7 @@ function DesktopSidebar({
) : (
<TitlebarDragRegion />
)}
<View style={sidebarHeaderGroupStyle}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems />
</View>
<SidebarNavRows style={sidebarHeaderGroupStyle} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
@@ -943,6 +772,7 @@ function DesktopSidebar({
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -952,7 +782,7 @@ function DesktopSidebar({
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
@@ -971,45 +801,10 @@ function DesktopSidebar({
}
function WorkspacesSectionHeader() {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton,
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
],
[],
);
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open command center"
testID="sidebar-command-center-search"
style={searchButtonStyle}
onPress={handleSearchPress}
>
{({ hovered, pressed }) => (
<Search
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
@@ -1075,16 +870,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[1],
},
workspacesHeaderIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
workspacesHeaderIconButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -54,6 +54,11 @@ const html = `<!doctype html>
margin: 0;
padding: 0;
background: transparent;
height: 100%;
}
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
#diagram {
height: 100%;
}
#diagram svg {
display: block;
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import type { DiagramColorScheme } from "./render-model";
import { useMermaidRenderModel } from "./use-render-model";
interface MermaidFullscreenViewerProps {
source: string;
colorScheme: DiagramColorScheme;
onClose: () => void;
}
// The diagram is already in the render cache, so the viewport knows its size before the
// fullscreen iframe has re-rendered it.
const FALLBACK_SIZE = { width: 240, height: 240 };
const VIEWER_FIT = { padding: SPACING[6] };
export function MermaidFullscreenViewer({
source,
colorScheme,
onClose,
}: MermaidFullscreenViewerProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source,
phase: "complete",
colorScheme,
});
const handleRendered = useCallback(
(message: MermaidRenderedMessage) => {
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
useEffect(() => {
function handleKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [onClose]);
// The Modal focus trap walks descendants and focuses the first one that takes it. Focus the
// content layer ourselves (this effect runs after the trap's) so keystrokes stay in this
// document instead of disappearing into a focused diagram iframe.
const contentLayerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
contentLayerRef.current?.focus();
}, []);
const visible = state.visible;
const contentSize = useMemo(
() => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE),
[visible],
);
const actions = useMemo(
() => [
{
icon: X,
label: t("common.actions.close"),
onPress: onClose,
testID: "mermaid-fullscreen-close",
},
],
[onClose, t],
);
const contentLayerStyle = useMemo(
() => [
styles.contentLayer,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
},
],
[insets.bottom, insets.left, insets.right, insets.top],
);
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<WindowChromeRootRegion corners="both">
<View style={styles.root}>
<View style={styles.backdrop} />
<View style={contentLayerStyle}>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */}
<div ref={contentLayerRef} style={focusLayerDomStyle} tabIndex={-1}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={contentSize}
fit={VIEWER_FIT}
minScale={1}
onPressOutsideContent={onClose}
style={styles.viewport}
testID="mermaid-fullscreen-viewport"
wheelActivation="always"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
</View>
</View>
</WindowChromeRootRegion>
</Modal>
);
}
const focusLayerDomStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
outline: "none",
};
const styles = StyleSheet.create((theme) => ({
root: { flex: 1, minHeight: 0, minWidth: 0 },
backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 },
contentLayer: { position: "absolute", inset: 0 },
viewport: { flex: 1 },
}));
@@ -1,104 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
import { Code, Workflow } from "lucide-react-native";
import { Code, Maximize2, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
height: React.CSSProperties["height"];
onRendered: (message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => void;
onRenderFailed: (revision: number) => void;
}
function MermaidIframeRuntime({
request,
height,
onRendered,
onRenderFailed,
}: MermaidIframeRuntimeProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const iframeStyle = useMemo<React.CSSProperties>(
() => ({
display: "block",
width: "100%",
height,
border: 0,
pointerEvents: "none",
background: "transparent",
}),
[height],
);
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) return;
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive: false,
};
target.postMessage(message, "*");
}, []);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = parseMermaidRuntimeMessage(event.data);
if (!message) return;
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
}
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onRenderFailed, onRendered, sendRequest]);
return (
<iframe
ref={iframeRef}
title=""
aria-hidden
sandbox="allow-scripts"
srcDoc={mermaidRuntimeHtml}
tabIndex={-1}
style={iframeStyle}
/>
);
}
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
@@ -118,16 +31,13 @@ function MermaidFenceHostImpl({
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const [showSource, setShowSource] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const showSourcePress = useCallback(() => setShowSource(true), []);
const showDiagramPress = useCallback(() => setShowSource(false), []);
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
(message: MermaidRenderedMessage) => {
setHasRuntimeContent(true);
rendered({
revision: message.revision,
@@ -149,8 +59,14 @@ function MermaidFenceHostImpl({
label: t("message.diagram.viewSource"),
onPress: showSourcePress,
},
{
icon: Maximize2,
label: t("message.diagram.fullscreen"),
onPress: openFullscreen,
testID: "mermaid-fullscreen",
},
],
[showSourcePress, t],
[openFullscreen, showSourcePress, t],
);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
@@ -162,7 +78,11 @@ function MermaidFenceHostImpl({
return { container: [margins, sourceContainerStyle], text };
}, [textStyle]);
const diagramStyle = useMemo(
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
const diagramSize = useMemo(
@@ -212,18 +132,40 @@ function MermaidFenceHostImpl({
>
<MermaidIframeRuntime
request={request}
height="100%"
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
colorScheme={visible.colorScheme}
onClose={closeFullscreen}
/>
) : null}
</>
);
}
const MEASURING_SIZE = { width: 240, height: 240 };
/**
* The toolbar overlays the top of the box (8px offset + 32px compact buttons) and the box clips
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const sourceContainerStyle: ViewStyle = { position: "relative" };
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
* replaces the height below. In the markdown column there is no free space to grow into, so the
* box collapses and the diagram is scaled down to fit a couple of pixels. Size it from the height.
*/
const containerStyle: ViewStyle = {
flexBasis: "auto",
flexGrow: 0,
flexShrink: 0,
overflow: "hidden",
position: "relative",
};
const measuringStyle: ViewStyle = {
position: "absolute",
left: 0,
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef } from "react";
import type { DiagramColorScheme, MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
export interface MermaidRenderedMessage {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
height: number;
width: number;
}
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
onRendered: (message: MermaidRenderedMessage) => void;
onRenderFailed: (revision: number) => void;
}
/** Sandboxed Mermaid renderer. Sizing and gestures belong to the surrounding viewport. */
export function MermaidIframeRuntime({
request,
onRendered,
onRenderFailed,
}: MermaidIframeRuntimeProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) return;
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive: false,
};
target.postMessage(message, "*");
}, []);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = parseMermaidRuntimeMessage(event.data);
if (!message) return;
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
}
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onRenderFailed, onRendered, sendRequest]);
// `inert` (not just tabIndex) because the Modal focus trap focuses descendants
// programmatically; a focused iframe swallows every keystroke, including Escape.
return (
<iframe
ref={iframeRef}
title=""
aria-hidden
inert
sandbox="allow-scripts"
srcDoc={mermaidRuntimeHtml}
tabIndex={-1}
style={iframeStyle}
/>
);
}
const iframeStyle: React.CSSProperties = {
display: "block",
width: "100%",
height: "100%",
border: 0,
pointerEvents: "none",
background: "transparent",
};
@@ -59,6 +59,41 @@ let pendingRender: MermaidRuntimeRenderMessage | null = null;
let isRendering = false;
let isDrainScheduled = false;
interface DiagramSize {
height: number;
width: number;
}
/**
* Mermaid emits `width="100%"` with an inline `max-width` in pixels, so the diagram renders at
* its natural width and never fills a larger box. The host sizes this frame, so let the diagram
* follow the frame in both directions instead.
*/
function stretchToFrame(svg: SVGSVGElement): void {
svg.style.maxWidth = "100%";
svg.style.maxHeight = "100%";
svg.style.width = "100%";
svg.style.height = "100%";
}
/**
* The reported size is the host's content size, and the host feeds it back as this frame's size.
* Measuring the rendered box would make that a loop: every re-render lands in a frame the last
* measurement shrank by the container's padding, so a streaming diagram ratchets down to nothing.
* The viewBox is the diagram's own size and doesn't move.
*/
function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSize {
const viewBox = svg?.viewBox.baseVal;
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { height: Math.ceil(viewBox.height), width: Math.ceil(viewBox.width) };
}
const rect = svg?.getBoundingClientRect();
return {
height: Math.ceil(rect?.height ?? host.scrollHeight),
width: Math.ceil(rect?.width ?? host.scrollWidth),
};
}
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
@@ -72,14 +107,18 @@ async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
return;
}
host.innerHTML = svg;
const rect = host.querySelector("svg")?.getBoundingClientRect();
const element = host.querySelector("svg");
if (element) {
stretchToFrame(element);
}
const size = measureDiagram(host, element);
sendToHost({
type: "rendered",
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
height: Math.ceil(rect?.height ?? host.scrollHeight),
width: Math.ceil(rect?.width ?? host.scrollWidth),
height: size.height,
width: size.width,
});
} catch {
if (message.revision === latestRevision) {
File diff suppressed because one or more lines are too long
@@ -29,9 +29,13 @@ function waitForRuntimeMessage(
});
}
async function mountRuntime(): Promise<HTMLIFrameElement> {
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
if (size) {
frame.style.width = `${size.width}px`;
frame.style.height = `${size.height}px`;
}
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
frame.srcdoc = mermaidRuntimeHtml;
document.body.append(frame);
@@ -40,6 +44,13 @@ async function mountRuntime(): Promise<HTMLIFrameElement> {
return frame;
}
function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } {
if (message.type !== "rendered") {
throw new Error(`Expected a rendered diagram, got ${message.type}`);
}
return { height: message.height, width: message.width };
}
function render(
frame: HTMLIFrameElement,
input: { revision: number; source: string },
@@ -85,6 +96,22 @@ describe("Mermaid sandbox runtime", () => {
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
});
/**
* The host sizes this frame from the reported size, so a size that depends on the frame feeds
* back: every re-render measures inside a frame the previous measurement already shrank by the
* container's padding, and a streaming diagram ratchets down to a few pixels.
*/
it("reports the same size whatever frame the host gives it", async () => {
const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]";
const narrowFrame = await mountRuntime({ height: 60, width: 60 });
const wideFrame = await mountRuntime({ height: 600, width: 900 });
const narrow = await render(narrowFrame, { revision: 1, source });
const wide = await render(wideFrame, { revision: 1, source });
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
@@ -18,6 +18,7 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
@@ -228,6 +228,7 @@ interface SidebarWorkspaceListProps {
onRefresh?: () => void;
onWorkspacePress?: () => void;
onAddProject?: () => void;
onImportSession?: () => void;
listFooterComponent?: ReactElement | null;
// Rendered inside the scroll area, below the Pinned section and above the workspace
// list. Holds the "Workspaces" section header so pinned items sit above it.
@@ -1879,6 +1880,7 @@ export function SidebarWorkspaceList({
onRefresh: _onRefresh,
onWorkspacePress,
onAddProject,
onImportSession,
listFooterComponent,
listHeaderComponent,
parentGestureRef,
@@ -1972,6 +1974,7 @@ export function SidebarWorkspaceList({
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
onAddProject={onAddProject}
onImportSession={onImportSession}
listFooterComponent={listFooterComponent}
listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty}
@@ -2067,6 +2070,7 @@ function ProjectModeList({
shortcutIndexByWorkspaceKey,
onWorkspacePress,
onAddProject,
onImportSession,
listFooterComponent,
listHeaderComponent,
sidebarFilterEmpty,
@@ -2375,7 +2379,7 @@ function ProjectModeList({
const projectBody =
projects.length === 0 ? (
<SidebarProjectEmptyState onAddProject={onAddProject} />
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
) : (
<DraggableList
testID="sidebar-project-list"
@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { Plus } from "lucide-react-native";
import { Import, Plus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -37,7 +37,13 @@ export function SidebarFilterEmptyState() {
);
}
export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () => void }) {
export function SidebarProjectEmptyState({
onAddProject,
onImportSession,
}: {
onAddProject?: () => void;
onImportSession?: () => void;
}) {
const { t } = useTranslation();
return (
@@ -47,6 +53,9 @@ export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () =
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="ghost" size="sm" leftIcon={Import} onPress={onImportSession}>
{t("importSession.title")}
</Button>
</View>
);
}
@@ -0,0 +1,178 @@
import { router, usePathname } from "expo-router";
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
import { memo, useCallback, useMemo, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { PluginSidebarItemRow } from "@/plugins/sidebar-items";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
builtinSidebarNavLabelKey,
builtinSidebarNavShortcutAction,
type BuiltinSidebarNavId,
} from "@/sidebar-nav/model";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import {
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
} from "@/utils/host-routes";
interface SidebarNavRowProps {
onBeforeNavigate?: () => void;
}
interface SidebarNavRowsProps extends SidebarNavRowProps {
/** Style for the group wrapper, which the sidebar owns. */
style?: StyleProp<ViewStyle>;
}
/**
* Top-level sidebar navigation, ordered and filtered by the user's
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
* wrapper — when every item is hidden.
*/
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
const { items } = useSidebarNavItems();
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
if (visibleItems.length === 0) return null;
return (
<View style={style}>
{visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItemRow
key={item.key}
group={item.group}
onBeforeNavigate={onBeforeNavigate}
/>
);
}
const Row = BUILTIN_ROWS[item.id];
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
})}
</View>
);
}
const SidebarNewWorkspaceRow = memo(function SidebarNewWorkspaceRow({
onBeforeNavigate,
}: SidebarNavRowProps) {
const { t } = useTranslation();
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("new-workspace"));
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(
activeWorkspaceServerId,
"workspaceMultiplicity",
);
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(
activeWorkspaceServerId
? buildNewWorkspaceRoute(
activeWorkspace && canUseActiveWorkspaceContext
? {
serverId: activeWorkspaceServerId,
sourceDirectory: activeWorkspace.projectRootPath,
projectId: activeWorkspace.projectId,
}
: { serverId: activeWorkspaceServerId },
)
: buildNewWorkspaceRoute(),
);
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={Plus}
label={t(builtinSidebarNavLabelKey("new-workspace"))}
onPress={handlePress}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
const { t } = useTranslation();
const pathname = usePathname();
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(buildSessionsRoute());
}, [onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={History}
label={t(builtinSidebarNavLabelKey("history"))}
onPress={handlePress}
isActive={pathname.includes("/sessions")}
testID="sidebar-sessions"
variant="compact"
/>
);
}
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
const { t } = useTranslation();
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
setCommandCenterOpen(true);
}, [onBeforeNavigate, setCommandCenterOpen]);
return (
<SidebarHeaderRow
icon={Search}
label={t(builtinSidebarNavLabelKey("search"))}
onPress={handlePress}
testID="sidebar-search"
variant="compact"
shortcutKeys={shortcutKeys}
/>
);
}
function SidebarSchedulesRow({ onBeforeNavigate }: SidebarNavRowProps) {
const { t } = useTranslation();
const pathname = usePathname();
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(buildSchedulesRoute());
}, [onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={CalendarClock}
label={t(builtinSidebarNavLabelKey("schedules"))}
onPress={handlePress}
isActive={pathname.includes("/schedules")}
testID="sidebar-schedules"
variant="compact"
/>
);
}
const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps>> = {
"new-workspace": SidebarNewWorkspaceRow,
history: SidebarHistoryRow,
search: SidebarSearchRow,
schedules: SidebarSchedulesRow,
};
+27 -19
View File
@@ -43,6 +43,7 @@ export interface ToastApi {
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
const TOAST_MAX_WIDTH = 480;
export function useToastHost(): {
api: ToastApi;
@@ -265,24 +266,26 @@ export function ToastViewport({
const content = (
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={toastAnimatedStyle}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text testID="app-toast-message" style={toastMessageStyle}>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
<View style={styles.widthBoundary} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={toastAnimatedStyle}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text testID="app-toast-message" style={toastMessageStyle}>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
</View>
</View>
);
@@ -302,9 +305,14 @@ const styles = StyleSheet.create((theme) => ({
zIndex: OVERLAY_Z.toast,
alignItems: "center",
},
widthBoundary: {
width: "92%",
maxWidth: TOAST_MAX_WIDTH,
alignItems: "center",
},
toast: {
alignSelf: "center",
maxWidth: "92%",
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
+54 -1
View File
@@ -83,6 +83,11 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import { usePluginClientSlashCommands } from "@/plugins/client-slash-commands";
import {
executePluginClientSlashCommand,
resolvePluginClientSlashCommand,
} from "@/plugins/client-slash-commands/model";
import {
useHostRuntimeAgentDirectoryStatus,
useHostRuntimeClient,
@@ -1268,6 +1273,11 @@ function ComposerContentImpl({
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const pluginClientSlashCommands = usePluginClientSlashCommands({
serverId,
workspaceId,
agentId,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
@@ -1319,6 +1329,27 @@ function ComposerContentImpl({
],
);
const runPluginClientSlashCommand = useCallback(
(resolved: { command: (typeof pluginClientSlashCommands)[number]; args: string }): boolean => {
if (blurOnSubmit) messageInputRef.current?.blur();
clearDraft("sent");
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
executePluginClientSlashCommand({
command: resolved.command,
args: resolved.args,
onError(error) {
console.error("[Composer] Failed to run plugin client slash command:", error);
toastErrorRef.current(error instanceof Error ? error.message : String(error));
},
});
return true;
},
[blurOnSubmit, clearDraft, replaceUserInput, resetSuppression, setSelectedAttachments],
);
const autocomplete = useAgentAutocomplete({
userInput,
cursorIndex,
@@ -1328,6 +1359,7 @@ function ComposerContentImpl({
draftConfig: commandDraftConfig,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
pluginClientSlashCommands,
onAutocompleteApplied: () => {
messageInputRef.current?.focus();
},
@@ -1595,6 +1627,12 @@ function ComposerContentImpl({
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
const pluginSlashCommand = resolvePluginClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
commands: pluginClientSlashCommands,
});
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
if (blurOnSubmit) {
messageInputRef.current?.blur();
@@ -1606,6 +1644,8 @@ function ComposerContentImpl({
blurOnSubmit,
buildOutgoingAttachments,
runClientSlashCommand,
pluginClientSlashCommands,
runPluginClientSlashCommand,
sendMessageWithContent,
],
);
@@ -1860,9 +1900,22 @@ function ComposerContentImpl({
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
const pluginSlashCommand = resolvePluginClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
commands: pluginClientSlashCommands,
});
if (pluginSlashCommand && runPluginClientSlashCommand(pluginSlashCommand)) return;
queueMessage(payload.text, outgoingAttachments);
},
[attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand],
[
attachments,
buildOutgoingAttachments,
pluginClientSlashCommands,
queueMessage,
runClientSlashCommand,
runPluginClientSlashCommand,
],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
@@ -15,6 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import {
consumeForcedTimelineTailReplacement,
type TimelineDeliveryMode,
type TimelineResponsePayload,
type ViewedTimelineOwner,
@@ -59,15 +60,6 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
function consumeForcedTimelineTailReplacement(
payload: TimelineResponsePayload,
replacements: Set<string>,
): TimelineResponsePayload {
if (payload.direction !== "tail") return payload;
if (!replacements.delete(payload.agentId)) return payload;
return { ...payload, reset: true };
}
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
export type {
@@ -187,6 +187,19 @@ const CATALOG_DATA = [
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
},
{
id: "gjc",
title: "Gajae Code",
description:
"Runs on the Claude/Codex/Gemini subscription you already pay for. Plan-before-mutation workflows, evidence-gated execution, and approval prompts for shell and destructive edits.",
version: "manual",
iconId: "gjc",
installLink: "https://gajae-code.com",
command: ["gjc", "acp"],
env: {
GJC_ACP_PERMISSION_MODE: "prompt",
},
},
{
id: "glm-acp-agent",
title: "GLM Agent",
@@ -11,7 +11,11 @@ import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import {
resetWorkingDiffComparisons,
resolveWorkingDiffComparison,
selectWorkingDiffComparison,
} from "@/git/working-diff-comparison";
import {
applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
@@ -87,10 +91,12 @@ function checkoutStatusUpdate(
};
}
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
useReviewDraftStore.getState().setDiffModeOverride({
scopeKey: "review:scope",
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
function selectBaseComparison(isDirtyAtSelection: boolean): void {
selectWorkingDiffComparison({
serverId,
cwd,
comparison: "base",
isDirty: isDirtyAtSelection,
});
}
@@ -99,7 +105,7 @@ function createQueryClient(): QueryClient {
}
beforeEach(() => {
resetReviewDraftStore();
resetWorkingDiffComparisons();
});
describe("fetchCheckoutStatus", () => {
@@ -113,13 +119,14 @@ describe("fetchCheckoutStatus", () => {
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
});
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => {
setDiffModeOverride(true);
it("expires a manual working-diff comparison when the fetched dirty state flipped", async () => {
selectBaseComparison(true);
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
await fetchCheckoutStatus({ client, serverId, cwd });
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: false })).toBe("base");
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
});
});
@@ -233,9 +240,9 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
).toBe("unauthenticated");
});
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
it("expires a manual working-diff comparison when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
setDiffModeOverride(false);
selectBaseComparison(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -243,12 +250,12 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("uncommitted");
});
it("keeps a manual diff-mode override while the pushed dirty state still matches", () => {
it("keeps a manual working-diff comparison while the pushed dirty state still matches", () => {
const queryClient = createQueryClient();
setDiffModeOverride(true);
selectBaseComparison(true);
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -256,7 +263,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
expect(resolveWorkingDiffComparison({ serverId, cwd, isDirty: true })).toBe("base");
});
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
@@ -8,7 +8,7 @@ import {
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { expireStaleDiffModeOverrides } from "@/review/store";
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
@@ -31,7 +31,7 @@ export async function fetchCheckoutStatus({
cwd: string;
}): Promise<CheckoutStatusPayload> {
const payload = await client.getCheckoutStatus(cwd);
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
expireWorkingDiffComparisons({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
}
@@ -71,7 +71,7 @@ export function applyCheckoutStatusUpdateFromEvent({
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
expireStaleDiffModeOverrides({
expireWorkingDiffComparisons({
serverId,
cwd: payload.cwd,
isDirty: payload.isGit && payload.isDirty,
@@ -4,6 +4,8 @@ import {
characterOffsetAtPoint,
hitTestDiffDocument,
orderedSelection,
selectAllSource,
selectCellSource,
selectedSourceText,
} from "./hit-testing";
import { buildDiffDocumentModel } from "./model";
@@ -104,6 +106,64 @@ describe("diff hit testing", () => {
}),
).toBeNull();
});
it("selects one source line for context-menu copy", () => {
const model = buildModel("copy this", { wrapLines: false });
const cell = changedCell(model);
expect(selectedSourceText(model, selectCellSource(model, position(model, cell, 4)))).toBe(
"copy this",
);
});
it("selects all diff source in document order", () => {
const model = buildModel("first", { wrapLines: false });
expect(selectedSourceText(model, selectAllSource(model)!)).toBe("@@ -1,0 +1,2 @@\nfirst\ntail");
});
it("selects all source from one split side without interleaving panes", () => {
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
const changedRow = model.rows.find(
(row) => row.kind === "line" && row.cells.length === 2 && row.cells.every(Boolean),
);
expect(changedRow?.kind).toBe("line");
const left = changedRow!.kind === "line" ? changedRow!.cells[0]! : null;
const right = changedRow!.kind === "line" ? changedRow!.cells[1]! : null;
expect(selectedSourceText(model, selectAllSource(model, position(model, left!, 0))!)).toBe(
"@@ -1,2 +1,2 @@\nold-one\nold-two",
);
expect(selectedSourceText(model, selectAllSource(model, position(model, right!, 0))!)).toBe(
"new-one\nnew-two",
);
});
it("defaults split hunk-header select-all to the new pane", () => {
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
const header = model.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find((cell) => cell?.type === "header");
expect(header).toBeDefined();
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
"new-one\nnew-two",
);
});
it("falls back to the old pane for a deletion-only split hunk", () => {
const model = buildModel("unused", { files: [deletedSplitFile()], layout: "split" });
const header = model.rows
.filter((row) => row.kind === "line")
.flatMap((row) => row.cells)
.find((cell) => cell?.type === "header");
expect(header).toBeDefined();
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
"@@ -1,2 +1,0 @@\nold-one\nold-two",
);
});
});
function buildModel(changedContent: string, overrides: Partial<BuildDiffDocumentModelInput> = {}) {
@@ -212,6 +272,29 @@ function splitFile(): ParsedDiffFile {
};
}
function deletedSplitFile(): ParsedDiffFile {
return {
path: "src/deleted.ts",
isNew: false,
isDeleted: true,
additions: 0,
deletions: 2,
hunks: [
{
oldStart: 1,
oldCount: 2,
newStart: 1,
newCount: 0,
lines: [
{ type: "header", content: "@@ -1,2 +1,0 @@" },
{ type: "remove", content: "old-one" },
{ type: "remove", content: "old-two" },
],
},
],
};
}
function reviewActionsForFirstAddition(): NonNullable<
BuildDiffDocumentModelInput["reviewActions"]
> {
@@ -105,6 +105,64 @@ export function selectedSourceText(model: DiffDocumentModel, selection: DiffSele
.join("\n");
}
export function selectCellSource(
model: DiffDocumentModel,
position: DiffCharacterPosition,
): DiffSelection {
const row = model.rows[position.rowIndex];
const cell = row?.kind === "line" ? row.cells[position.cellIndex] : null;
const end = cell?.content.length ?? position.sourceOffset;
return {
anchor: { ...position, sourceOffset: 0 },
focus: { ...position, sourceOffset: end },
};
}
export function selectAllSource(
model: DiffDocumentModel,
preferredPosition?: DiffCharacterPosition,
): DiffSelection | null {
let first: DiffCharacterPosition | null = null;
let last: DiffCharacterPosition | null = null;
const preferredRow = preferredPosition ? model.rows[preferredPosition.rowIndex] : null;
const preferredCell =
preferredPosition && preferredRow?.kind === "line"
? preferredRow.cells[preferredPosition.cellIndex]
: null;
let splitCellIndex: number | null = null;
if (model.layout === "split") {
const hasNewSource = model.rows.some(
(row) => row.kind === "line" && row.cells[1] && row.cells[1].type !== "header",
);
splitCellIndex = hasNewSource ? 1 : 0;
if (preferredPosition && preferredCell && preferredCell.type !== "header") {
splitCellIndex = preferredPosition.cellIndex;
}
}
for (const row of model.rows) {
if (row.kind !== "line") continue;
row.cells.forEach((cell, cellIndex) => {
if (!cell || (splitCellIndex !== null && cellIndex !== splitCellIndex)) return;
const side = cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new");
first ??= {
fileIndex: row.fileIndex,
rowIndex: row.index,
cellIndex,
side,
sourceOffset: 0,
};
last = {
fileIndex: row.fileIndex,
rowIndex: row.index,
cellIndex,
side,
sourceOffset: cell.content.length,
};
});
}
return first && last ? { anchor: first, focus: last } : null;
}
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
return (
left.rowIndex - right.rowIndex ||
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useTranslation } from "react-i18next";
import type { ViewStyle } from "react-native";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useToast } from "@/contexts/toast-context";
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header";
import {
@@ -11,7 +20,12 @@ import {
diffMaterializationWindow,
resolveVisibleFileSections,
} from "./header-layout";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import {
hitTestDiffDocument,
selectAllSource,
selectCellSource,
selectedSourceText,
} from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
@@ -33,6 +47,7 @@ const RESIZE_SETTLE_DELAY_MS = 120;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const toast = useToast();
const workspaceCache = useDiffDocumentWorkspaceCache();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -75,6 +90,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
const [interactionFiles, setInteractionFiles] = useState<
ReturnType<typeof buildDiffDocumentModel>["files"]
>([]);
const [hasSelection, setHasSelection] = useState(false);
const [contextHit, setContextHit] = useState<Extract<DiffHit, { kind: "cell" }> | null>(null);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
@@ -488,13 +505,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[schedulePaint],
);
const pointHit = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const pointHitAt = useCallback((clientX: number, clientY: number) => {
const currentModel = modelRef.current;
const root = rootRef.current;
if (!currentModel || !root) return null;
const bounds = root.getBoundingClientRect();
const x = event.clientX - bounds.left;
const documentY = event.clientY - bounds.top + scrollTopRef.current;
const x = clientX - bounds.left;
const documentY = clientY - bounds.top + scrollTopRef.current;
const file = currentModel.files.find(
(entry) => entry.top <= documentY && documentY < entry.bottom,
);
@@ -505,6 +522,27 @@ export function DiffSurface(props: DiffSurfaceProps) {
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
});
}, []);
const pointHit = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
[pointHitAt],
);
const setSelection = useCallback(
(selection: DiffSelection | null) => {
selectionRef.current = selection;
const currentModel = modelRef.current;
setHasSelection(
Boolean(
selection && currentModel && selectedSourceText(currentModel, selection).length > 0,
),
);
schedulePaint();
},
[schedulePaint],
);
useEffect(() => {
dragRef.current = null;
setSelection(null);
}, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]);
const pointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
@@ -518,8 +556,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
return;
const dismissSelectionOnClick = selectionRef.current !== null;
if (dismissSelectionOnClick) {
selectionRef.current = null;
schedulePaint();
setSelection(null);
}
const hit = pointHit(event);
if (hit?.kind !== "cell") return;
@@ -530,12 +567,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
moved: false,
dismissSelectionOnClick,
};
selectionRef.current = { anchor: hit.position, focus: hit.position };
setSelection({ anchor: hit.position, focus: hit.position });
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
schedulePaint();
},
[pointHit, schedulePaint],
[pointHit, setSelection],
);
const updateActiveHeader = useCallback(
(target: EventTarget | null) => {
@@ -589,10 +625,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
setHoveredAffordance(null);
}
if (!drag || hit?.kind !== "cell") return;
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
schedulePaint();
setSelection({ anchor: drag.anchor, focus: hit.position });
},
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
[pointHit, setSelection, updateActiveHeader, viewport.width],
);
const pointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@@ -612,8 +647,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
})
: false;
if (drag && !moved && drag.dismissSelectionOnClick) {
selectionRef.current = null;
schedulePaint();
setSelection(null);
return;
}
if (
@@ -624,12 +658,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
hit.target &&
reviewActions
) {
selectionRef.current = null;
setSelection(null);
reviewActions.onStartComment(hit.target);
schedulePaint();
}
},
[pointHit, reviewActions, schedulePaint],
[pointHit, reviewActions, setSelection],
);
const cancelPointer = useCallback(() => {
dragRef.current = null;
@@ -643,6 +676,61 @@ export function DiffSurface(props: DiffSurfaceProps) {
},
[model],
);
const prepareContextMenu = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const hit = pointHitAt(event.clientX, event.clientY);
setContextHit(hit?.kind === "cell" ? hit : null);
},
[pointHitAt],
);
const writeSourceText = useCallback(
(text: string) => {
void copyToClipboard(text)
.then(() => toast.copied(t("common.states.copied")))
.catch(() => toast.error(t("common.errors.unableToCopy")));
},
[t, toast],
);
const copySelectedSource = useCallback(() => {
const currentModel = modelRef.current;
const selection = selectionRef.current;
if (currentModel && selection) writeSourceText(selectedSourceText(currentModel, selection));
}, [writeSourceText]);
const copyContextLine = useCallback(() => {
const currentModel = modelRef.current;
if (!currentModel || !contextHit) return;
const selection = selectCellSource(currentModel, contextHit.position);
writeSourceText(selectedSourceText(currentModel, selection));
}, [contextHit, writeSourceText]);
const selectAll = useCallback(() => {
const currentModel = modelRef.current;
if (currentModel) {
setSelection(
selectAllSource(currentModel, contextHit?.position ?? selectionRef.current?.focus),
);
}
}, [contextHit?.position, setSelection]);
const keyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (
target instanceof Element &&
target.closest('input, textarea, [contenteditable="true"]')
) {
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {
event.preventDefault();
selectAll();
return;
}
if (event.key === "Escape" && selectionRef.current) {
event.preventDefault();
setSelection(null);
}
},
[selectAll, setSelection],
);
const rootStyle = useMemo<React.CSSProperties>(
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
[props.palette.surface],
@@ -672,7 +760,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
[desiredTypography, loadedTypography],
);
return (
const surface = (
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
<div
ref={scrollRef}
@@ -685,7 +773,9 @@ export function DiffSurface(props: DiffSurfaceProps) {
onPointerCancel={cancelPointer}
onLostPointerCapture={cancelPointer}
onPointerLeave={pointerLeave}
onContextMenu={prepareContextMenu}
onCopy={copy}
onKeyDown={keyDown}
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
@@ -752,6 +842,34 @@ export function DiffSurface(props: DiffSurfaceProps) {
) : null}
</div>
);
return (
<ContextMenu>
<ContextMenuTrigger contextOnly style={CONTEXT_TRIGGER_STYLE}>
{surface}
</ContextMenuTrigger>
<ContextMenuContent align="start" minWidth={180} testID="diff-source-context-menu">
<ContextMenuItem
disabled={!hasSelection}
onSelect={copySelectedSource}
testID="diff-source-copy-selection"
>
{t("common.actions.copy")}
</ContextMenuItem>
<ContextMenuItem
disabled={!contextHit}
onSelect={copyContextLine}
testID="diff-source-copy-line"
>
{t("common.actions.copyLine")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={selectAll} testID="diff-source-select-all">
{t("common.actions.selectAll")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function shiftCanvasPixels(input: {
@@ -820,7 +938,7 @@ function WebFileHeaderSection({
[file.bottom, file.top],
);
return (
<div style={style}>
<div style={style} onContextMenu={stopContextMenuPropagation}>
<div
data-diff-header="true"
data-diff-header-path={file.path}
@@ -864,7 +982,7 @@ function WebReviewThread({
[height, left, top, width],
);
return (
<div style={style} data-diff-review="true">
<div style={style} data-diff-review="true" onContextMenu={stopContextMenuPropagation}>
<InlineReviewThread
reviewTarget={target}
reviewActions={actions}
@@ -876,12 +994,17 @@ function WebReviewThread({
);
}
function stopContextMenuPropagation(event: React.MouseEvent): void {
event.stopPropagation();
}
const ROOT_STYLE: React.CSSProperties = {
position: "relative",
flex: 1,
minHeight: 0,
overflow: "hidden",
};
const CONTEXT_TRIGGER_STYLE: ViewStyle = { flex: 1, minHeight: 0 };
const SCROLL_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
-3
View File
@@ -187,7 +187,6 @@ interface ChangesSurfaceProps {
cwd: string;
enabled?: boolean;
presentation?: ChangesPresentation;
modeScope: string;
focusPath?: string;
focusRequestId?: number;
onOpenFile?: (path: string) => void;
@@ -1565,7 +1564,6 @@ export function ChangesSurface({
cwd,
enabled,
presentation = "combined",
modeScope,
focusPath,
focusRequestId,
onOpenFile,
@@ -1679,7 +1677,6 @@ export function ChangesSurface({
cwd,
ignoreWhitespace: preferences.hideWhitespace,
enabled: enabled !== false,
modeScope,
});
usePublishWorkingDiffAttachment({
serverId,
+8 -37
View File
@@ -5,14 +5,12 @@ import {
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftKey,
buildReviewDraftScopeKey,
useInlineReviewController,
useResolvedDiffMode,
useReviewAttachmentSnapshot,
useSetDiffModeOverride,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
interface UseWorkingDiffOptions {
serverId: string;
@@ -21,7 +19,6 @@ interface UseWorkingDiffOptions {
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
modeScope: string;
}
export function useWorkingDiff({
@@ -31,7 +28,6 @@ export function useWorkingDiff({
ignoreWhitespace,
enabled,
queryScope,
modeScope,
}: UseWorkingDiffOptions) {
const {
status,
@@ -50,39 +46,14 @@ export function useWorkingDiff({
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
);
const modeScopeKey = `${reviewDraftScopeKey}:surface=${encodeURIComponent(modeScope)}`;
const diffMode = useResolvedDiffMode({
scopeKey: modeScopeKey,
hasUncommittedChanges,
const { comparison: diffMode, selectComparison } = useWorkingDiffComparison({
serverId,
workspaceId,
cwd,
isDirty: hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
const selectDiffMode = useCallback(
(nextMode: "uncommitted" | "base") => {
setDiffModeOverride({
scopeKey: modeScopeKey,
override: {
serverId,
cwd,
mode: nextMode,
isDirtyAtSelection: hasUncommittedChanges,
},
});
},
[cwd, hasUncommittedChanges, modeScopeKey, serverId, setDiffModeOverride],
);
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
const selectUncommitted = useCallback(() => selectComparison("uncommitted"), [selectComparison]);
const selectBase = useCallback(() => selectComparison("base"), [selectComparison]);
const {
files,
@@ -0,0 +1,72 @@
import { useCallback } from "react";
import { create } from "zustand";
import {
expireWorkingDiffComparisonsInState,
resolveWorkingDiffComparisonFromState,
selectWorkingDiffComparisonInState,
type WorkingDiffCheckoutIdentity,
type WorkingDiffComparison,
type WorkingDiffComparisonState,
} from "./state";
interface WorkingDiffComparisonStore extends WorkingDiffComparisonState {
select: (
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
isDirty: boolean;
},
) => void;
}
const useWorkingDiffComparisonStore = create<WorkingDiffComparisonStore>((set) => ({
overrides: {},
select: (input) => set((state) => selectWorkingDiffComparisonInState(state, input)),
}));
export function useWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
): {
comparison: WorkingDiffComparison;
selectComparison: (comparison: WorkingDiffComparison) => void;
} {
const { serverId, workspaceId, cwd, isDirty } = input;
const comparison = useWorkingDiffComparisonStore((state) =>
resolveWorkingDiffComparisonFromState(state, { serverId, workspaceId, cwd, isDirty }),
);
const select = useWorkingDiffComparisonStore((state) => state.select);
const selectComparison = useCallback(
(next: WorkingDiffComparison) =>
select({ serverId, workspaceId, cwd, isDirty, comparison: next }),
[cwd, isDirty, select, serverId, workspaceId],
);
return { comparison, selectComparison };
}
export function selectWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
isDirty: boolean;
},
): void {
useWorkingDiffComparisonStore.getState().select(input);
}
export function resolveWorkingDiffComparison(
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
): WorkingDiffComparison {
return resolveWorkingDiffComparisonFromState(useWorkingDiffComparisonStore.getState(), input);
}
export function expireWorkingDiffComparisons(input: {
serverId: string;
cwd: string;
isDirty: boolean;
}): void {
useWorkingDiffComparisonStore.setState((state) =>
expireWorkingDiffComparisonsInState(state, input),
);
}
export function resetWorkingDiffComparisons(): void {
useWorkingDiffComparisonStore.setState({ overrides: {} });
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
expireWorkingDiffComparisonsInState,
resolveWorkingDiffComparisonFromState,
selectWorkingDiffComparisonInState,
type WorkingDiffComparisonState,
workingDiffComparisonKey,
} from "./state";
const checkout = { serverId: "server-1", workspaceId: "workspace-1", cwd: "/repo" };
function emptyState(): WorkingDiffComparisonState {
return { overrides: {} };
}
describe("working diff comparison", () => {
it("scopes selection to the workspace checkout rather than a panel", () => {
expect(workingDiffComparisonKey(checkout)).toBe(
"working-diff:server=server-1:workspace=workspace-1",
);
expect(workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })).not.toBe(
workingDiffComparisonKey(checkout),
);
expect(workingDiffComparisonKey({ ...checkout, workspaceId: null, cwd: "/repo/" })).toBe(
"working-diff:server=server-1:cwd=%2Frepo",
);
});
it("defaults from checkout dirtiness and honors a matching manual selection", () => {
expect(
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: true }),
).toBe("uncommitted");
expect(
resolveWorkingDiffComparisonFromState(emptyState(), { ...checkout, isDirty: false }),
).toBe("base");
const selected = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison: "base",
isDirty: true,
});
expect(resolveWorkingDiffComparisonFromState(selected, { ...checkout, isDirty: true })).toBe(
"base",
);
});
it("masks and expires stale selections for every workspace on the checkout", () => {
let state = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison: "base",
isDirty: true,
});
state = selectWorkingDiffComparisonInState(state, {
...checkout,
workspaceId: "workspace-2",
comparison: "uncommitted",
isDirty: false,
});
state = selectWorkingDiffComparisonInState(state, {
serverId: "server-2",
workspaceId: "workspace-3",
cwd: "/other",
comparison: "base",
isDirty: true,
});
expect(resolveWorkingDiffComparisonFromState(state, { ...checkout, isDirty: false })).toBe(
"base",
);
const expired = expireWorkingDiffComparisonsInState(state, {
serverId: checkout.serverId,
cwd: checkout.cwd,
isDirty: false,
});
expect(expired.overrides[workingDiffComparisonKey(checkout)]).toBeUndefined();
expect(
expired.overrides[workingDiffComparisonKey({ ...checkout, workspaceId: "workspace-2" })],
).toBeDefined();
expect(
expired.overrides[
workingDiffComparisonKey({
serverId: "server-2",
workspaceId: "workspace-3",
cwd: "/other",
})
],
).toBeDefined();
});
it("keeps state identity when nothing expires", () => {
const state = selectWorkingDiffComparisonInState(emptyState(), {
...checkout,
comparison: "base",
isDirty: true,
});
expect(
expireWorkingDiffComparisonsInState(state, {
serverId: checkout.serverId,
cwd: checkout.cwd,
isDirty: true,
}),
).toBe(state);
});
});
@@ -0,0 +1,85 @@
export type WorkingDiffComparison = "uncommitted" | "base";
export interface WorkingDiffComparisonOverride {
serverId: string;
cwd: string;
comparison: WorkingDiffComparison;
isDirtyAtSelection: boolean;
}
export interface WorkingDiffComparisonState {
overrides: Record<string, WorkingDiffComparisonOverride>;
}
export interface WorkingDiffCheckoutIdentity {
serverId: string;
workspaceId?: string | null;
cwd: string;
}
function normalizeCwd(cwd: string): string {
const trimmed = cwd.trim();
return trimmed === "/" ? trimmed : trimmed.replace(/\/+$/, "");
}
export function workingDiffComparisonKey(input: WorkingDiffCheckoutIdentity): string {
const workspaceId = input.workspaceId?.trim();
const checkout = workspaceId
? `workspace=${encodeURIComponent(workspaceId)}`
: `cwd=${encodeURIComponent(normalizeCwd(input.cwd))}`;
return `working-diff:server=${encodeURIComponent(input.serverId.trim())}:${checkout}`;
}
export function selectWorkingDiffComparisonInState(
state: WorkingDiffComparisonState,
input: WorkingDiffCheckoutIdentity & {
comparison: WorkingDiffComparison;
isDirty: boolean;
},
): WorkingDiffComparisonState {
return {
overrides: {
...state.overrides,
[workingDiffComparisonKey(input)]: {
serverId: input.serverId.trim(),
cwd: normalizeCwd(input.cwd),
comparison: input.comparison,
isDirtyAtSelection: input.isDirty,
},
},
};
}
export function resolveWorkingDiffComparisonFromState(
state: WorkingDiffComparisonState,
input: WorkingDiffCheckoutIdentity & { isDirty: boolean },
): WorkingDiffComparison {
const override = state.overrides[workingDiffComparisonKey(input)];
// Status can render before boundary expiry runs, so resolution must also mask a stale
// selection under any ordering of the two updates.
if (override?.isDirtyAtSelection === input.isDirty) {
return override.comparison;
}
return input.isDirty ? "uncommitted" : "base";
}
export function expireWorkingDiffComparisonsInState(
state: WorkingDiffComparisonState,
input: { serverId: string; cwd: string; isDirty: boolean },
): WorkingDiffComparisonState {
// Expire at the status boundary so selections cannot return after an unmounted surface
// misses a dirty-state transition.
const staleKeys = Object.entries(state.overrides)
.filter(
([, override]) =>
override.serverId === input.serverId.trim() &&
override.cwd === normalizeCwd(input.cwd) &&
override.isDirtyAtSelection !== input.isDirty,
)
.map(([key]) => key);
if (staleKeys.length === 0) return state;
const overrides = { ...state.overrides };
for (const key of staleKeys) delete overrides[key];
return { overrides };
}
@@ -81,6 +81,13 @@ describe("ACP provider catalog", () => {
});
});
it("registers Gajae Code with its ACP command and permission-prompt env", () => {
const patch = buildAcpProviderConfigPatch(findProvider("gjc"));
expect(patch.providers?.gjc?.command).toEqual(["gjc", "acp"]);
expect(patch.providers?.gjc?.env).toEqual({ GJC_ACP_PERMISSION_MODE: "prompt" });
});
it("preserves provider params in the daemon config patch", () => {
const droidPatch = buildAcpProviderConfigPatch(findProvider("factory-droid"));
@@ -13,6 +13,8 @@ import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
import { mergeSlashCommandSources } from "@/plugins/client-slash-commands/model";
import {
applySlashCommandReplacement,
filterAndRankCommandAutocompleteEntries,
@@ -36,6 +38,7 @@ interface UseAgentAutocompleteInput {
onAutocompleteApplied?: () => void;
onClientSlashCommand?: (command: ClientSlashCommand) => void;
canExecuteClientSlashCommand?: boolean;
pluginClientSlashCommands?: readonly PluginClientSlashCommand[];
}
interface AgentAutocompleteKeyPressEvent {
@@ -51,6 +54,10 @@ interface AgentAutocompleteInputSnapshot {
type AgentAutocompleteOption =
| (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
| (AutocompleteOption & {
type: "plugin_command";
command: PluginClientSlashCommand;
})
| (AutocompleteOption & { type: "provider_command" })
| (AutocompleteOption & {
type: "workspace_entry";
@@ -107,6 +114,7 @@ interface DirectorySuggestionEntry {
type AvailableCommand =
| { source: "client"; command: ClientSlashCommand }
| { source: "plugin"; command: PluginClientSlashCommand }
| { source: "provider"; command: AgentSlashCommand };
function normalizeDraftCommandConfig(
@@ -175,6 +183,9 @@ function mapCommandToOption(entry: AvailableCommand, t: TFunction): AgentAutocom
command: entry.command,
};
}
if (entry.source === "plugin") {
return { ...base, type: "plugin_command", command: entry.command };
}
return {
...base,
type: "provider_command",
@@ -187,6 +198,7 @@ interface BuildAutocompleteOptionsInput {
isVisible: boolean;
mode: AutocompleteMode;
commands: AgentSlashCommand[];
pluginCommands: readonly PluginClientSlashCommand[];
isDraftContext: boolean;
commandFilterQuery: string;
activeSlashCommand: SlashCommandRange | null;
@@ -201,19 +213,26 @@ function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
}
if (input.mode === "command") {
const providerCommands = input.commands.map(
(command): AvailableCommand => ({ source: "provider", command }),
);
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
const rootCommands: AvailableCommand[] = input.isDraftContext
? providerCommands
: [
...CLIENT_SLASH_COMMANDS.map(
(command): AvailableCommand => ({ source: "client", command }),
),
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
];
const availableCommands =
const providerCommands = input.commands.map((command) => ({
source: "provider" as const,
command,
}));
const rootCommands: AvailableCommand[] = mergeSlashCommandSources({
builtIn: CLIENT_SLASH_COMMANDS,
plugins: input.pluginCommands,
provider: input.commands,
onPluginCollision(command, winner) {
console.warn(
`[Plugins] Client slash command /${command.name} from ${command.pluginId} ignored; ${winner} command wins`,
);
},
})
.filter((entry) => !input.isDraftContext || entry.source !== "built-in")
.map((entry): AvailableCommand => {
if (entry.source === "built-in") return { source: "client", command: entry.command };
return entry;
});
const availableCommands: AvailableCommand[] =
input.activeSlashCommand?.position === "inline"
? filterInlineSkillCommandEntries(providerCommands)
: rootCommands;
@@ -330,6 +349,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
onAutocompleteApplied,
onClientSlashCommand,
canExecuteClientSlashCommand,
pluginClientSlashCommands = [],
} = input;
const activeSlashCommand = useMemo(
@@ -446,6 +466,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
activeFileMention,
commandFilterQuery,
commands,
pluginCommands: pluginClientSlashCommands,
activeSlashCommand,
fileSuggestions: fileSuggestionsQuery.data ?? [],
isDraftContext,
@@ -458,6 +479,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
activeSlashCommand,
commandFilterQuery,
commands,
pluginClientSlashCommands,
fileSuggestionsQuery.data,
isDraftContext,
isVisible,
@@ -477,7 +499,9 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
activeFileMention,
});
const selectedIsCommand =
selected.type === "client_command" || selected.type === "provider_command";
selected.type === "client_command" ||
selected.type === "plugin_command" ||
selected.type === "provider_command";
if (snapshot && selectedIsCommand && !current.slashCommand) return;
if (
selected.type === "client_command" &&
@@ -0,0 +1,94 @@
import { useCallback, useState, type ReactNode } from "react";
import { type Href, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostChooser } from "@/hosts/host-chooser";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { useOpenProject } from "@/hooks/use-open-project";
interface UseImportSessionOptions {
serverId?: string;
cwd?: string;
workspaceId?: string;
}
interface UseImportSessionResult {
open: () => void;
sheet: ReactNode;
}
interface ImportedAgentTarget {
id: string;
cwd: string;
}
/**
* Sends the user to an agent that belongs to a workspace other than the one they
* are looking at. The imported session's own directory decides the destination,
* so the project has to exist before the route can resolve.
*/
export function useNavigateToImportedAgent(
serverId: string | null | undefined,
): (agent: ImportedAgentTarget) => Promise<void> {
const router = useRouter();
const normalizedServerId = serverId?.trim() || null;
const openProject = useOpenProject(normalizedServerId);
return useCallback(
async (agent: ImportedAgentTarget) => {
if (!normalizedServerId) return;
const project = await openProject(agent.cwd);
if (project.ok) {
router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href);
}
},
[normalizedServerId, openProject, router],
);
}
export function useImportSession({
serverId,
cwd,
workspaceId,
}: UseImportSessionOptions = {}): UseImportSessionResult {
const { t } = useTranslation();
const chooseHost = useHostChooser();
const [importServerId, setImportServerId] = useState<string | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const client = useHostRuntimeClient(importServerId ?? "");
const navigateToImportedAgent = useNavigateToImportedAgent(importServerId);
const openForHost = useCallback((chosenServerId: string) => {
setImportServerId(chosenServerId);
setIsSheetOpen(true);
}, []);
const open = useCallback(() => {
if (serverId) {
openForHost(serverId);
return;
}
chooseHost({
title: t("importSession.chooseHostTitle"),
onChooseHost: openForHost,
});
}, [chooseHost, openForHost, serverId, t]);
const close = useCallback(() => setIsSheetOpen(false), []);
return {
open,
sheet: (
<ImportSessionSheet
visible={isSheetOpen}
client={client}
serverId={importServerId}
cwd={cwd}
workspaceId={workspaceId}
onClose={close}
onImported={navigateToImportedAgent}
/>
),
};
}
+5 -3
View File
@@ -36,6 +36,7 @@ import {
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
type AppSettingsUpdate,
type OpenInSidePanePreferences,
type PullRequestOpenLocation,
type DesktopSettingsBridge,
@@ -72,6 +73,7 @@ export {
};
export type {
AppSettings,
AppSettingsUpdate,
AppLanguage,
OpenInSidePanePreferences,
PullRequestOpenLocation,
@@ -116,7 +118,7 @@ export interface UseAppSettingsReturn {
settings: AppSettings;
isLoading: boolean;
error: unknown;
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
updateSettings: (updates: AppSettingsUpdate) => Promise<void>;
resetSettings: () => Promise<void>;
}
@@ -140,7 +142,7 @@ export function useAppSettings(): UseAppSettingsReturn {
});
const updateSettings = useCallback(
async (updates: Partial<AppSettings>) => {
async (updates: AppSettingsUpdate) => {
try {
await saveAppSettings({ queryClient, updates });
} catch (err) {
@@ -242,7 +244,7 @@ export async function persistAppSettings(updates: Partial<AppSettings>): Promise
export async function saveAppSettings(input: {
queryClient: QueryClient;
updates: Partial<AppSettings>;
updates: AppSettingsUpdate;
deps?: SettingsDeps;
}): Promise<void> {
await saveAppSettingsPure({
@@ -197,6 +197,46 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.chatOutlineEnabled).toBe(false);
});
it("defaults sidebar navigation items to an empty preference list", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.sidebarNavItems).toEqual([]);
});
it("loads stored sidebar navigation items in order", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({
sidebarNavItems: [
{ key: "history", visible: false },
{ key: "new-workspace", visible: true },
],
}),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sidebarNavItems).toEqual([
{ key: "history", visible: false },
{ key: "new-workspace", visible: true },
]);
});
it("falls back to the default sidebar navigation items when the stored list is malformed", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sidebarNavItems: [{ key: 3 }] }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sidebarNavItems).toEqual([]);
});
it("collapses legacy diff destinations into the former Explorer choice", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -380,6 +420,44 @@ describe("loadAppSettingsFromStorage", () => {
});
});
describe("saveAppSettings", () => {
it("applies consecutive functional updates to the latest cached settings", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, DEFAULT_CLIENT_SETTINGS);
await Promise.all([
saveAppSettings({
queryClient,
updates: (current) => ({
sidebarNavItems: [...current.sidebarNavItems, { key: "history", visible: false }],
}),
deps,
}),
saveAppSettings({
queryClient,
updates: (current) => ({
sidebarNavItems: [...current.sidebarNavItems, { key: "search", visible: true }],
}),
deps,
}),
]);
expect(queryClient.getQueryData(APP_SETTINGS_QUERY_KEY)).toMatchObject({
sidebarNavItems: [
{ key: "history", visible: false },
{ key: "search", visible: true },
],
});
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
sidebarNavItems: [
{ key: "history", visible: false },
{ key: "search", visible: true },
],
});
});
});
describe("loadSettingsFromStorage", () => {
it("defaults built-in daemon management to enabled when storage is empty", async () => {
const deps = makeDeps();
+12 -2
View File
@@ -3,6 +3,7 @@ import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { AppLanguage } from "@/i18n/locales";
import type { SidebarNavPreference } from "@/sidebar-nav/model";
import {
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
type SidebarChecksDisplay,
@@ -82,6 +83,8 @@ export interface AppSettings {
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
sidebarRowItems: SidebarRowItems;
sidebarChecksDisplay: SidebarChecksDisplay;
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
sidebarNavItems: SidebarNavPreference[];
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
@@ -91,6 +94,10 @@ export interface AppSettings {
pullRequestOpenLocation: PullRequestOpenLocation;
}
export type AppSettingsUpdate =
| Partial<AppSettings>
| ((current: AppSettings) => Partial<AppSettings>);
export interface OpenInSidePanePreferences {
explorerFiles: boolean;
diffs: boolean;
@@ -130,6 +137,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sidebarWorkspaceTrailing: "diff",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarNavItems: [],
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
@@ -218,6 +226,7 @@ const StoredAppSettingsSchema = z
.enum(["iconAndText", "icon", "none"])
.optional()
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
autoExpandReasoning: z.boolean().catch(false),
toolCallDetailLevel: z
.enum(["overview", "detailed"])
@@ -319,14 +328,15 @@ export interface SettingsDeps {
export async function saveAppSettings(input: {
queryClient: QueryClient;
updates: Partial<AppSettings>;
updates: AppSettingsUpdate;
deps: SettingsDeps;
}): Promise<void> {
const storedCurrent =
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage(input.deps));
const current = normalizeAppSettings(storedCurrent);
const next = { ...current, ...input.updates };
const updates = typeof input.updates === "function" ? input.updates(current) : input.updates;
const next = { ...current, ...updates };
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await writeAppSettings(
input.deps.storage,
+3 -1
View File
@@ -5,12 +5,14 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
/** `null` action ids are accepted so callers can keep the hook unconditional. */
export function useShortcutKeys(actionId: string | null): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
return useMemo(() => {
if (actionId === null) return null;
return resolveShortcutKeysForAction(actionId, overrides, {
isMac,
isDesktop: isDesktopApp,
+4 -1
View File
@@ -285,6 +285,7 @@ describe("translation resources", () => {
it("includes workspace and panel keys for the Batch 4A migration", () => {
expect(en.importSession.title).toBe("Import session");
expect(en.importSession.chooseHostTitle).toBe("Import from host");
expect(en.importSession.status.connectHost).toBe("Connect to a host to import sessions");
expect(en.importSession.actions.refresh).toBe("Refresh sessions");
expect(en.workspace.fileExplorer.sort.name).toBe("Name");
@@ -298,6 +299,9 @@ describe("translation resources", () => {
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
expect(en.openProject.tiles.importSession.description).toBe(
"Open a Claude Code, Codex or other session you started in a terminal",
);
});
it("includes workspace Git and review keys for the Batch 4B migration", () => {
@@ -444,7 +448,6 @@ describe("translation resources", () => {
expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts...");
expect(en.sidebar.actions.addProject).toBe("Add project");
expect(en.sidebar.actions.hosts).toBe("Hosts");
expect(en.sidebar.actions.home).toBe("Home");
expect(en.sidebar.actions.settings).toBe("Settings");
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
expect(en.sidebar.sections.sessions).toBe("History");
+30 -3
View File
@@ -10,10 +10,12 @@ export const ar: TranslationResources = {
cancel: "يلغي",
close: "يغلق",
copy: "ينسخ",
copyLine: "نسخ السطر",
dismiss: "رفض",
retry: "أعد المحاولة",
search: "يبحث",
select: "يختار",
selectAll: "تحديد الكل",
},
placeholders: {
search: "يبحث...",
@@ -33,6 +35,7 @@ export const ar: TranslationResources = {
errors: {
error: "خطأ",
unableToSave: "غير قادر على الحفظ",
unableToCopy: "تعذر النسخ",
nameRequired: "الاسم مطلوب",
daemonUnavailable: "Daemon غير متوفر",
daemonClientUnavailable: "عميل Daemon غير متوفر",
@@ -286,6 +289,7 @@ export const ar: TranslationResources = {
zoomIn: "تكبير",
zoomOut: "تصغير",
resetZoom: "إعادة تعيين العرض",
fullscreen: "ملء الشاشة",
viewSource: "عرض المصدر",
viewDiagram: "عرض المخطط",
},
@@ -357,7 +361,13 @@ export const ar: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "جلسة الاستيراد",
searchPlaceholder: "البحث في الجلسات...",
scope: {
host: "الجلسات على {{host}}",
workspace: "مساحة العمل هذه",
},
filters: {
all: "الجميع",
},
@@ -366,12 +376,13 @@ export const ar: TranslationResources = {
updateHost: "قم بتحديث المضيف لاستيراد الجلسات.",
noProviders: "لم يتم تمكين أي موفري خدمات قابلين للاستيراد.",
loading: "جارٍ تحميل الجلسات الأخيرة...",
failedAll: "تعذر تحميل الجلسات الأخيرة.",
failedProviders: "تعذر تحميل جلسات العمل لـ{{providers}}.",
failedProvider: "تعذر تحميل جلسات {{provider}}",
failedImport: "تعذر استيراد الجلسة المحددة.",
},
actions: {
refresh: "تحديث الجلسات",
showAll: "عرض الكل",
loadMore: "تحميل المزيد",
},
preview: {
untitledSession: "جلسة بلا عنوان",
@@ -379,6 +390,7 @@ export const ar: TranslationResources = {
},
empty: {
noRecent: "لا توجد جلسات حديثة لاستيرادها.",
noMatches: "لا توجد جلسات تطابق بحثك.",
alreadyImported: "تم بالفعل استيراد كافة الجلسات الأخيرة.",
noProviderSessions: "لم يتم العثور على جلسات{{provider}}.",
},
@@ -491,14 +503,23 @@ export const ar: TranslationResources = {
completed: "اكتمل الإعداد",
failed: "فشل الإعداد",
workspace: "إعداد Workspace",
blocked: "تم حظر الإعداد",
},
status: {
running: "جري",
completed: "مكتمل",
failed: "فشل",
waiting: "في انتظار إخراج الإعداد",
blocked: "محظور",
},
waiting: "جارٍ إعداد مساحة العمل...",
blocked: {
title: "لم يتم تشغيل الإعداد",
description:
"يأتي طلب السحب هذا من {{repository}}، وهو مستودع مختلف. قد يشغّل الإعداد والبرامج النصية تعليمات برمجية لم تراجعها.",
run: "تشغيل الإعداد",
runFailed: "فشل تشغيل إعداد مساحة العمل",
},
empty: {
noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.",
},
@@ -1082,7 +1103,6 @@ export const ar: TranslationResources = {
addProject: "إضافة مشروع",
newWorkspace: "مساحة عمل جديدة",
hosts: "المضيفون",
home: "بيت",
settings: "إعدادات",
closeSidebar: "إغلاق الشريط الجانبي",
},
@@ -1099,6 +1119,7 @@ export const ar: TranslationResources = {
},
sections: {
sessions: "السجل",
search: "بحث",
schedules: "الجداول",
},
worktreeSetup: {
@@ -2096,6 +2117,12 @@ export const ar: TranslationResources = {
title: "مخطط المحادثة",
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
title: "الشريط الجانبي",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
moveUp: "نقل لأعلى",
moveDown: "نقل لأسفل",
},
fonts: {
title: "الخطوط",
systemDefault: "الافتراضي للنظام",
+31 -4
View File
@@ -7,10 +7,12 @@ export const en = {
cancel: "Cancel",
close: "Close",
copy: "Copy",
copyLine: "Copy line",
dismiss: "Dismiss",
retry: "Retry",
search: "Search",
select: "Select",
selectAll: "Select all",
},
placeholders: {
search: "Search...",
@@ -30,6 +32,7 @@ export const en = {
errors: {
error: "Error",
unableToSave: "Unable to save",
unableToCopy: "Unable to copy",
nameRequired: "Name is required",
daemonUnavailable: "Daemon unavailable",
daemonClientUnavailable: "Daemon client unavailable",
@@ -284,6 +287,7 @@ export const en = {
zoomIn: "Zoom in",
zoomOut: "Zoom out",
resetZoom: "Reset view",
fullscreen: "Fullscreen",
viewSource: "View source",
viewDiagram: "View diagram",
},
@@ -356,6 +360,12 @@ export const en = {
},
importSession: {
title: "Import session",
chooseHostTitle: "Import from host",
searchPlaceholder: "Search sessions...",
scope: {
host: "Sessions on {{host}}",
workspace: "This workspace",
},
filters: {
all: "All",
},
@@ -364,12 +374,13 @@ export const en = {
updateHost: "Update the host to import sessions.",
noProviders: "No importable providers are enabled.",
loading: "Loading recent sessions...",
failedAll: "Could not load recent sessions.",
failedProviders: "Could not load sessions for {{providers}}.",
failedProvider: "Could not load {{provider}} sessions",
failedImport: "Could not import selected session.",
},
actions: {
refresh: "Refresh sessions",
showAll: "Show all",
loadMore: "Load more",
},
preview: {
untitledSession: "Untitled session",
@@ -377,6 +388,7 @@ export const en = {
},
empty: {
noRecent: "No recent sessions to import.",
noMatches: "No sessions match your search.",
alreadyImported: "All recent sessions are already imported.",
noProviderSessions: "No {{provider}} sessions found.",
},
@@ -489,14 +501,23 @@ export const en = {
completed: "Setup completed",
failed: "Setup failed",
workspace: "Workspace setup",
blocked: "Setup blocked",
},
status: {
running: "Running",
completed: "Completed",
failed: "Failed",
waiting: "Waiting for setup output",
blocked: "Blocked",
},
waiting: "Setting up workspace...",
blocked: {
title: "Setup was not run",
description:
"This PR comes from {{repository}}, a different repository. Its setup and scripts could run code you have not reviewed.",
run: "Run setup",
runFailed: "Failed to run workspace setup",
},
empty: {
noCommands: "No setup commands ran for this workspace.",
},
@@ -1091,7 +1112,6 @@ export const en = {
addProject: "Add project",
newWorkspace: "New workspace",
hosts: "Hosts",
home: "Home",
settings: "Settings",
closeSidebar: "Close sidebar",
},
@@ -1108,6 +1128,7 @@ export const en = {
},
sections: {
sessions: "History",
search: "Search",
schedules: "Schedules",
},
worktreeSetup: {
@@ -1422,7 +1443,7 @@ export const en = {
},
importSession: {
title: "Import session",
description: "Bring in recent external CLI sessions",
description: "Open a Claude Code, Codex or other session you started in a terminal",
},
setupProviders: {
title: "Setup providers",
@@ -2195,6 +2216,12 @@ export const en = {
title: "Chat outline",
description: "Show an outline for jumping between prompts",
},
sidebar: {
title: "Sidebar",
description: "Choose which items appear at the top of the sidebar and in what order",
moveUp: "Move up",
moveDown: "Move down",
},
fonts: {
title: "Fonts",
systemDefault: "System default",
+31 -3
View File
@@ -10,10 +10,12 @@ export const es: TranslationResources = {
cancel: "Cancelar",
close: "Cerrar",
copy: "Copiar",
copyLine: "Copiar línea",
dismiss: "Despedir",
retry: "Rever",
search: "Buscar",
select: "Seleccionar",
selectAll: "Seleccionar todo",
},
placeholders: {
search: "Buscar...",
@@ -33,6 +35,7 @@ export const es: TranslationResources = {
errors: {
error: "Error",
unableToSave: "No se puede guardar",
unableToCopy: "No se pudo copiar",
nameRequired: "El nombre es obligatorio",
daemonUnavailable: "Daemonno disponible",
daemonClientUnavailable: "ClienteDaemonno disponible",
@@ -289,6 +292,7 @@ export const es: TranslationResources = {
zoomIn: "Acercar",
zoomOut: "Alejar",
resetZoom: "Restablecer vista",
fullscreen: "Pantalla completa",
viewSource: "Ver fuente",
viewDiagram: "Ver diagrama",
},
@@ -360,7 +364,13 @@ export const es: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "Importar sesión",
searchPlaceholder: "Buscar sesiones...",
scope: {
host: "Sesiones en {{host}}",
workspace: "Este espacio de trabajo",
},
filters: {
all: "Todo",
},
@@ -369,12 +379,13 @@ export const es: TranslationResources = {
updateHost: "Actualice el host para importar sesiones.",
noProviders: "No hay proveedores importables habilitados.",
loading: "Cargando sesiones recientes...",
failedAll: "No se pudieron cargar las sesiones recientes.",
failedProviders: "No se pudieron cargar sesiones para{{providers}}.",
failedProvider: "No se pudieron cargar las sesiones de {{provider}}",
failedImport: "No se pudo importar la sesión seleccionada.",
},
actions: {
refresh: "Actualizar sesiones",
showAll: "Mostrar todo",
loadMore: "Cargar más",
},
preview: {
untitledSession: "Sesión sin título",
@@ -382,6 +393,7 @@ export const es: TranslationResources = {
},
empty: {
noRecent: "No hay sesiones recientes para importar.",
noMatches: "Ninguna sesión coincide con tu búsqueda.",
alreadyImported: "Todas las sesiones recientes ya están importadas.",
noProviderSessions: "No se encontraron sesiones{{provider}}.",
},
@@ -495,14 +507,23 @@ export const es: TranslationResources = {
completed: "Configuración completada",
failed: "Error de configuración",
workspace: "Configuración deWorkspace",
blocked: "Configuración bloqueada",
},
status: {
running: "Correr",
completed: "Terminado",
failed: "Fallido",
waiting: "Esperando el resultado de la configuración",
blocked: "Bloqueado",
},
waiting: "Configurando el espacio de trabajo...",
blocked: {
title: "No se ejecutó la configuración",
description:
"Este PR proviene de {{repository}}, un repositorio diferente. Su configuración y scripts podrían ejecutar código que no has revisado.",
run: "Ejecutar configuración",
runFailed: "No se pudo ejecutar la configuración",
},
empty: {
noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo.",
},
@@ -1116,7 +1137,6 @@ export const es: TranslationResources = {
addProject: "Agregar proyecto",
newWorkspace: "Nuevo espacio de trabajo",
hosts: "Hosts",
home: "Hogar",
settings: "Ajustes",
closeSidebar: "Cerrar barra lateral",
},
@@ -1133,6 +1153,7 @@ export const es: TranslationResources = {
},
sections: {
sessions: "Historial",
search: "Buscar",
schedules: "Horarios",
},
worktreeSetup: {
@@ -2146,6 +2167,13 @@ export const es: TranslationResources = {
title: "Esquema del chat",
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
title: "Barra lateral",
description:
"Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden",
moveUp: "Mover hacia arriba",
moveDown: "Mover hacia abajo",
},
fonts: {
title: "Fuentes",
systemDefault: "Valor predeterminado del sistema",
+30 -3
View File
@@ -10,10 +10,12 @@ export const fr: TranslationResources = {
cancel: "Annuler",
close: "Fermer",
copy: "Copie",
copyLine: "Copier la ligne",
dismiss: "Rejeter",
retry: "Réessayer",
search: "Recherche",
select: "Sélectionner",
selectAll: "Tout sélectionner",
},
placeholders: {
search: "Recherche...",
@@ -33,6 +35,7 @@ export const fr: TranslationResources = {
errors: {
error: "Erreur",
unableToSave: "Impossible d'enregistrer",
unableToCopy: "Impossible de copier",
nameRequired: "Le nom est requis",
daemonUnavailable: "Daemonindisponible",
daemonClientUnavailable: "ClientDaemonindisponible",
@@ -290,6 +293,7 @@ export const fr: TranslationResources = {
zoomIn: "Zoomer",
zoomOut: "Dézoomer",
resetZoom: "Réinitialiser la vue",
fullscreen: "Plein écran",
viewSource: "Afficher la source",
viewDiagram: "Afficher le diagramme",
},
@@ -361,7 +365,13 @@ export const fr: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "Session d'importation",
searchPlaceholder: "Rechercher des sessions...",
scope: {
host: "Sessions sur {{host}}",
workspace: "Cet espace de travail",
},
filters: {
all: "Tous",
},
@@ -370,12 +380,13 @@ export const fr: TranslationResources = {
updateHost: "Mettez à jour l'hôte pour importer des sessions.",
noProviders: "Aucun fournisseur importable n'est activé.",
loading: "Chargement des sessions récentes...",
failedAll: "Impossible de charger les sessions récentes.",
failedProviders: "Impossible de charger les sessions pour{{providers}}.",
failedProvider: "Impossible de charger les sessions {{provider}}",
failedImport: "Impossible d'importer la session sélectionnée.",
},
actions: {
refresh: "Sessions de rafraîchissement",
showAll: "Tout afficher",
loadMore: "Charger plus",
},
preview: {
untitledSession: "Séance sans titre",
@@ -383,6 +394,7 @@ export const fr: TranslationResources = {
},
empty: {
noRecent: "Aucune session récente à importer.",
noMatches: "Aucune session ne correspond à votre recherche.",
alreadyImported: "Toutes les sessions récentes sont déjà importées.",
noProviderSessions: "Aucune session{{provider}}trouvée.",
},
@@ -495,14 +507,23 @@ export const fr: TranslationResources = {
completed: "Configuration terminée",
failed: "Échec de l'installation",
workspace: "ConfigurationWorkspace",
blocked: "Configuration bloquée",
},
status: {
running: "En cours d'exécution",
completed: "Complété",
failed: "Échoué",
waiting: "En attente de la sortie de configuration",
blocked: "Bloqué",
},
waiting: "Configuration de l'espace de travail...",
blocked: {
title: "La configuration n’a pas été exécutée",
description:
"Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.",
run: "Exécuter la configuration",
runFailed: "Échec de la configuration de l’espace de travail",
},
empty: {
noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.",
},
@@ -1116,7 +1137,6 @@ export const fr: TranslationResources = {
addProject: "Ajouter un projet",
newWorkspace: "Nouvel espace de travail",
hosts: "Hôtes",
home: "Maison",
settings: "Paramètres",
closeSidebar: "Fermer la barre latérale",
},
@@ -1133,6 +1153,7 @@ export const fr: TranslationResources = {
},
sections: {
sessions: "Historique",
search: "Rechercher",
schedules: "Planifications",
},
worktreeSetup: {
@@ -2150,6 +2171,12 @@ export const fr: TranslationResources = {
title: "Plan de la discussion",
description: "Afficher un plan pour passer d’une requête à l’autre",
},
sidebar: {
title: "Barre latérale",
description: "Choisissez les éléments affichés en haut de la barre latérale et leur ordre",
moveUp: "Déplacer vers le haut",
moveDown: "Déplacer vers le bas",
},
fonts: {
title: "Polices",
systemDefault: "Valeur par défaut du système",
+30 -3
View File
@@ -10,10 +10,12 @@ export const ja: TranslationResources = {
cancel: "キャンセル",
close: "閉じる",
copy: "コピー",
copyLine: "行をコピー",
dismiss: "閉じる",
retry: "再試行",
search: "検索",
select: "選択",
selectAll: "すべて選択",
},
placeholders: {
search: "検索...",
@@ -33,6 +35,7 @@ export const ja: TranslationResources = {
errors: {
error: "エラー",
unableToSave: "保存できません",
unableToCopy: "コピーできません",
nameRequired: "名前は必須です",
daemonUnavailable: "デーモンが利用できません",
daemonClientUnavailable: "デーモンクライアントが利用できません",
@@ -289,6 +292,7 @@ export const ja: TranslationResources = {
zoomIn: "拡大",
zoomOut: "縮小",
resetZoom: "表示をリセット",
fullscreen: "全画面表示",
viewSource: "ソースを表示",
viewDiagram: "図を表示",
},
@@ -360,7 +364,13 @@ export const ja: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "セッションをインポート",
searchPlaceholder: "セッションを検索...",
scope: {
host: "{{host}} のセッション",
workspace: "このワークスペース",
},
filters: {
all: "すべて",
},
@@ -369,12 +379,13 @@ export const ja: TranslationResources = {
updateHost: "セッションをインポートするにはホストを更新してください。",
noProviders: "インポート可能なプロバイダーが有効になっていません。",
loading: "最近のセッションを読み込み中...",
failedAll: "最近のセッションを読み込めませんでした。",
failedProviders: "{{providers}}のセッションを読み込めませんでした。",
failedProvider: "{{provider}} のセッションを読み込めませんでした",
failedImport: "選択したセッションをインポートできませんでした。",
},
actions: {
refresh: "セッションを更新",
showAll: "すべて表示",
loadMore: "さらに読み込む",
},
preview: {
untitledSession: "無題のセッション",
@@ -382,6 +393,7 @@ export const ja: TranslationResources = {
},
empty: {
noRecent: "インポートする最近のセッションがありません。",
noMatches: "検索に一致するセッションがありません。",
alreadyImported: "最近のセッションはすでにすべてインポートされています。",
noProviderSessions: "{{provider}}のセッションが見つかりません。",
},
@@ -495,14 +507,23 @@ export const ja: TranslationResources = {
completed: "セットアップ完了",
failed: "セットアップ失敗",
workspace: "ワークスペースセットアップ",
blocked: "セットアップはブロックされました",
},
status: {
running: "実行中",
completed: "完了",
failed: "失敗",
waiting: "セットアップ出力を待機中",
blocked: "ブロック済み",
},
waiting: "ワークスペースをセットアップ中...",
blocked: {
title: "セットアップは実行されませんでした",
description:
"このPRは別のリポジトリ {{repository}} からのものです。セットアップとスクリプトは未確認のコードを実行する可能性があります。",
run: "セットアップを実行",
runFailed: "ワークスペースのセットアップに失敗しました",
},
empty: {
noCommands: "このワークスペースでセットアップコマンドは実行されませんでした。",
},
@@ -1094,7 +1115,6 @@ export const ja: TranslationResources = {
addProject: "プロジェクトを追加",
newWorkspace: "新しいワークスペース",
hosts: "ホスト",
home: "ホーム",
settings: "設定",
closeSidebar: "サイドバーを閉じる",
},
@@ -1111,6 +1131,7 @@ export const ja: TranslationResources = {
},
sections: {
sessions: "履歴",
search: "検索",
schedules: "スケジュール",
},
worktreeSetup: {
@@ -2113,6 +2134,12 @@ export const ja: TranslationResources = {
title: "チャットのアウトライン",
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
title: "サイドバー",
description: "サイドバー上部に表示する項目とその順序を選択します",
moveUp: "上に移動",
moveDown: "下に移動",
},
fonts: {
title: "フォント",
systemDefault: "システムデフォルト",
+30 -3
View File
@@ -10,10 +10,12 @@ export const ko: TranslationResources = {
cancel: "취소",
close: "닫기",
copy: "복사",
copyLine: "줄 복사",
dismiss: "닫기",
retry: "다시 시도",
search: "검색",
select: "선택",
selectAll: "모두 선택",
},
placeholders: {
search: "검색...",
@@ -33,6 +35,7 @@ export const ko: TranslationResources = {
errors: {
error: "오류",
unableToSave: "저장할 수 없습니다",
unableToCopy: "복사할 수 없습니다",
nameRequired: "이름을 입력하세요",
daemonUnavailable: "데몬을 사용할 수 없습니다",
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
@@ -287,6 +290,7 @@ export const ko: TranslationResources = {
zoomIn: "확대",
zoomOut: "축소",
resetZoom: "보기 초기화",
fullscreen: "전체 화면",
viewSource: "소스 보기",
viewDiagram: "다이어그램 보기",
},
@@ -358,7 +362,13 @@ export const ko: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "세션 가져오기",
searchPlaceholder: "세션 검색...",
scope: {
host: "{{host}}의 세션",
workspace: "이 워크스페이스",
},
filters: {
all: "전체",
},
@@ -367,12 +377,13 @@ export const ko: TranslationResources = {
updateHost: "세션을 가져오려면 호스트를 업데이트하세요.",
noProviders: "가져올 수 있는 프로바이더가 활성화되어 있지 않습니다.",
loading: "최근 세션을 불러오는 중...",
failedAll: "최근 세션을 불러올 수 없습니다.",
failedProviders: "{{providers}}의 세션을 불러올 수 없습니다.",
failedProvider: "{{provider}} 세션을 불러올 수 없습니다",
failedImport: "선택한 세션을 가져올 수 없습니다.",
},
actions: {
refresh: "세션 새로고침",
showAll: "전체 표시",
loadMore: "더 보기",
},
preview: {
untitledSession: "제목 없는 세션",
@@ -380,6 +391,7 @@ export const ko: TranslationResources = {
},
empty: {
noRecent: "가져올 최근 세션이 없습니다.",
noMatches: "검색과 일치하는 세션이 없습니다.",
alreadyImported: "최근 세션이 모두 이미 가져와졌습니다.",
noProviderSessions: "{{provider}} 세션을 찾을 수 없습니다.",
},
@@ -492,14 +504,23 @@ export const ko: TranslationResources = {
completed: "설정 완료",
failed: "설정 실패",
workspace: "워크스페이스 설정",
blocked: "설정 차단됨",
},
status: {
running: "실행 중",
completed: "완료됨",
failed: "실패함",
waiting: "설정 출력을 기다리는 중",
blocked: "차단됨",
},
waiting: "워크스페이스 설정 중...",
blocked: {
title: "설정이 실행되지 않았습니다",
description:
"이 PR은 다른 저장소인 {{repository}}에서 왔습니다. 설정과 스크립트가 검토하지 않은 코드를 실행할 수 있습니다.",
run: "설정 실행",
runFailed: "워크스페이스 설정 실행 실패",
},
empty: {
noCommands: "이 워크스페이스에서 실행된 설정 명령이 없습니다.",
},
@@ -1089,7 +1110,6 @@ export const ko: TranslationResources = {
addProject: "프로젝트 추가",
newWorkspace: "새 워크스페이스",
hosts: "호스트",
home: "홈",
settings: "설정",
closeSidebar: "사이드바 닫기",
},
@@ -1106,6 +1126,7 @@ export const ko: TranslationResources = {
},
sections: {
sessions: "기록",
search: "검색",
schedules: "일정",
},
worktreeSetup: {
@@ -2107,6 +2128,12 @@ export const ko: TranslationResources = {
title: "채팅 개요",
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
title: "사이드바",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
moveUp: "위로 이동",
moveDown: "아래로 이동",
},
fonts: {
title: "글꼴",
systemDefault: "시스템 기본값",
+30 -3
View File
@@ -10,10 +10,12 @@ export const ptBR: TranslationResources = {
cancel: "Cancelar",
close: "Fechar",
copy: "Copiar",
copyLine: "Copiar linha",
dismiss: "Dispensar",
retry: "Tentar novamente",
search: "Buscar",
select: "Selecionar",
selectAll: "Selecionar tudo",
},
placeholders: {
search: "Buscar...",
@@ -33,6 +35,7 @@ export const ptBR: TranslationResources = {
errors: {
error: "Erro",
unableToSave: "Não foi possível salvar",
unableToCopy: "Não foi possível copiar",
nameRequired: "O nome é obrigatório",
daemonUnavailable: "Daemon indisponível",
daemonClientUnavailable: "Cliente do daemon indisponível",
@@ -289,6 +292,7 @@ export const ptBR: TranslationResources = {
zoomIn: "Aproximar",
zoomOut: "Afastar",
resetZoom: "Redefinir visualização",
fullscreen: "Tela cheia",
viewSource: "Ver fonte",
viewDiagram: "Ver diagrama",
},
@@ -360,7 +364,13 @@ export const ptBR: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "Importar sessão",
searchPlaceholder: "Buscar sessões...",
scope: {
host: "Sessões em {{host}}",
workspace: "Este workspace",
},
filters: {
all: "Tudo",
},
@@ -369,12 +379,13 @@ export const ptBR: TranslationResources = {
updateHost: "Atualize o host para importar sessões.",
noProviders: "Nenhum provedor importável está ativado.",
loading: "Carregando sessões recentes...",
failedAll: "Não foi possível carregar sessões recentes.",
failedProviders: "Não foi possível carregar sessões de {{providers}}.",
failedProvider: "Não foi possível carregar as sessões de {{provider}}",
failedImport: "Não foi possível importar a sessão selecionada.",
},
actions: {
refresh: "Atualizar sessões",
showAll: "Mostrar tudo",
loadMore: "Carregar mais",
},
preview: {
untitledSession: "Sessão sem título",
@@ -382,6 +393,7 @@ export const ptBR: TranslationResources = {
},
empty: {
noRecent: "Nenhuma sessão recente para importar.",
noMatches: "Nenhuma sessão corresponde à sua busca.",
alreadyImported: "Todas as sessões recentes já foram importadas.",
noProviderSessions: "Nenhuma sessão de {{provider}} encontrada.",
},
@@ -495,14 +507,23 @@ export const ptBR: TranslationResources = {
completed: "Configuração concluída",
failed: "Falha na configuração",
workspace: "Configuração do workspace",
blocked: "Configuração bloqueada",
},
status: {
running: "Em execução",
completed: "Concluído",
failed: "Falhou",
waiting: "Aguardando saída da configuração",
blocked: "Bloqueado",
},
waiting: "Configurando workspace...",
blocked: {
title: "A configuração não foi executada",
description:
"Este PR vem de {{repository}}, um repositório diferente. A configuração e os scripts podem executar código que você não revisou.",
run: "Executar configuração",
runFailed: "Falha ao executar a configuração do workspace",
},
empty: {
noCommands: "Nenhum comando de configuração foi executado para este workspace.",
},
@@ -1107,7 +1128,6 @@ export const ptBR: TranslationResources = {
addProject: "Adicionar projeto",
newWorkspace: "Novo workspace",
hosts: "Hosts",
home: "Início",
settings: "Configurações",
closeSidebar: "Fechar barra lateral",
},
@@ -1124,6 +1144,7 @@ export const ptBR: TranslationResources = {
},
sections: {
sessions: "Histórico",
search: "Buscar",
schedules: "Agendamentos",
},
worktreeSetup: {
@@ -2130,6 +2151,12 @@ export const ptBR: TranslationResources = {
title: "Estrutura do chat",
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
title: "Barra lateral",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
moveUp: "Mover para cima",
moveDown: "Mover para baixo",
},
fonts: {
title: "Fontes",
systemDefault: "Sistema padrão",
+31 -3
View File
@@ -10,10 +10,12 @@ export const ru: TranslationResources = {
cancel: "Отмена",
close: "Закрыть",
copy: "Копировать",
copyLine: "Копировать строку",
dismiss: "Отклонить",
retry: "Повторить",
search: "Поиск",
select: "Выбрать",
selectAll: "Выбрать все",
},
placeholders: {
search: "Поиск...",
@@ -33,6 +35,7 @@ export const ru: TranslationResources = {
errors: {
error: "Ошибка",
unableToSave: "Не удалось сохранить",
unableToCopy: "Не удалось скопировать",
nameRequired: "Требуется имя",
daemonUnavailable: "Daemon недоступен",
daemonClientUnavailable: "Daemon клиента недоступен",
@@ -288,6 +291,7 @@ export const ru: TranslationResources = {
zoomIn: "Увеличить масштаб",
zoomOut: "Уменьшить масштаб",
resetZoom: "Сбросить масштаб",
fullscreen: "Полноэкранный режим",
viewSource: "Показать исходный код",
viewDiagram: "Показать диаграмму",
},
@@ -359,7 +363,13 @@ export const ru: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "Импортировать сессию",
searchPlaceholder: "Поиск сессий...",
scope: {
host: "Сессии на {{host}}",
workspace: "Это рабочее пространство",
},
filters: {
all: "Все",
},
@@ -368,12 +378,13 @@ export const ru: TranslationResources = {
updateHost: "Обновите хост, чтобы импортировать сессии.",
noProviders: "Нет включённых провайдеров с поддержкой импорта.",
loading: "Загрузка недавних сессий...",
failedAll: "Не удалось загрузить недавние сессии.",
failedProviders: "Не удалось загрузить сессии следующих провайдеров: {{providers}}.",
failedProvider: "Не удалось загрузить сессии провайдера {{provider}}",
failedImport: "Не удалось импортировать выбранную сессию.",
},
actions: {
refresh: "Обновить список сессий",
showAll: "Показать все",
loadMore: "Загрузить ещё",
},
preview: {
untitledSession: "Сессия без названия",
@@ -381,6 +392,7 @@ export const ru: TranslationResources = {
},
empty: {
noRecent: "Нет недавних сессий для импорта.",
noMatches: "Нет сессий, соответствующих запросу.",
alreadyImported: "Все недавние сессии уже импортированы.",
noProviderSessions: "Сессии провайдера {{provider}} не найдены.",
},
@@ -494,14 +506,23 @@ export const ru: TranslationResources = {
completed: "Настройка завершена",
failed: "Настройка не удалась",
workspace: "Настройка рабочего пространства",
blocked: "Настройка заблокирована",
},
status: {
running: "Выполняется",
completed: "Завершено",
failed: "Завершено с ошибкой",
waiting: "Ожидание вывода команд настройки",
blocked: "Заблокировано",
},
waiting: "Настройка рабочего пространства...",
blocked: {
title: "Настройка не запускалась",
description:
"Этот PR поступил из другого репозитория — {{repository}}. Его настройка и скрипты могут выполнить код, который вы не проверяли.",
run: "Запустить настройку",
runFailed: "Не удалось запустить настройку рабочего пространства",
},
empty: {
noCommands: "Для этого рабочего пространства не выполнялись команды настройки.",
},
@@ -1098,7 +1119,6 @@ export const ru: TranslationResources = {
addProject: "Добавить проект",
newWorkspace: "Новое рабочее пространство",
hosts: "Хосты",
home: "Главная",
settings: "Настройки",
closeSidebar: "Закрыть боковую панель",
},
@@ -1115,6 +1135,7 @@ export const ru: TranslationResources = {
},
sections: {
sessions: "История",
search: "Поиск",
schedules: "Расписания",
},
worktreeSetup: {
@@ -2132,6 +2153,13 @@ export const ru: TranslationResources = {
title: "Оглавление чата",
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
title: "Боковая панель",
description:
"Выберите, какие элементы отображаются вверху боковой панели и в каком порядке",
moveUp: "Переместить вверх",
moveDown: "Переместить вниз",
},
fonts: {
title: "Шрифты",
systemDefault: "Системный шрифт",
+30 -3
View File
@@ -10,10 +10,12 @@ export const zhCN: TranslationResources = {
cancel: "取消",
close: "关闭",
copy: "复制",
copyLine: "复制行",
dismiss: "关闭",
retry: "重试",
search: "搜索",
select: "选择",
selectAll: "全选",
},
placeholders: {
search: "搜索...",
@@ -33,6 +35,7 @@ export const zhCN: TranslationResources = {
errors: {
error: "错误",
unableToSave: "无法保存",
unableToCopy: "无法复制",
nameRequired: "名称必填",
daemonUnavailable: "Daemon 不可用",
daemonClientUnavailable: "Daemon client 不可用",
@@ -286,6 +289,7 @@ export const zhCN: TranslationResources = {
zoomIn: "放大",
zoomOut: "缩小",
resetZoom: "重置视图",
fullscreen: "全屏",
viewSource: "查看源码",
viewDiagram: "查看图表",
},
@@ -357,7 +361,13 @@ export const zhCN: TranslationResources = {
},
},
importSession: {
chooseHostTitle: en.importSession.chooseHostTitle,
title: "导入会话",
searchPlaceholder: "搜索会话...",
scope: {
host: "{{host}} 上的会话",
workspace: "此 workspace",
},
filters: {
all: "全部",
},
@@ -366,12 +376,13 @@ export const zhCN: TranslationResources = {
updateHost: "更新 Host 以导入会话。",
noProviders: "没有已启用的可导入 Provider。",
loading: "正在加载最近会话...",
failedAll: "无法加载最近会话。",
failedProviders: "无法加载 {{providers}} 的会话。",
failedProvider: "无法加载 {{provider}} 的会话",
failedImport: "无法导入所选会话。",
},
actions: {
refresh: "刷新会话",
showAll: "显示全部",
loadMore: "加载更多",
},
preview: {
untitledSession: "未命名会话",
@@ -379,6 +390,7 @@ export const zhCN: TranslationResources = {
},
empty: {
noRecent: "没有可导入的最近会话。",
noMatches: "没有与搜索匹配的会话。",
alreadyImported: "所有最近会话都已导入。",
noProviderSessions: "没有找到 {{provider}} 会话。",
},
@@ -491,14 +503,23 @@ export const zhCN: TranslationResources = {
completed: "Setup 已完成",
failed: "Setup 失败",
workspace: "Workspace setup",
blocked: "设置已阻止",
},
status: {
running: "正在运行",
completed: "已完成",
failed: "失败",
waiting: "正在等待 setup 输出",
blocked: "已阻止",
},
waiting: "正在 setup workspace...",
blocked: {
title: "未运行设置",
description:
"此 PR 来自另一个仓库 {{repository}}。其设置和脚本可能会运行你尚未审查的代码。",
run: "运行设置",
runFailed: "运行工作区设置失败",
},
empty: {
noCommands: "此 workspace 没有运行 setup 命令。",
},
@@ -1074,7 +1095,6 @@ export const zhCN: TranslationResources = {
addProject: "添加 project",
newWorkspace: "新建工作区",
hosts: "Hosts",
home: "首页",
settings: "设置",
closeSidebar: "关闭侧边栏",
},
@@ -1091,6 +1111,7 @@ export const zhCN: TranslationResources = {
},
sections: {
sessions: "历史",
search: "搜索",
schedules: "计划",
},
worktreeSetup: {
@@ -2072,6 +2093,12 @@ export const zhCN: TranslationResources = {
title: "聊天大纲",
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
title: "侧边栏",
description: "选择侧边栏顶部显示的项目及其顺序",
moveUp: "上移",
moveDown: "下移",
},
fonts: {
title: "字体",
systemDefault: "系统默认",
-1
View File
@@ -135,7 +135,6 @@ function ChangesPanel() {
cwd={cwd}
enabled={isActive}
presentation={presentation}
modeScope={tabId}
focusPath={target.kind === "working_diff" ? target.focusPath : undefined}
focusRequestId={target.kind === "working_diff" ? target.focusRequestId : undefined}
onSelectDiffFile={isTree ? handleSelectDiffFile : undefined}
+182 -78
View File
@@ -1,4 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -15,6 +17,7 @@ import {
type WorkspaceSetupSnapshot,
} from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
function useSetupPanelDescriptor(
target: { kind: "setup"; workspaceId: string },
@@ -49,6 +52,17 @@ function useSetupPanelDescriptor(
};
}
if (snapshot?.status === "blocked") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.blocked"),
tooltip: t("workspace.setup.descriptor.blocked"),
titleState: "ready",
icon: CircleAlert,
statusBucket: null,
};
}
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.workspace"),
@@ -100,6 +114,55 @@ type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number];
const EMPTY_COMMANDS: SetupCommand[] = [];
function BlockedSetupNotice({
serverId,
workspaceId,
source,
}: {
serverId: string;
workspaceId: string;
source: NonNullable<WorkspaceSetupSnapshot["blockedSource"]>;
}) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const supportsSetupRun = useHostFeature(serverId, "workspaceSetupRun");
const [runError, setRunError] = useState<string | null>(null);
const [isRunningSetup, setIsRunningSetup] = useState(false);
const runSetup = useCallback(async () => {
if (!client) return;
setRunError(null);
setIsRunningSetup(true);
try {
const response = await client.runWorkspaceSetup(workspaceId);
if (response.error) throw new Error(response.error);
} catch (error) {
setRunError(error instanceof Error ? error.message : t("workspace.setup.blocked.runFailed"));
} finally {
setIsRunningSetup(false);
}
}, [client, t, workspaceId]);
return (
<>
<Alert
variant="warning"
title={t("workspace.setup.blocked.title")}
description={t("workspace.setup.blocked.description", {
repository: source.headRepository,
})}
testID="workspace-setup-blocked"
>
{supportsSetupRun ? (
<Button accessibilityRole="button" onPress={runSetup} loading={isRunningSetup} size="sm">
{t("workspace.setup.blocked.run")}
</Button>
) : null}
</Alert>
{runError ? <Alert variant="error" description={runError} /> : null}
</>
);
}
function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null {
const running = commands.find((c) => c.status === "running");
if (running) return running.index;
@@ -109,11 +172,12 @@ function resolveAutoExpandIndex(commands: { index: number; status: string }[]):
function resolveSetupStatusLabel(
status: string | undefined,
labels: Record<CommandStatus | "waiting", string>,
labels: Record<CommandStatus | "blocked" | "waiting", string>,
): string {
if (status === "running") return labels.running;
if (status === "completed") return labels.completed;
if (status === "failed") return labels.failed;
if (status === "blocked") return labels.blocked;
return labels.waiting;
}
@@ -151,26 +215,18 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
}
function SetupPanel() {
const { t } = useTranslation();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({
serverId,
workspaceId: target.workspaceId,
});
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// On mount, if no snapshot in the store, request cached status from server
const requestedRef = useRef(false);
useEffect(() => {
if (snapshot || requestedRef.current || !client) return;
requestedRef.current = true;
client
.fetchWorkspaceSetupStatus(target.workspaceId)
.fetchWorkspaceSetupStatus(workspaceId)
.then((response) => {
if (response.snapshot) {
upsertProgress({
@@ -181,33 +237,26 @@ function SetupPanel() {
return;
})
.catch(() => {
// Server may not support this yet — ignore
// Server may not support this yet — ignore.
});
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
return snapshot;
}
function useExpandedSetupCommands() {
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(new Set());
const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => {
setExpandedIndices((prev) => {
const next = new Set(prev);
if (next.has(index) || isAutoExpanded) {
next.delete(index);
// If this was auto-expanded, record that the user manually collapsed it
if (isAutoExpanded) {
setManuallyCollapsed((mc) => new Set(mc).add(index));
}
if (isAutoExpanded) setManuallyCollapsed((current) => new Set(current).add(index));
} else {
next.add(index);
// If the user re-expands, remove from manually collapsed
setManuallyCollapsed((mc) => {
const updated = new Set(mc);
setManuallyCollapsed((current) => {
const updated = new Set(current);
updated.delete(index);
return updated;
});
@@ -215,6 +264,23 @@ function SetupPanel() {
return next;
});
}, []);
return { expandedIndices, manuallyCollapsed, toggleExpanded };
}
function SetupPanel() {
const { t } = useTranslation();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
const autoExpandIndex = resolveAutoExpandIndex(commands);
const statusLabels = useMemo(
@@ -222,6 +288,7 @@ function SetupPanel() {
running: t("workspace.setup.status.running"),
completed: t("workspace.setup.status.completed"),
failed: t("workspace.setup.status.failed"),
blocked: t("workspace.setup.status.blocked"),
waiting: t("workspace.setup.status.waiting"),
}),
[t],
@@ -238,60 +305,97 @@ function SetupPanel() {
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
{statusLabel}
</Text>
{isWaiting ? (
<View style={styles.waitingContainer}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
</View>
) : null}
{!isWaiting && hasNoSetupCommands ? (
<View style={styles.emptyContainer}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
>
{t("workspace.setup.empty.noCommands")}
</Text>
</View>
) : null}
{!isWaiting && !hasNoSetupCommands ? (
<View style={styles.commandList}>
{commands.map((command) => {
const rowState = buildCommandRowState({
command,
autoExpandIndex,
log,
expandedIndices,
manuallyCollapsed,
snapshotError: snapshot?.error,
});
return (
<SetupCommandRow
key={`${command.index}:${command.command}`}
command={command}
showDetail={rowState.showDetail}
isAutoExpanded={rowState.isAutoExpanded}
isExpandable={rowState.isExpandable}
hasLog={rowState.hasLog}
hasError={rowState.hasError}
processedLog={rowState.processedLog}
errorMessage={snapshot?.error ?? null}
onToggle={toggleExpanded}
/>
);
})}
<StandaloneLogView commands={commands} log={log} />
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
</View>
) : null}
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
</ScrollView>
);
}
interface SetupPanelBodyProps {
serverId: string;
workspaceId: string;
snapshot: WorkspaceSetupSnapshot | null;
commands: SetupCommand[];
log: string;
isWaiting: boolean;
hasNoSetupCommands: boolean;
autoExpandIndex: number | null;
expandedIndices: Set<number>;
manuallyCollapsed: Set<number>;
onToggle: (index: number, isAutoExpanded: boolean) => void;
}
function SetupPanelBody(props: SetupPanelBodyProps) {
const { t } = useTranslation();
const { snapshot, commands, log } = props;
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
return (
<BlockedSetupNotice
serverId={props.serverId}
workspaceId={props.workspaceId}
source={snapshot.blockedSource}
/>
);
}
if (props.isWaiting) {
return (
<View style={styles.waitingContainer}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
</View>
);
}
if (props.hasNoSetupCommands) {
return (
<View style={styles.emptyContainer}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
>
{t("workspace.setup.empty.noCommands")}
</Text>
</View>
);
}
return (
<View style={styles.commandList}>
{commands.map((command) => {
const rowState = buildCommandRowState({
command,
autoExpandIndex: props.autoExpandIndex,
log,
expandedIndices: props.expandedIndices,
manuallyCollapsed: props.manuallyCollapsed,
snapshotError: snapshot?.error,
});
return (
<SetupCommandRow
key={`${command.index}:${command.command}`}
command={command}
{...rowState}
errorMessage={snapshot?.error ?? null}
onToggle={props.onToggle}
/>
);
})}
<StandaloneLogView commands={commands} log={log} />
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
</View>
);
}
interface SetupCommandRowProps {
command: SetupCommand;
showDetail: boolean;
+1 -7
View File
@@ -3,7 +3,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { pluginRegistry } from "./registry";
import { useSessionStore } from "@/stores/session-store";
export function PluginCatalogSync({
serverId,
@@ -32,15 +31,10 @@ export function PluginCatalogSync({
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) {
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
pluginRegistry.installCatalog(serverId, catalog, {
replacePluginId,
client,
});
if (timelineChanged) {
useSessionStore
.getState()
.sessions[serverId]?.viewedTimelineSync?.reprojectVisibleTimelines();
}
}
return undefined;
})
@@ -1,16 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
import type { PluginComposerPillProps } from "@getpaseo/plugin";
import { afterEach, describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { startPluginClientSide } from "./lifecycle";
import { pluginComposerPillStore } from "./store";
import type { InstalledPlugin } from "./types";
import { createPluginClientRuntime } from "./client-runtime";
import { pluginComposerPillStore } from "./composer-pills/store";
function Pill(_props: PluginComposerPillProps) {
return null;
}
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
function installation(): InstalledPlugin {
return {
id: "review",
serverId: "host-a",
@@ -21,7 +21,7 @@ function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): I
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -39,19 +39,10 @@ afterEach(() => {
});
describe("plugin client-side lifecycle", () => {
it("lets client code add and remove a targeted composer pill at runtime", async () => {
const captured: { client?: PluginClientContext } = {};
let cleanupCount = 0;
const plugin = installation((context) => {
captured.client = context;
return () => {
cleanupCount += 1;
};
});
it("lets the client runtime add and remove a targeted composer pill", () => {
const plugin = installation();
installations.push(plugin);
const stop = startPluginClientSide(plugin, daemonClient);
const client = captured.client;
if (!client) throw new Error("Client lifecycle did not start");
const client = createPluginClientRuntime(plugin, daemonClient);
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
const remove = client.addComposerPill({
@@ -73,23 +64,10 @@ describe("plugin client-side lifecycle", () => {
remove();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
await stop();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
expect(cleanupCount).toBe(1);
});
it("rejects duplicate pills only within the same plugin target", () => {
const plugin = installation(() => () => undefined);
const plugin = installation();
installations.push(plugin);
const contribution = {
id: "review-ready",
@@ -1,25 +1,21 @@
import type {
PluginCleanup,
PluginClientContext,
PluginClientOpenPanelOptions,
} from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin";
import {
createPluginAgentActionContext,
createPluginCapabilities,
createPluginWorkspaceActionContext,
} from "../actions";
import { createPluginClientStateSource } from "../client-state/source";
import { createPluginNavigation } from "../navigation";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import type { InstalledPlugin } from "../types";
import { pluginComposerPillStore } from "./store";
} from "./actions";
import { createPluginClientStateSource } from "./client-state/source";
import type { PluginClientRuntime } from "./evaluate";
import { createPluginNavigation } from "./navigation";
import { pluginComposerPillStore } from "./composer-pills/store";
import { createPluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
export function startPluginClientSide(
export function createPluginClientRuntime(
installation: InstalledPlugin,
daemonClient: DaemonClient,
): PluginCleanup {
if (!installation.clientSide) return () => undefined;
): PluginClientRuntime {
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
if (!runtime) throw new Error("Plugin host is offline");
const state = createPluginClientStateSource(installation.serverId);
@@ -28,7 +24,7 @@ export function startPluginClientSide(
runtime,
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
);
const context: PluginClientContext = {
return {
...capabilities,
addComposerPill(contribution) {
return pluginComposerPillStore.add(installation, contribution);
@@ -37,25 +33,6 @@ export function startPluginClientSide(
openClientPanel({ installation, runtime, state, panelId, options });
},
};
let cleanup: PluginCleanup;
try {
cleanup = installation.clientSide(context);
if (typeof cleanup !== "function") {
throw new Error("Plugin client-side entrypoint must return a cleanup function");
}
} catch (error) {
pluginComposerPillStore.removeInstallation(installation);
throw error;
}
let stopped = false;
return async () => {
if (stopped) return;
stopped = true;
pluginComposerPillStore.removeInstallation(installation);
await cleanup();
};
}
function openClientPanel(input: {
@@ -0,0 +1,78 @@
import { useMemo } from "react";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { createPluginAgentActionContext, createPluginWorkspaceActionContext } from "../actions";
import { createPluginClientStateSource } from "../client-state/source";
import { createPluginNavigation } from "../navigation";
import { useInstalledPlugins } from "../registry";
import { createPluginSurfaceRuntime } from "../surface-runtime";
export interface PluginClientSlashCommand {
pluginId: string;
name: string;
description: string;
argumentHint: string;
run(args: string): Promise<void>;
}
export function usePluginClientSlashCommands(input: {
serverId: string;
workspaceId: string | null | undefined;
agentId: string;
}): PluginClientSlashCommand[] {
const client = useHostRuntimeClient(input.serverId);
const installed = useInstalledPlugins();
return useMemo(() => {
if (!client || !input.workspaceId) return [];
const workspaceId = input.workspaceId;
const state = createPluginClientStateSource(input.serverId);
const navigation = createPluginNavigation({
serverId: input.serverId,
workspaceId,
});
const commands = installed
.filter((plugin) => plugin.serverId === input.serverId)
.flatMap((plugin) => {
const runtime = createPluginSurfaceRuntime(client, plugin.id);
if (!runtime) return [];
return (plugin.clientSlashCommands ?? []).flatMap((contribution) => {
const context =
contribution.context === "agent"
? createPluginAgentActionContext({
plugin,
runtime,
navigation,
state,
workspaceId,
agentId: input.agentId,
})
: createPluginWorkspaceActionContext({
plugin,
runtime,
navigation,
state,
workspaceId,
});
if (!context) return [];
return [
{
pluginId: plugin.id,
name: contribution.name,
description: contribution.description,
argumentHint: contribution.argumentHint,
async run(args: string) {
if (contribution.context === "agent" && context.context === "agent") {
await contribution.onSubmit({ ...context, args });
} else if (
contribution.context === "workspace" &&
context.context === "workspace"
) {
await contribution.onSubmit({ ...context, args });
}
},
},
];
});
});
return commands;
}, [client, input.agentId, input.serverId, input.workspaceId, installed]);
}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import {
executePluginClientSlashCommand,
mergeSlashCommandSources,
resolvePluginClientSlashCommand,
} from "./model";
describe("plugin client slash commands", () => {
it("uses built-in, plugin, then provider precedence", () => {
const collision = vi.fn();
const result = mergeSlashCommandSources({
builtIn: [{ name: "clear", aliases: ["new"] }],
plugins: [
{ name: "clear", pluginId: "review" },
{ name: "new", pluginId: "alias-collision" },
{ name: "review", pluginId: "alpha" },
{ name: "review", pluginId: "beta" },
],
provider: [{ name: "review" }, { name: "usage" }],
onPluginCollision: collision,
});
expect(result.map(({ source, command }) => `${source}:${command.name}`)).toEqual([
"built-in:clear",
"plugin:review",
"provider:usage",
]);
expect(collision).toHaveBeenCalledTimes(3);
});
it("passes the trimmed raw remainder as args", () => {
const command = { name: "review" };
expect(
resolvePluginClientSlashCommand({
text: " /review foo bar ",
hasAttachments: false,
commands: [command],
}),
).toEqual({ command, args: "foo bar" });
expect(
resolvePluginClientSlashCommand({
text: "/review foo",
hasAttachments: true,
commands: [command],
}),
).toBeNull();
});
it("runs the client handler and reports failures", async () => {
const run = vi.fn(async (_args: string) => undefined);
const onError = vi.fn();
await executePluginClientSlashCommand({ command: { run }, args: "foo", onError });
expect(run).toHaveBeenCalledWith("foo");
expect(onError).not.toHaveBeenCalled();
const failure = new Error("review failed");
await executePluginClientSlashCommand({
command: { run: vi.fn().mockRejectedValue(failure) },
args: "bar",
onError,
});
expect(onError).toHaveBeenCalledWith(failure);
});
it("does not make the composer wait for a never-resolving handler", () => {
const run = vi.fn(() => new Promise<void>(() => undefined));
expect(
executePluginClientSlashCommand({ command: { run }, args: "foo", onError: vi.fn() }),
).toBeUndefined();
expect(run).toHaveBeenCalledWith("foo");
});
});
@@ -0,0 +1,70 @@
export interface SlashCommandDescriptor {
name: string;
aliases?: readonly string[];
}
export type AvailableSlashCommand<
BuiltIn extends SlashCommandDescriptor,
Plugin extends SlashCommandDescriptor,
Provider extends SlashCommandDescriptor,
> =
| { source: "built-in"; command: BuiltIn }
| { source: "plugin"; command: Plugin }
| { source: "provider"; command: Provider };
export function mergeSlashCommandSources<
BuiltIn extends SlashCommandDescriptor,
Plugin extends SlashCommandDescriptor,
Provider extends SlashCommandDescriptor,
>(input: {
builtIn: readonly BuiltIn[];
plugins: readonly Plugin[];
provider: readonly Provider[];
onPluginCollision(command: Plugin, winner: "built-in" | "plugin"): void;
}): Array<AvailableSlashCommand<BuiltIn, Plugin, Provider>> {
const commands: Array<AvailableSlashCommand<BuiltIn, Plugin, Provider>> = input.builtIn.map(
(command) => ({ source: "built-in", command }),
);
const claimed = new Map<string, "built-in" | "plugin">();
for (const command of input.builtIn) {
claimed.set(command.name, "built-in");
for (const alias of command.aliases ?? []) claimed.set(alias, "built-in");
}
for (const command of input.plugins) {
const winner = claimed.get(command.name);
if (winner) {
input.onPluginCollision(command, winner);
continue;
}
claimed.set(command.name, "plugin");
commands.push({ source: "plugin", command });
}
for (const command of input.provider) {
if (!claimed.has(command.name)) commands.push({ source: "provider", command });
}
return commands;
}
export function resolvePluginClientSlashCommand<Command extends SlashCommandDescriptor>(input: {
text: string;
hasAttachments: boolean;
commands: readonly Command[];
}): { command: Command; args: string } | null {
if (input.hasAttachments) return null;
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(input.text.trim());
if (!match) return null;
const command = input.commands.find((candidate) => candidate.name === match[1]);
return command ? { command, args: (match[2] ?? "").trim() } : null;
}
export function executePluginClientSlashCommand(input: {
command: { run(args: string): Promise<void> };
args: string;
onError(error: unknown): void;
}): void {
try {
void input.command.run(input.args).catch(input.onError);
} catch (error) {
input.onError(error);
}
}
@@ -97,7 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
onSelect: onAgentSelect,
},
],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
+89 -18
View File
@@ -1,5 +1,19 @@
import { describe, expect, it } from "vitest";
import { evaluatePluginClientBundle } from "./evaluate";
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
const runtime = {
paseo: {},
async rpc() {},
openSurface() {},
openPanel() {},
addComposerPill() {
return () => undefined;
},
} as unknown as PluginClientRuntime;
function evaluatePluginClientBundle(id: string, source: string) {
return runPluginClientBundle(id, source, runtime);
}
function bundle(body: string): string {
return `(function() {
@@ -10,6 +24,73 @@ function bundle(body: string): string {
}
describe("evaluatePluginClientBundle", () => {
it("returns idempotent removers for every client registration", () => {
let pillCount = 0;
const plugin = runPluginClientBundle(
"removals",
bundle(`
function Component() { return null; }
const schema = { safeParse(value) { return { success: true, data: value }; } };
globalThis.__pluginRemovals = [
plugin.addSurface("main", Component),
plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }),
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }),
plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }),
plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }),
plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }),
plugin.addTimelineRenderer({ kind: "card", version: 1, schema, Component }),
];
`),
{
...runtime,
addComposerPill() {
pillCount += 1;
return () => {
pillCount -= 1;
};
},
},
);
const removals = Reflect.get(globalThis, "__pluginRemovals") as Array<() => void>;
expect(
[
plugin.surfaces,
plugin.sidebarItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
plugin.attachmentSources,
plugin.themes,
plugin.timelineTransformers,
plugin.timelineRenderers,
].every((items) => items.length === 1),
).toBe(true);
expect(pillCount).toBe(1);
for (const remove of removals) {
remove();
remove();
}
expect(
[
plugin.surfaces,
plugin.sidebarItems,
plugin.workspacePanels,
plugin.commandCenterItems,
plugin.clientSlashCommands,
plugin.attachmentSources,
plugin.themes,
plugin.timelineTransformers,
plugin.timelineRenderers,
].every((items) => items.length === 0),
).toBe(true);
expect(pillCount).toBe(0);
Reflect.deleteProperty(globalThis, "__pluginRemovals");
});
it("collects timeline transformers and renderers", () => {
const plugin = evaluatePluginClientBundle(
"reports",
@@ -212,26 +293,16 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Duplicate Command Center item: review");
});
it("collects one explicit client-side entrypoint", () => {
it("runs the client entry with the full runtime context", () => {
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
if (!plugin.paseo || !plugin.rpc || !plugin.openSurface || !plugin.openPanel || !plugin.addComposerPill) {
throw new Error("missing client runtime");
}
`),
);
expect(plugin.clientSide).toBeTypeOf("function");
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
plugin.addClientSide(contributeClient);
`),
),
).toThrow("Plugin has more than one client-side entrypoint");
expect(plugin.id).toBe("review");
});
it("rejects duplicate attachment source ids", () => {
@@ -397,11 +468,11 @@ describe("evaluatePluginClientBundle", () => {
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
it("resolves shared RPC helpers from @getpaseo/plugin", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin/server");
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin");
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
const module = { exports: {} };
module.exports.default = function(plugin) {
+147 -50
View File
@@ -10,7 +10,9 @@ import {
defineRpc,
type PluginAttachmentSourceContribution,
type PluginCommandCenterItemContribution,
type PluginClientContribution,
type PluginCleanup,
type PluginClientContext,
type PluginClientSlashCommandContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
type PluginThemeContribution,
@@ -22,7 +24,6 @@ import {
useWorkspace,
useRpc,
} from "@getpaseo/plugin";
import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
import { Icon, resolvePluginIcon } from "./icons";
@@ -67,13 +68,23 @@ function requireId(value: string, label: string): string {
return id;
}
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
const collector: PluginRegistrationCollector = {
export type PluginClientRuntime = Pick<
PluginClientContext,
"paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill"
>;
export function runPluginClientBundle(
id: string,
bundle: string,
runtime: PluginClientRuntime,
onChange: () => void = () => undefined,
): EvaluatedPlugin {
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -83,18 +94,43 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
const sidebarItemIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const clientSlashCommandNames = new Set<string>();
const attachmentSourceIds = new Set<string>();
const themeIds = new Set<string>();
const timelineTransformerIds = new Set<string>();
const timelineRendererIds = new Set<string>();
const pluginContext = createPluginContext({
const removals = new Set<PluginCleanup>();
let setupComplete = false;
const notifyChange = () => {
if (setupComplete) onChange();
};
function register<T>(items: T[], item: T, release: () => void): PluginCleanup {
items.push(item);
notifyChange();
let active = true;
const remove = () => {
if (!active) return;
active = false;
const index = items.indexOf(item);
if (index !== -1) items.splice(index, 1);
release();
removals.delete(remove);
notifyChange();
};
removals.add(remove);
return remove;
}
const pluginContext: PluginClientContext = {
...runtime,
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
if (typeof Component !== "function")
throw new Error(`Surface ${normalizedId} is not a component`);
surfaceIds.add(normalizedId);
collector.surfaces.push({ id: normalizedId, Component });
return register(collector.surfaces, { id: normalizedId, Component }, () =>
surfaceIds.delete(normalizedId),
);
},
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
@@ -104,12 +140,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
sidebarItemIds.add(normalizedId);
collector.sidebarItems.push({
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
});
return register(
collector.sidebarItems,
{
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
},
() => sidebarItemIds.delete(normalizedId),
);
},
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
const normalizedId = requireId(contribution.id, "workspace panel id");
@@ -129,13 +169,17 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
resolvePluginIcon(icon);
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
collector.workspacePanels.push({
...contribution,
id: normalizedId,
title,
icon,
locations,
});
return register(
collector.workspacePanels,
{
...contribution,
id: normalizedId,
title,
icon,
locations,
},
() => workspacePanelIds.delete(normalizedId),
);
},
addCommandCenterItem(contribution: PluginCommandCenterItemContribution) {
const normalizedId = requireId(contribution.id, "Command Center item id");
@@ -158,20 +202,42 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
collector.commandCenterItems.push({
...contribution,
id: normalizedId,
title,
icon,
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
});
return register(
collector.commandCenterItems,
{
...contribution,
id: normalizedId,
title,
icon,
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
},
() => commandCenterItemIds.delete(normalizedId),
);
},
addClientSide(contribution: PluginClientContribution) {
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
if (typeof contribution !== "function") {
throw new Error("Plugin client-side entrypoint is not a function");
addSlashCommand(contribution: PluginClientSlashCommandContribution) {
const name = requireId(contribution.name, "client slash command name");
if (clientSlashCommandNames.has(name)) {
throw new Error(`Duplicate client slash command: ${name}`);
}
collector.clientSide = contribution;
const description = contribution.description.trim();
if (!description) throw new Error(`Client slash command ${name} has no description`);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Client slash command ${name} has invalid context`);
}
if (typeof contribution.onSubmit !== "function") {
throw new Error(`Client slash command ${name} has no callback`);
}
clientSlashCommandNames.add(name);
return register(
collector.clientSlashCommands,
{
...contribution,
name,
description,
argumentHint: contribution.argumentHint.trim(),
},
() => clientSlashCommandNames.delete(name),
);
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
@@ -192,21 +258,25 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
collector.attachmentSources.push({
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
});
return register(
collector.attachmentSources,
{
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
},
() => attachmentSourceIds.delete(normalizedId),
);
},
addTheme(contribution: PluginThemeContribution) {
const normalizedId = requireId(contribution.id, "theme id");
if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`);
const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId });
themeIds.add(normalizedId);
collector.themes.push(theme);
return register(collector.themes, theme, () => themeIds.delete(normalizedId));
},
addTimelineTransformer(contribution: PluginTimelineTransformerContribution) {
const normalizedId = requireId(contribution.id, "timeline transformer id");
@@ -228,7 +298,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
throw new Error(`Invalid timeline transformer id: ${contribution.id}`);
}
timelineTransformerIds.add(normalizedId);
collector.timelineTransformers.push(contribution);
return register(collector.timelineTransformers, contribution, () =>
timelineTransformerIds.delete(normalizedId),
);
},
addTimelineRenderer(contribution: PluginTimelineRendererContribution) {
const kind = requireId(contribution.kind, "timeline renderer kind");
@@ -246,9 +318,23 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
throw new Error(`Timeline renderer ${rendererId} is not a component`);
}
timelineRendererIds.add(rendererId);
collector.timelineRenderers.push({ ...contribution, kind });
return register(collector.timelineRenderers, { ...contribution, kind }, () =>
timelineRendererIds.delete(rendererId),
);
},
});
addComposerPill(contribution) {
const removePill = runtime.addComposerPill(contribution);
let active = true;
const remove = () => {
if (!active) return;
active = false;
removePill();
removals.delete(remove);
};
removals.add(remove);
return remove;
},
};
const runtimeRequire = (name: string): unknown => {
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
@@ -267,8 +353,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@getpaseo/plugin/server") {
return { defineAttachmentSource, defineRpc };
if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") {
return {};
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
@@ -284,8 +370,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (typeof setup !== "function") {
throw new Error(`Plugin ${id} must default export a function`);
}
const cleanup = setup(pluginContext);
if (typeof cleanup !== "function") {
const entryCleanup = setup(pluginContext);
if (typeof entryCleanup !== "function") {
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
}
@@ -297,7 +383,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
}
} catch (error) {
try {
void Promise.resolve(cleanup()).catch((cleanupError) => {
void Promise.resolve(entryCleanup()).catch((cleanupError) => {
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
});
} catch (cleanupError) {
@@ -305,6 +391,17 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
}
throw error;
}
setupComplete = true;
let stopped = false;
const cleanup = async () => {
if (stopped) return;
stopped = true;
try {
await entryCleanup();
} finally {
for (const remove of removals) remove();
}
};
return {
id,
cleanup,
@@ -312,7 +409,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
sidebarItems: collector.sidebarItems,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSide: collector.clientSide,
clientSlashCommands: collector.clientSlashCommands,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
timelineTransformers: collector.timelineTransformers,
-1
View File
@@ -1,6 +1,5 @@
export { PluginCatalogSync } from "./catalog-sync";
export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
export { PluginSidebarItems } from "./sidebar-items";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";

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