diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 9d2eddc7582..b54256d446a 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -95,6 +95,8 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable
**Safety guarantees:**
- Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs.
+- Complete RC draft releases created by the release workflow are published before cutting a new tag, so a GitHub queue failure in the final publish job can be resumed safely.
+- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag before cutting the next RC. This keeps retries from leaving multiple unpublished draft releases behind.
- Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history.
- When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped.
diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml
index 6d68765f903..33645a3c253 100644
--- a/.github/workflows/computer-e2e.yml
+++ b/.github/workflows/computer-e2e.yml
@@ -40,6 +40,14 @@ jobs:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool
+ # Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
+ # permission on Linux runners; node-pty's install fallback then fails
+ # before this smoke job can exercise the native package.
+ - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
+ if: runner.os == 'Linux'
+ run: |
+ npm install -g node-gyp@11.5.0
+ echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
@@ -80,6 +88,12 @@ jobs:
with:
run_install: false
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
+ # Why: keep scheduled Linux e2e on the same native install path as PR
+ # smoke and pr.yml's verify job.
+ - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
+ run: |
+ npm install -g node-gyp@11.5.0
+ echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- run: pnpm verify:computer-native
- run: pnpm build:cli
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index b70a92d35dc..22b332c1a21 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -71,6 +71,9 @@ jobs:
- name: Check feature wall asset budget
run: pnpm check:feature-wall-assets
+ - name: Verify macOS entitlements
+ run: pnpm verify:macos-entitlements
+
- name: Typecheck
run: pnpm typecheck
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 0f2dc8ba6c0..4851157bc4e 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -16,7 +16,7 @@ name: Cut Release
# 5. Write package.json, commit (detached), tag, push tag.
# 6. If ref was the tip of origin/main, fast-forward main to include the
# version-bump commit so developers see the right version locally.
-# 7. Invoke release.yml to build and publish artifacts.
+# 7. Build and publish artifacts from the tag.
on:
workflow_dispatch:
@@ -61,7 +61,7 @@ jobs:
timeout-minutes: 15
outputs:
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
- should_release: ${{ steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
+ should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
steps:
- name: Checkout ref
uses: actions/checkout@v4
@@ -174,9 +174,21 @@ jobs:
echo "Already ran this slot: ${{ steps.existing.outputs.already_ran }}"
echo "Reason: ${{ steps.existing.outputs.reason }}"
+ - name: Publish complete release-cut RC drafts from prior runs
+ id: publish_drafts
+ # Why: this must run even when the current slot already cut a tag; a
+ # later cron retry may be the first chance to unstick a complete RC draft.
+ if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: node config/scripts/publish-complete-draft-releases.mjs
+
- name: Compute next version
id: version
- if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+ # Why: if an RC run only had complete drafts to publish, stop there
+ # instead of immediately cutting another RC after the recovered one.
+ # Stable dispatches should still cut the requested stable release.
+ if: steps.window.outputs.allowed == 'true' && steps.existing.outputs.already_ran != 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) && !((github.event_name == 'schedule' || inputs.kind == 'rc') && steps.publish_drafts.outputs.published_count != '0' && steps.publish_drafts.outputs.skipped_count == '0')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
@@ -238,22 +250,50 @@ jobs:
' "$(strip_pre "$1")" "$2"
}
- next_rc_in_series() {
- # Given a base version, find the highest existing -rc.N for it
- # and return base-rc.(N+1). If none exists, base-rc.0.
+ highest_rc_for_base() {
local base="$1"
- local highest
- highest="$(git tag --list "v${base}-rc.*" \
+ git tag --list "v${base}-rc.*" \
| sed -E "s/^v${base//./\\.}-rc\.//" \
| sort -n \
- | tail -n 1 || true)"
- if [[ -z "$highest" ]]; then
- echo "${base}-rc.0"
+ | tail -n 1 || true
+ }
+
+ release_draft_state() {
+ # Prints: true, false, or missing.
+ local tag="$1"
+ local state_file="$RUNNER_TEMP/release-state-${tag//[^A-Za-z0-9_.-]/_}"
+ if gh release view "$tag" \
+ --repo "$GITHUB_REPOSITORY" \
+ --json isDraft \
+ --jq '.isDraft' >"$state_file" 2>/dev/null; then
+ cat "$state_file"
else
- echo "${base}-rc.$((highest + 1))"
+ echo "missing"
fi
}
+ recover_unpublished_tag() {
+ local tag="$1"
+ local reason="$2"
+ local release_state
+ release_state="$(release_draft_state "$tag")"
+ case "$release_state" in
+ missing|true)
+ echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag."
+ echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT"
+ echo "recovered=true" >>"$GITHUB_OUTPUT"
+ exit 0
+ ;;
+ false)
+ return 1
+ ;;
+ *)
+ echo "::error::Unexpected release state for $tag: $release_state" >&2
+ exit 1
+ ;;
+ esac
+ }
+
# Fresh repo fallback so the math below never divides by zero.
if [[ -z "$latest_stable" ]]; then
latest_stable="0.0.0"
@@ -270,7 +310,18 @@ jobs:
# that class of bug; minor/major RCs are cut by running
# that stable kind first.
base="$(bump "$latest_stable" patch)"
- new="$(next_rc_in_series "$base")"
+ highest_rc="$(highest_rc_for_base "$base")"
+ if [[ -z "$highest_rc" ]]; then
+ new="${base}-rc.0"
+ else
+ existing_rc_tag="v${base}-rc.${highest_rc}"
+ # Why: a failed or GitHub-stuck run can leave the highest RC
+ # tag attached to a draft/missing release. Resume that tag so
+ # retries finish the intended release instead of piling up
+ # vX.Y.Z-rc.N+1 drafts.
+ recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
+ new="${base}-rc.$((highest_rc + 1))"
+ fi
;;
patch|minor|major)
new="$(bump "$latest_stable" "$KIND")"
@@ -289,7 +340,7 @@ jobs:
# Orphan-tag recovery.
#
# Why: if a previous cut pushed the tag but was cancelled (or the
- # dependent release.yml job otherwise failed to start) before the
+ # dependent release build jobs otherwise failed to start) before the
# GitHub Release was published, the tag now exists on the remote
# but "latest stable" still points at the prior version. Every
# subsequent patch cut then recomputes the same version and dies
@@ -301,7 +352,7 @@ jobs:
# Recovery policy: if the tag exists AND no GitHub release has
# been published for it (draft-or-absent both count as "not
# shipped"), treat this as a resumable state: emit the existing
- # tag as the job output so the downstream release.yml job runs
+ # tag as the job output so the downstream release build jobs run
# against it and finishes what the earlier attempt started. The
# bump/commit/push steps are skipped in that case — there is
# nothing to bump; the tag is already on the remote.
@@ -310,32 +361,10 @@ jobs:
# already exist — that's a real conflict (someone tagged manually
# over a shipped version) and needs human attention.
if git rev-parse "v$new" >/dev/null 2>&1; then
- # Why: use the REST API, not `gh release view`. `gh api` returns
- # `true`/`false` for a real release, while a 404 means the tag is
- # orphaned and can be safely re-dispatched. Capture failures
- # explicitly so the 404 payload never leaks into the state check.
- release_state="missing"
- if gh api \
- "repos/$GITHUB_REPOSITORY/releases/tags/v$new" \
- --jq '.draft' >"$RUNNER_TEMP/release-state" 2>/dev/null; then
- release_state="$(cat "$RUNNER_TEMP/release-state")"
- fi
- case "$release_state" in
- missing|true)
- echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag."
- echo "recovered_tag=v$new" >>"$GITHUB_OUTPUT"
- echo "recovered=true" >>"$GITHUB_OUTPUT"
- exit 0
- ;;
- false)
- echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2
- exit 1
- ;;
- *)
- echo "::error::Unexpected release state for v$new: $release_state" >&2
- exit 1
- ;;
- esac
+ recover_unpublished_tag "v$new" "tag collision" || {
+ echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2
+ exit 1
+ }
fi
echo "version=$new" >>"$GITHUB_OUTPUT"
@@ -634,6 +663,12 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ # Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
+ # rejects duplicate entitlements after the expensive app build.
+ - name: Verify macOS entitlements
+ if: matrix.platform == 'mac'
+ run: pnpm verify:macos-entitlements
+
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
# requires the build identity to be the literal string `stable` or `rc`,
# substituted by electron-vite's `define` block at build time. Derive
@@ -674,6 +709,9 @@ jobs:
- name: Build app
run: pnpm build:release
env:
+ # Why: Vite's web build crossed Node's default old-space ceiling on
+ # the macOS release runner, leaving v1.4.2-rc.8 as an incomplete draft.
+ NODE_OPTIONS: --max-old-space-size=4096
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
diff --git a/.gitignore b/.gitignore
index 28e3868ff1c..eca545b5345 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,9 +67,14 @@ design-docs/
.context/
.atl/
+# Machine-local agent hook endpoint files may contain auth tokens.
+/agent-hooks/
+
# Local-only design/planning docs (not checked in)
docs/*.md
+docs/**/*.md
!docs/README*.md
+!docs/**/README*.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -87,3 +92,6 @@ playwright-report/
/.claude/skills/
/.agents/skills/
/skills-lock.json
+
+# Agent hook runtime endpoints (machine-local secrets)
+/agent-hooks/
diff --git a/README.md b/README.md
index 18883b5f506..ccd76d71fe0 100644
--- a/README.md
+++ b/README.md
@@ -9,12 +9,12 @@
- English · 中文 · 日本語 · Español
+ English · 中文 · 日本語 · 한국어 · Español
The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux .
@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
Claude Code
Codex
+ Grok
Gemini
Pi
Hermes Agent
@@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*).
## Features
-- **No login required** — Bring your own Claude Code or Codex subscription.
+- **No login required** — Bring your own Claude Code, Codex, or Grok subscription.
- **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly.
- **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance.
- **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 30fc98ae895..87e87a92fc1 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -7,6 +7,13 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
+// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
+// apps. Keeping relay assets as extraResources makes them real directories
+// instead of paths hidden inside app.asar.
+const relayExtraResource = {
+ from: 'out/relay',
+ to: 'relay'
+}
/** @type {import('electron-builder').Configuration} */
module.exports = {
@@ -39,6 +46,8 @@ module.exports = {
// integration — dependencies inside the asar archive are invisible to
// require(). Unpack CLI runtime deps so they resolve from
// app.asar.unpacked/node_modules/.
+ // Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
+ // before the GUI process starts, so those deps need the same treatment.
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
@@ -49,6 +58,8 @@ module.exports = {
'out/main/computer-sidecar.js',
'out/main/chunks/**',
'resources/**',
+ 'node_modules/ws/**',
+ 'node_modules/tweetnacl/**',
'node_modules/zod/**',
'node_modules/sherpa-onnx*/**'
],
@@ -81,6 +92,7 @@ module.exports = {
win: {
executableName: 'Orca',
extraResources: [
+ relayExtraResource,
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
@@ -134,6 +146,7 @@ module.exports = {
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
extraResources: [
+ relayExtraResource,
{
from: 'resources/darwin/bin/orca',
to: 'bin/orca'
@@ -170,6 +183,7 @@ module.exports = {
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
extraResources: [
+ relayExtraResource,
{
from: 'resources/linux/bin/orca',
to: 'bin/orca'
diff --git a/config/scripts/orca-dev b/config/scripts/orca-dev
index 47bdb1c5b56..10754fe4ca3 100755
--- a/config/scripts/orca-dev
+++ b/config/scripts/orca-dev
@@ -25,4 +25,12 @@ case "$(uname -s)" in
esac
export ORCA_USER_DATA_PATH="${ORCA_DEV_USER_DATA_PATH:-$default_data}"
+electron_executable="$REPO_ROOT/node_modules/.bin/electron"
+case "$(uname -s)" in
+ MINGW*|MSYS*) electron_executable="$REPO_ROOT/node_modules/electron/dist/electron.exe" ;;
+esac
+if [ -z "${ORCA_APP_EXECUTABLE:-}" ] && [ -x "$electron_executable" ]; then
+ export ORCA_APP_EXECUTABLE="$electron_executable"
+ export ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1
+fi
exec node "$CLI_ENTRY" "$@"
diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs
new file mode 100644
index 00000000000..68af1d87c60
--- /dev/null
+++ b/config/scripts/publish-complete-draft-releases.mjs
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+
+import { appendFileSync } from 'node:fs'
+import { pathToFileURL } from 'node:url'
+import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs'
+
+const API_VERSION = '2022-11-28'
+const RELEASE_CUT_AUTHOR = 'github-actions[bot]'
+const DESKTOP_RC_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$/
+
+export function isReleaseCutDraft(release) {
+ return (
+ release?.draft === true &&
+ release?.author?.login === RELEASE_CUT_AUTHOR &&
+ typeof release?.tag_name === 'string' &&
+ DESKTOP_RC_TAG_PATTERN.test(release.tag_name)
+ )
+}
+
+function isRcTag(tag) {
+ return tag.includes('-rc.')
+}
+
+async function githubJson(fetchImpl, url, token, options = {}) {
+ const res = await fetchImpl(url, {
+ ...options,
+ headers: {
+ Accept: 'application/vnd.github+json',
+ Authorization: `Bearer ${token}`,
+ 'X-GitHub-Api-Version': API_VERSION,
+ ...options.headers
+ }
+ })
+ if (!res.ok) {
+ const body = await res.text().catch(() => '')
+ throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`)
+ }
+ return res.json()
+}
+
+async function fetchReleases(repo, token, fetchImpl) {
+ const releases = await githubJson(
+ fetchImpl,
+ `https://api.github.com/repos/${repo}/releases?per_page=100`,
+ token
+ )
+ if (!Array.isArray(releases)) {
+ throw new Error(`GitHub releases response for ${repo} was not an array`)
+ }
+ return releases
+}
+
+export async function publishCompleteDraftReleases({
+ repo,
+ token,
+ fetchImpl = fetch,
+ verifyReleaseAssets = verifyRequiredReleaseAssets,
+ log = console.log
+}) {
+ if (!repo) {
+ throw new Error('repo is required')
+ }
+ if (!token) {
+ throw new Error('token is required')
+ }
+
+ const releases = await fetchReleases(repo, token, fetchImpl)
+ const candidates = releases
+ .filter(isReleaseCutDraft)
+ .sort((a, b) => new Date(a.created_at ?? 0).getTime() - new Date(b.created_at ?? 0).getTime())
+
+ const published = []
+ const skipped = []
+
+ for (const release of candidates) {
+ const tag = release.tag_name
+ try {
+ await verifyReleaseAssets({ repo, tag, token })
+ } catch (error) {
+ const reason = error instanceof Error ? error.message.split('\n')[0] : String(error)
+ skipped.push({ tag, reason })
+ log(`Skipping incomplete RC draft release ${tag}: ${reason}`)
+ continue
+ }
+
+ // Why: only release-cut-authored RC drafts with a complete asset set are
+ // resumed here; incomplete drafts stay private for the normal rebuild path.
+ await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases/${release.id}`, token, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ draft: false,
+ prerelease: isRcTag(tag)
+ })
+ })
+ published.push(tag)
+ log(`Published complete RC draft release ${tag}`)
+ }
+
+ if (published.length === 0 && skipped.length === 0) {
+ log('No complete release-cut RC drafts to publish.')
+ }
+
+ return { published, skipped }
+}
+
+export function writeGithubOutputs({ published, skipped }, outputPath = process.env.GITHUB_OUTPUT) {
+ if (!outputPath) {
+ return
+ }
+ appendFileSync(
+ outputPath,
+ `${[
+ `published_count=${published.length}`,
+ `skipped_count=${skipped.length}`,
+ `published_tags=${published.join(',')}`,
+ `skipped_tags=${skipped.map((item) => item.tag).join(',')}`
+ ].join('\n')}\n`
+ )
+}
+
+async function main() {
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
+ const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
+ const result = await publishCompleteDraftReleases({ repo, token })
+ writeGithubOutputs(result)
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.message)
+ process.exit(1)
+ })
+}
diff --git a/config/scripts/publish-complete-draft-releases.test.mjs b/config/scripts/publish-complete-draft-releases.test.mjs
new file mode 100644
index 00000000000..589f5475b50
--- /dev/null
+++ b/config/scripts/publish-complete-draft-releases.test.mjs
@@ -0,0 +1,136 @@
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ isReleaseCutDraft,
+ publishCompleteDraftReleases,
+ writeGithubOutputs
+} from './publish-complete-draft-releases.mjs'
+
+function jsonResponse(body, init = {}) {
+ return {
+ ok: init.ok ?? true,
+ status: init.status ?? 200,
+ statusText: init.statusText ?? 'OK',
+ json: vi.fn(async () => body),
+ text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body)))
+ }
+}
+
+describe('isReleaseCutDraft', () => {
+ it('only accepts bot-authored desktop draft releases', () => {
+ expect(
+ isReleaseCutDraft({
+ draft: true,
+ tag_name: 'v1.4.2-rc.7',
+ author: { login: 'github-actions[bot]' }
+ })
+ ).toBe(true)
+ expect(
+ isReleaseCutDraft({
+ draft: true,
+ tag_name: 'mobile-v0.0.7',
+ author: { login: 'github-actions[bot]' }
+ })
+ ).toBe(false)
+ expect(
+ isReleaseCutDraft({
+ draft: true,
+ tag_name: 'v1.4.2',
+ author: { login: 'github-actions[bot]' }
+ })
+ ).toBe(false)
+ expect(
+ isReleaseCutDraft({
+ draft: false,
+ tag_name: 'v1.4.2',
+ author: { login: 'github-actions[bot]' }
+ })
+ ).toBe(false)
+ })
+})
+
+describe('publishCompleteDraftReleases', () => {
+ it('publishes complete release-cut drafts and skips incomplete ones', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(
+ jsonResponse([
+ {
+ id: 7,
+ draft: true,
+ tag_name: 'v1.4.2-rc.7',
+ created_at: '2026-05-15T07:31:19Z',
+ author: { login: 'github-actions[bot]' }
+ },
+ {
+ id: 8,
+ draft: true,
+ tag_name: 'v1.4.2-rc.8',
+ created_at: '2026-05-15T10:51:57Z',
+ author: { login: 'github-actions[bot]' }
+ }
+ ])
+ )
+ .mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.2-rc.7', draft: false }))
+ const verifyReleaseAssets = vi.fn(async ({ tag }) => {
+ if (tag === 'v1.4.2-rc.8') {
+ throw new Error('Release v1.4.2-rc.8 is missing required assets.')
+ }
+ })
+ const log = vi.fn()
+
+ const result = await publishCompleteDraftReleases({
+ repo: 'stablyai/orca',
+ token: 'token',
+ fetchImpl,
+ verifyReleaseAssets,
+ log
+ })
+
+ expect(result).toEqual({
+ published: ['v1.4.2-rc.7'],
+ skipped: [
+ {
+ tag: 'v1.4.2-rc.8',
+ reason: 'Release v1.4.2-rc.8 is missing required assets.'
+ }
+ ]
+ })
+ expect(fetchImpl).toHaveBeenLastCalledWith(
+ 'https://api.github.com/repos/stablyai/orca/releases/7',
+ expect.objectContaining({
+ method: 'PATCH',
+ body: JSON.stringify({ draft: false, prerelease: true })
+ })
+ )
+ })
+})
+
+describe('writeGithubOutputs', () => {
+ it('writes count outputs for workflow conditions', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-release-outputs-'))
+ const outputPath = join(dir, 'output')
+ try {
+ writeGithubOutputs(
+ {
+ published: ['v1.4.2-rc.7'],
+ skipped: [{ tag: 'v1.4.2-rc.8', reason: 'missing assets' }]
+ },
+ outputPath
+ )
+
+ expect(readFileSync(outputPath, 'utf8')).toBe(
+ `${[
+ 'published_count=1',
+ 'skipped_count=1',
+ 'published_tags=v1.4.2-rc.7',
+ 'skipped_tags=v1.4.2-rc.8'
+ ].join('\n')}\n`
+ )
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs
index 408206b9838..c3ac1b049a5 100644
--- a/config/scripts/run-electron-vite-dev.mjs
+++ b/config/scripts/run-electron-vite-dev.mjs
@@ -23,9 +23,15 @@ function getDevUserDataPath() {
return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
- return path.join(process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), 'orca-dev')
+ return path.join(
+ process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'),
+ 'orca-dev'
+ )
}
- return path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), 'orca-dev')
+ return path.join(
+ process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'),
+ 'orca-dev'
+ )
}
function prepareDevCliWrapper() {
@@ -33,18 +39,19 @@ function prepareDevCliWrapper() {
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
+ const electronBin = getElectronExecutable()
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'orca-dev.cmd'),
- `@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nnode "${cliPath}" %*\r\n`,
+ `@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
'utf8'
)
} else {
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(
wrapperPath,
- `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
+ `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
'utf8'
)
chmodSync(wrapperPath, 0o755)
@@ -54,6 +61,13 @@ function prepareDevCliWrapper() {
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
}
+function getElectronExecutable() {
+ if (process.platform === 'win32') {
+ return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe')
+ }
+ return path.join(repoRoot, 'node_modules', '.bin', 'electron')
+}
+
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
prepareDevCliWrapper()
}
@@ -109,7 +123,10 @@ function parseDebugPortEnv(raw) {
// space-separated form. `--remote-debugging-pipe` opts into pipe-based
// debugging — don't fight the user's choice by injecting a port.
const userPassedPort = forwardedRaw.some(
- (a) => a === '--remote-debugging-port' || a.startsWith('--remote-debugging-port=') || a === '--remote-debugging-pipe'
+ (a) =>
+ a === '--remote-debugging-port' ||
+ a.startsWith('--remote-debugging-port=') ||
+ a === '--remote-debugging-pipe'
)
// Why: --help/--version exit immediately; binding a probe socket and printing
// a debug-port line would be noise.
@@ -136,7 +153,9 @@ if (!userPassedPort && !isHelpOrVersion) {
// resolve to ::1 on IPv6-first hosts).
console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`)
} else {
- console.error('[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.')
+ console.error(
+ '[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.'
+ )
}
}
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
diff --git a/config/scripts/verify-macos-entitlements.mjs b/config/scripts/verify-macos-entitlements.mjs
new file mode 100644
index 00000000000..20bb7230510
--- /dev/null
+++ b/config/scripts/verify-macos-entitlements.mjs
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url))
+const repoRoot = resolve(__dirname, '../..')
+
+const defaultPlists = [
+ 'resources/build/entitlements.mac.plist',
+ 'resources/build/entitlements.computer-use.mac.plist'
+]
+
+const plistPaths = process.argv.slice(2)
+const pathsToCheck = plistPaths.length > 0 ? plistPaths : defaultPlists
+
+let failed = false
+
+for (const plistPath of pathsToCheck) {
+ const absolutePath = resolve(repoRoot, plistPath)
+ const xml = readFileSync(absolutePath, 'utf8')
+ const problems = findDuplicateDictKeys(xml)
+
+ if (problems.length === 0) {
+ console.log(`${plistPath}: OK`)
+ continue
+ }
+
+ failed = true
+ console.error(`${plistPath}: duplicate plist dict keys found`)
+ for (const problem of problems) {
+ console.error(
+ `- ${problem.key} first appears on line ${problem.firstLine}, duplicated on line ${problem.duplicateLine}`
+ )
+ }
+}
+
+if (failed) {
+ process.exit(1)
+}
+
+function findDuplicateDictKeys(xml) {
+ const problems = []
+ const lineStarts = buildLineStarts(xml)
+ const dictStack = []
+ // Why: `plutil -lint` accepts duplicate keys, but `codesign` rejects
+ // duplicate entitlements during release signing.
+ const tokenPattern =
+ /||]*>([\s\S]*?)<\/key>|<(\/?)dict\b[^>]*>/g
+
+ for (const match of xml.matchAll(tokenPattern)) {
+ if (match[0].startsWith(' B[End]\n```')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('inserts inline math as a rendered rich editor node', () => {
+ const editor = runCommand('inline-math')
+
+ try {
+ expect(editor.getMarkdown()).toBe('$x$')
+ expect(editor.getJSON()).toMatchObject({
+ content: [
+ {
+ content: [
+ {
+ type: 'inlineMath',
+ attrs: { latex: 'x' }
+ }
+ ]
+ }
+ ]
+ })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('inserts display math as a rendered rich editor node', () => {
+ const editor = runCommand('math-block')
+
+ try {
+ expect(editor.getMarkdown()).toBe('$$\nx\n$$')
+ expect(editor.getJSON()).toMatchObject({
+ content: [
+ {
+ type: 'blockMath',
+ attrs: { latex: 'x' }
+ }
+ ]
+ })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('parses existing display math as a rendered rich editor node', () => {
+ const editor = createEditor('Before\n\n$$\nx + y\n$$\n\nAfter')
+
+ try {
+ expect(editor.getJSON()).toMatchObject({
+ content: [
+ { type: 'paragraph' },
+ {
+ type: 'blockMath',
+ attrs: { latex: 'x + y' }
+ },
+ { type: 'paragraph' }
+ ]
+ })
+ expect(editor.getMarkdown()).toBe('Before\n\n$$\nx + y\n$$\n\nAfter')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('inserts a plain unicode emoji', () => {
+ const editor = runCommand('emoji')
+
+ try {
+ expect(editor.getMarkdown()).toBe('🙂')
+ } finally {
+ editor.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx
index aee9405ce83..03da78f3920 100644
--- a/src/renderer/src/components/editor/rich-markdown-commands.tsx
+++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx
@@ -1,6 +1,20 @@
+/* eslint-disable max-lines -- Why: slash commands and doc-link command helpers share trigger-range contracts used by the editor keyboard path. */
import React from 'react'
import type { Editor } from '@tiptap/react'
-import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
+import { TextSelection } from '@tiptap/pm/state'
+import type {} from '@tiptap/extension-mathematics'
+import {
+ Heading1,
+ Heading2,
+ Heading3,
+ ImageIcon,
+ List,
+ ListOrdered,
+ Quote,
+ Sigma,
+ Table2,
+ Workflow
+} from 'lucide-react'
import type { MarkdownDocument } from '../../../../shared/types'
import { stripMarkdownExtension } from './markdown-doc-links'
@@ -41,16 +55,72 @@ export type SlashCommandId =
| 'code-block'
| 'divider'
| 'image'
+ | 'table'
+ | 'mermaid'
+ | 'inline-math'
+ | 'math-block'
+ | 'emoji'
+
+export type SlashCommandIcon =
+ | { kind: 'component'; component: React.ComponentType<{ className?: string }> }
+ | { kind: 'text'; value: string }
+
+export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others'
export type SlashCommand = {
id: SlashCommandId
label: string
aliases: string[]
- icon: React.ComponentType<{ className?: string }>
+ icon: SlashCommandIcon
+ group: SlashCommandGroup
description: string
run: (editor: Editor) => void
}
+function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon {
+ return { kind: 'component', component }
+}
+
+function textIcon(value: string): SlashCommandIcon {
+ return { kind: 'text', value }
+}
+
+function insertTextWithSelection(
+ editor: Editor,
+ text: string,
+ selectionStartOffset?: number,
+ selectionEndOffset = selectionStartOffset
+): void {
+ editor.commands.command(({ state, dispatch }) => {
+ const from = state.selection.from
+ const tr = state.tr.insertText(text, from, state.selection.to)
+
+ if (selectionStartOffset !== undefined) {
+ const selectionFrom = from + selectionStartOffset
+ const selectionTo = from + (selectionEndOffset ?? selectionStartOffset)
+ tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo))
+ }
+
+ dispatch?.(tr.scrollIntoView())
+ return true
+ })
+}
+
+function insertCodeBlock(editor: Editor, language: string, text: string): void {
+ editor.commands.command(({ state, dispatch }) => {
+ const codeBlockType = state.schema.nodes.codeBlock
+ if (!codeBlockType) {
+ return false
+ }
+ const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined)
+ const tr = state.tr.replaceSelectionWith(node).scrollIntoView()
+ const cursor = tr.selection.from + 1
+ tr.setSelection(TextSelection.create(tr.doc, cursor, cursor))
+ dispatch?.(tr)
+ return true
+ })
+}
+
/**
* Executes a slash command by first deleting the typed slash text, then
* delegating to the command's run method. Image is special-cased because
@@ -60,7 +130,8 @@ export function runSlashCommand(
editor: Editor,
slashMenu: { from: number; to: number },
command: SlashCommand,
- onImageCommand?: () => void
+ onImageCommand?: () => void,
+ onEmojiCommand?: () => void
): void {
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
// Why: image insertion cannot rely on window.prompt() in Electron, so this
@@ -69,25 +140,20 @@ export function runSlashCommand(
onImageCommand()
return
}
+ if (command.id === 'emoji' && onEmojiCommand) {
+ onEmojiCommand()
+ return
+ }
command.run(editor)
}
export const slashCommands: SlashCommand[] = [
- {
- id: 'text',
- label: 'Text',
- aliases: ['paragraph', 'plain'],
- icon: List,
- description: 'Start a normal paragraph.',
- run: (editor) => {
- editor.chain().focus().setParagraph().run()
- }
- },
{
id: 'heading-1',
label: 'Heading 1',
aliases: ['h1', 'title'],
- icon: Heading1,
+ icon: icon(Heading1),
+ group: 'Headings',
description: 'Large section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
@@ -99,7 +165,8 @@ export const slashCommands: SlashCommand[] = [
id: 'heading-2',
label: 'Heading 2',
aliases: ['h2'],
- icon: Heading2,
+ icon: icon(Heading2),
+ group: 'Headings',
description: 'Medium section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
@@ -111,7 +178,8 @@ export const slashCommands: SlashCommand[] = [
id: 'heading-3',
label: 'Heading 3',
aliases: ['h3'],
- icon: Heading3,
+ icon: icon(Heading3),
+ group: 'Headings',
description: 'Small section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
@@ -119,51 +187,67 @@ export const slashCommands: SlashCommand[] = [
editor.chain().focus().setHeading({ level: 3 }).run()
}
},
- {
- id: 'task-list',
- label: 'To-do List',
- aliases: ['todo', 'task', 'checkbox'],
- icon: List,
- description: 'Create a checklist.',
- run: (editor) => {
- editor.chain().focus().toggleTaskList().run()
- }
- },
- {
- id: 'bullet-list',
- label: 'Bullet List',
- aliases: ['bullet', 'ul', 'list'],
- icon: List,
- description: 'Create an unordered list.',
- run: (editor) => {
- editor.chain().focus().toggleBulletList().run()
- }
- },
- {
- id: 'ordered-list',
- label: 'Numbered List',
- aliases: ['ordered', 'ol', 'numbered'],
- icon: ListOrdered,
- description: 'Create an ordered list.',
- run: (editor) => {
- editor.chain().focus().toggleOrderedList().run()
- }
- },
{
id: 'blockquote',
label: 'Quote',
aliases: ['quote', 'blockquote'],
- icon: Quote,
+ icon: icon(Quote),
+ group: 'Basic blocks',
description: 'Insert a blockquote.',
run: (editor) => {
editor.chain().focus().toggleBlockquote().run()
}
},
+ {
+ id: 'ordered-list',
+ label: 'Numbered List',
+ aliases: ['ordered', 'ol', 'numbered'],
+ icon: icon(ListOrdered),
+ group: 'Basic blocks',
+ description: 'Create an ordered list.',
+ run: (editor) => {
+ editor.chain().focus().toggleOrderedList().run()
+ }
+ },
+ {
+ id: 'bullet-list',
+ label: 'Bullet List',
+ aliases: ['bullet', 'ul', 'list'],
+ icon: icon(List),
+ group: 'Basic blocks',
+ description: 'Create an unordered list.',
+ run: (editor) => {
+ editor.chain().focus().toggleBulletList().run()
+ }
+ },
+ {
+ id: 'task-list',
+ label: 'Check List',
+ aliases: ['todo', 'task', 'checkbox'],
+ icon: icon(List),
+ group: 'Basic blocks',
+ description: 'Create a checklist.',
+ run: (editor) => {
+ editor.chain().focus().toggleTaskList().run()
+ }
+ },
+ {
+ id: 'text',
+ label: 'Paragraph',
+ aliases: ['paragraph', 'plain'],
+ icon: icon(List),
+ group: 'Basic blocks',
+ description: 'Start a normal paragraph.',
+ run: (editor) => {
+ editor.chain().focus().setParagraph().run()
+ }
+ },
{
id: 'code-block',
label: 'Code Block',
aliases: ['code', 'snippet'],
- icon: List,
+ icon: icon(List),
+ group: 'Basic blocks',
description: 'Insert a fenced code block.',
run: (editor) => {
editor.chain().focus().toggleCodeBlock().run()
@@ -173,23 +257,80 @@ export const slashCommands: SlashCommand[] = [
id: 'divider',
label: 'Divider',
aliases: ['divider', 'rule', 'hr'],
- icon: List,
+ icon: icon(List),
+ group: 'Basic blocks',
description: 'Insert a horizontal rule.',
run: (editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
+ {
+ id: 'table',
+ label: 'Table',
+ aliases: ['grid', 'columns', 'rows'],
+ icon: icon(Table2),
+ group: 'Advanced',
+ description: 'Insert a 3x3 markdown table.',
+ run: (editor) => {
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
+ }
+ },
+ {
+ id: 'mermaid',
+ label: 'Mermaid Diagram',
+ aliases: ['diagram', 'flowchart', 'chart', 'graph'],
+ icon: icon(Workflow),
+ group: 'Advanced',
+ description: 'Insert a Mermaid fenced block.',
+ run: (editor) => {
+ insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]')
+ }
+ },
+ {
+ id: 'inline-math',
+ label: 'Inline Math',
+ aliases: ['math', 'latex', 'equation', 'formula'],
+ icon: icon(Sigma),
+ group: 'Advanced',
+ description: 'Insert inline LaTeX math.',
+ run: (editor) => {
+ editor.commands.insertInlineMath({ latex: 'x' })
+ }
+ },
+ {
+ id: 'math-block',
+ label: 'Math Block',
+ aliases: ['display math', 'latex block', 'equation block'],
+ icon: icon(Sigma),
+ group: 'Advanced',
+ description: 'Insert display LaTeX math.',
+ run: (editor) => {
+ editor.commands.insertBlockMath({ latex: 'x' })
+ }
+ },
{
id: 'image',
label: 'Image',
aliases: ['image', 'img'],
- icon: ImageIcon,
+ icon: icon(ImageIcon),
+ group: 'Media',
description: 'Insert an image from your computer.',
// Why: window.prompt() is not supported in Electron's renderer process,
// so image URL input is handled by an inline input bar in RichMarkdownEditor.
run: (editor) => {
editor.chain().focus().run()
}
+ },
+ {
+ id: 'emoji',
+ label: 'Emoji',
+ aliases: ['smile', 'reaction', 'icon'],
+ icon: textIcon('🙂'),
+ group: 'Others',
+ description: 'Insert a plain Unicode emoji.',
+ run: (editor) => {
+ insertTextWithSelection(editor, '🙂')
+ }
}
]
diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts
index 00479bfd89f..89e8478d134 100644
--- a/src/renderer/src/components/editor/rich-markdown-extensions.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts
@@ -10,9 +10,11 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
+import { BlockMath, InlineMath } from '@tiptap/extension-mathematics'
import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
+import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
import { MarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
@@ -55,7 +57,7 @@ export function createRichMarkdownExtensions({
// and works identically in dev and production modes.
Image.extend({
addStorage() {
- return { filePath: '' }
+ return { filePath: '', runtimeContext: undefined as RuntimeFileOperationArgs | undefined }
},
addNodeView() {
return ({ node, HTMLAttributes }) => {
@@ -79,11 +81,14 @@ export function createRichMarkdownExtensions({
const loadImage = (src: string | undefined): void => {
const fp = this.storage.filePath as string
+ const runtimeContext = this.storage.runtimeContext as
+ | RuntimeFileOperationArgs
+ | undefined
if (src && fp) {
// Why: when IPC resolution fails (e.g. unsupported format),
// the ternary falls back to the raw src so the browser can
// attempt its own loading rather than leaving a broken image.
- void loadLocalImageSrc(src, fp).then((resolved) => {
+ void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => {
img.src = resolved ? resolved : src
})
} else if (src) {
@@ -132,6 +137,17 @@ export function createRichMarkdownExtensions({
TableRow,
TableHeader,
TableCell,
+ InlineMath.configure({
+ katexOptions: {
+ throwOnError: false
+ }
+ }),
+ BlockMath.configure({
+ katexOptions: {
+ displayMode: true,
+ throwOnError: false
+ }
+ }),
RawMarkdownHtmlInline,
RawMarkdownHtmlBlock,
MarkdownDocLink,
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
index 059c0a0448f..17bfb45df53 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
@@ -45,6 +45,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
filteredDocLinkRowsRef: { current: [] },
selectedDocLinkIndexRef: { current: 0 },
handleLocalImagePickRef: { current: vi.fn() },
+ handleEmojiPickRef: { current: vi.fn() },
typedEmptyOrderedListMarkerRef: { current: typedMarker },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
@@ -117,4 +118,31 @@ describe('rich markdown key handler', () => {
editor.destroy()
}
})
+
+ it('keeps slash-menu filter input out of the document', () => {
+ const editor = createEditor({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: '/' }] }]
+ })
+
+ try {
+ editor.commands.setTextSelection(2)
+ let slashMenu = { query: '', from: 1, to: 2, left: 0, top: 0 }
+ const ctx = createContext(editor, false)
+ ctx.slashMenuRef.current = slashMenu
+ ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1' } as never]
+ ctx.setSlashMenu = vi.fn((next) => {
+ slashMenu = typeof next === 'function' ? next(slashMenu) : next
+ ctx.slashMenuRef.current = slashMenu
+ })
+ const event = keyEvent('h')
+
+ expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(editor.getText()).toBe('/')
+ expect(ctx.slashMenuRef.current?.query).toBe('h')
+ } finally {
+ editor.destroy()
+ }
+ })
})
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
index 70591cb00f9..c1bbd9851e1 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
@@ -31,6 +31,7 @@ export type KeyHandlerContext = {
filteredDocLinkRowsRef: MutableRefObject
selectedDocLinkIndexRef: MutableRefObject
handleLocalImagePickRef: MutableRefObject<() => void>
+ handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void>
typedEmptyOrderedListMarkerRef: MutableRefObject
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
@@ -38,7 +39,7 @@ export type KeyHandlerContext = {
setLinkBubble: (bubble: LinkBubbleState | null) => void
setSelectedCommandIndex: Dispatch>
setSelectedDocLinkIndex: Dispatch>
- setSlashMenu: (menu: SlashMenuState | null) => void
+ setSlashMenu: Dispatch>
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
}
@@ -231,6 +232,20 @@ export function createRichMarkdownKeyHandler(
return false
}
+ if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) {
+ event.preventDefault()
+ ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu))
+ ctx.setSelectedCommandIndex(0)
+ return true
+ }
+
+ if (event.key === 'Backspace' && currentSlashMenu.query.length > 0) {
+ event.preventDefault()
+ ctx.setSlashMenu((menu) => (menu ? { ...menu, query: menu.query.slice(0, -1) } : menu))
+ ctx.setSelectedCommandIndex(0)
+ return true
+ }
+
const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current
if (currentFilteredSlashCommands.length === 0) {
return false
@@ -265,8 +280,12 @@ export function createRichMarkdownKeyHandler(
// mirrors the latest highlighted slash-menu item for keyboard picks.
const selectedCommand = currentFilteredSlashCommands[ctx.selectedCommandIndexRef.current]
if (selectedCommand) {
- runSlashCommand(activeEditor, currentSlashMenu, selectedCommand, () =>
- ctx.handleLocalImagePickRef.current()
+ runSlashCommand(
+ activeEditor,
+ currentSlashMenu,
+ selectedCommand,
+ () => ctx.handleLocalImagePickRef.current(),
+ () => ctx.handleEmojiPickRef.current(currentSlashMenu)
)
}
return true
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
new file mode 100644
index 00000000000..7f81eb795c0
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { Editor } from '@tiptap/core'
+import StarterKit from '@tiptap/starter-kit'
+import { Markdown } from '@tiptap/markdown'
+import { normalizeSoftBreaks } from './rich-markdown-normalize'
+
+const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
+
+function createEditor(content: string): Editor {
+ return new Editor({
+ element: null,
+ extensions,
+ content,
+ contentType: 'markdown'
+ })
+}
+
+describe('rich markdown normalization', () => {
+ it('normalizes empty ordered list items into caret targets', () => {
+ const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n')
+
+ try {
+ normalizeSoftBreaks(editor)
+
+ const list = editor.state.doc.child(0)
+ const emptyItem = list.child(2)
+ expect(emptyItem.type.name).toBe('listItem')
+ expect(emptyItem.childCount).toBe(1)
+ expect(emptyItem.child(0).type.name).toBe('paragraph')
+ expect(emptyItem.child(0).content.size).toBe(0)
+ } finally {
+ editor.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts
index a87aa972020..a3ead9a798b 100644
--- a/src/renderer/src/components/editor/rich-markdown-normalize.ts
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts
@@ -10,8 +10,9 @@ import { Fragment, type Node as PmNode } from '@tiptap/pm/model'
* of just one logical line.
*
* This function normalises the ProseMirror document by splitting any paragraph whose
- * text nodes contain `\n` into separate paragraph nodes — one per line. Inline marks
- * (bold, italic, links, etc.) are preserved on each resulting paragraph. This is
+ * text nodes contain `\n` into separate paragraph nodes — one per line — and by
+ * giving empty parsed list items a paragraph caret target. Inline marks (bold,
+ * italic, links, etc.) are preserved on each resulting paragraph. This is
* structurally correct for the editing model: each visual line becomes its own block,
* so the cut handler (and all other block-level operations) work on a per-line basis.
*/
@@ -30,9 +31,24 @@ export function normalizeSoftBreaks(editor: Editor): void {
// Why: doc.forEach only iterates top-level children, so paragraphs nested inside
// blockquotes, table cells, or other container nodes would be missed.
// doc.descendants walks every node at every depth and provides absolute positions.
- const replacements: { from: number; to: number; paragraphs: Fragment[] }[] = []
+ const replacements: (
+ | { from: number; to: number; kind: 'soft-break-paragraphs'; paragraphs: Fragment[] }
+ | { from: number; to: number; kind: 'empty-list-item'; node: PmNode }
+ )[] = []
doc.descendants((node, pos) => {
+ if (node.type.name === 'listItem' && node.childCount === 0) {
+ // Why: marked parses `3. ` immediately before a heading as a list item
+ // with no paragraph. It renders a marker but has no editable caret target.
+ replacements.push({
+ from: pos,
+ to: pos + node.nodeSize,
+ kind: 'empty-list-item',
+ node: node.type.create(node.attrs, paragraphType.create(), node.marks)
+ })
+ return false
+ }
+
if (node.type !== paragraphType) {
return true // continue descending into container nodes
}
@@ -77,6 +93,7 @@ export function normalizeSoftBreaks(editor: Editor): void {
replacements.push({
from: pos,
to: pos + node.nodeSize,
+ kind: 'soft-break-paragraphs',
paragraphs: lines
})
@@ -90,11 +107,16 @@ export function normalizeSoftBreaks(editor: Editor): void {
// Capture the transaction lazily — only after all replacements are collected.
const tr = editor.view.state.tr
- // Apply replacements in reverse order to preserve positions.
- for (let i = replacements.length - 1; i >= 0; i--) {
- const { from, to, paragraphs } = replacements[i]
- const newNodes = paragraphs.map((content) => paragraphType.create(null, content))
- tr.replaceWith(from, to, newNodes)
+ // Apply replacements in reverse document order to preserve positions.
+ replacements.sort((a, b) => b.from - a.from)
+ for (const replacement of replacements) {
+ if (replacement.kind === 'empty-list-item') {
+ tr.replaceWith(replacement.from, replacement.to, replacement.node)
+ continue
+ }
+
+ const newNodes = replacement.paragraphs.map((content) => paragraphType.create(null, content))
+ tr.replaceWith(replacement.from, replacement.to, newNodes)
}
// Why: this normalization is a structural housekeeping step, not a user edit.
diff --git a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts
new file mode 100644
index 00000000000..99cd6e199c7
--- /dev/null
+++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts
@@ -0,0 +1,62 @@
+import { useEffect, useRef } from 'react'
+import * as monaco from 'monaco-editor'
+import type { OpenFile } from '@/store/slices/editor'
+import { cursorPositionCache, diffViewStateCache, scrollTopCache } from '@/lib/scroll-cache'
+
+function deleteCacheEntriesByPrefix(cache: Map, prefix: string): void {
+ for (const key of cache.keys()) {
+ if (key.startsWith(prefix)) {
+ cache.delete(key)
+ }
+ }
+}
+
+export function useClosedEditorTabCleanup(openFiles: OpenFile[]): void {
+ const prevOpenFilesRef = useRef>(new Map())
+
+ useEffect(() => {
+ const currentFilesById = new Map(openFiles.map((f) => [f.id, f]))
+ for (const [prevId, prevFile] of prevOpenFilesRef.current) {
+ if (!currentFilesById.has(prevId)) {
+ disposeClosedEditorTab(prevId, prevFile)
+ }
+ }
+ prevOpenFilesRef.current = currentFilesById
+ }, [openFiles])
+}
+
+function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void {
+ switch (prevFile.mode) {
+ case 'edit':
+ // Why: the edit model URI is constructed via monaco.Uri.parse(filePath)
+ // to match @monaco-editor/react's `path` prop convention.
+ monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose()
+ scrollTopCache.delete(prevFile.filePath)
+ deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`)
+ // Why: markdown and mermaid surfaces keep mode-scoped scroll positions.
+ scrollTopCache.delete(`${prevFile.filePath}:rich`)
+ scrollTopCache.delete(`${prevFile.filePath}:preview`)
+ scrollTopCache.delete(`${prevFile.filePath}:mermaid-diagram`)
+ cursorPositionCache.delete(prevFile.filePath)
+ deleteCacheEntriesByPrefix(cursorPositionCache, `${prevFile.filePath}::`)
+ break
+ case 'markdown-preview':
+ // Why: preview tabs own pane-scoped preview scroll cache entries even
+ // though they do not retain Monaco models.
+ scrollTopCache.delete(`${prevFile.id}:preview`)
+ deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.id}::`)
+ break
+ case 'diff':
+ // Why: kept diff models are keyed by tab id because one file can appear
+ // in multiple diff tabs with different contents.
+ monaco.editor.getModel(monaco.Uri.parse(`diff:original:${prevId}`))?.dispose()
+ monaco.editor.getModel(monaco.Uri.parse(`diff:modified:${prevId}`))?.dispose()
+ diffViewStateCache.delete(prevId)
+ deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`)
+ scrollTopCache.delete(`${prevId}:preview`)
+ deleteCacheEntriesByPrefix(scrollTopCache, `${prevId}::`)
+ break
+ case 'conflict-review':
+ break
+ }
+}
diff --git a/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts b/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts
new file mode 100644
index 00000000000..f89a0ef24f8
--- /dev/null
+++ b/src/renderer/src/components/editor/useEditorCmdSaveRequest.ts
@@ -0,0 +1,50 @@
+import { useEffect } from 'react'
+import { useAppStore } from '@/store'
+import type { OpenFile } from '@/store/slices/editor'
+import { ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT } from './editor-autosave'
+import type { FileContent } from './editor-panel-content-types'
+
+type UseEditorCmdSaveRequestParams = {
+ activeFile: OpenFile | null
+ openFiles: OpenFile[]
+ fileContents: Record
+ handleSave: (content: string) => Promise
+}
+
+export function useEditorCmdSaveRequest({
+ activeFile,
+ openFiles,
+ fileContents,
+ handleSave
+}: UseEditorCmdSaveRequestParams): void {
+ useEffect(() => {
+ const handler = (): void => {
+ if (!activeFile) {
+ return
+ }
+ const saveTargetFile =
+ activeFile.mode === 'markdown-preview'
+ ? (openFiles.find(
+ (openFile) =>
+ openFile.id === activeFile.markdownPreviewSourceFileId && openFile.mode === 'edit'
+ ) ?? null)
+ : activeFile
+ if (!saveTargetFile) {
+ return
+ }
+ // Why: a markdown preview tab is read-only but fronts the same document,
+ // so Cmd/Ctrl+S should save the source editor's current draft.
+ const state = useAppStore.getState()
+ const draft = state.editorDrafts[saveTargetFile.id]
+ if (!draft && !saveTargetFile.isUntitled && !saveTargetFile.isDirty) {
+ return
+ }
+ const fallbackContent =
+ draft ??
+ (activeFile.mode === 'markdown-preview' ? fileContents[activeFile.id]?.content : '')
+ void handleSave(fallbackContent ?? '')
+ }
+ window.addEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler)
+ return () => window.removeEventListener(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT, handler)
+ }, [activeFile, fileContents, handleSave, openFiles])
+}
diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts
new file mode 100644
index 00000000000..f26154be5cd
--- /dev/null
+++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts
@@ -0,0 +1,289 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { OpenFile } from '@/store/slices/editor'
+import { getConnectionId } from '@/lib/connection-context'
+import { useAppStore } from '@/store'
+import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client'
+import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
+import {
+ getRuntimeGitBranchDiff,
+ getRuntimeGitDiff,
+ getRuntimeGitScope
+} from '@/runtime/runtime-git-client'
+import type { DiffContent, FileContent } from './editor-panel-content-types'
+import { canUseChangesModeForFile } from './editor-panel-file-mode'
+import {
+ useEditorPanelExternalContentEvents,
+ usePruneClosedEditorContent
+} from './useEditorPanelExternalContentEvents'
+import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry'
+
+const inFlightFileReads = new Map>()
+const inFlightDiffReads = new Map>()
+
+type GitStatusByWorktree = ReturnType['gitStatusByWorktree']
+type EditorViewModeByFile = ReturnType['editorViewMode']
+
+type UseEditorPanelContentStateParams = {
+ activeFile: OpenFile | null
+ isChangesMode: boolean
+ openFiles: OpenFile[]
+ gitStatusByWorktree: GitStatusByWorktree
+ editorViewMode: EditorViewModeByFile
+}
+
+type UseEditorPanelContentStateResult = {
+ fileContents: Record
+ diffContents: Record
+ reloadFileContent: (file: OpenFile) => void
+}
+
+function inFlightReadKey(connectionId: string | undefined, filePath: string): string {
+ return `${connectionId ?? ''}::${filePath}`
+}
+
+function inFlightDiffKey(
+ file: OpenFile,
+ connectionId: string | undefined,
+ compareAgainstHead = false
+): string {
+ const branch =
+ file.diffSource === 'branch' && file.branchCompare
+ ? `${file.branchCompare.baseOid ?? ''}..${file.branchCompare.headOid ?? ''}::${file.branchOldPath ?? ''}`
+ : ''
+ return `${connectionId ?? ''}::${file.diffSource ?? ''}::${compareAgainstHead ? 'head' : 'default'}::${file.filePath}::${branch}`
+}
+
+export function useEditorPanelContentState({
+ activeFile,
+ isChangesMode,
+ openFiles,
+ gitStatusByWorktree,
+ editorViewMode
+}: UseEditorPanelContentStateParams): UseEditorPanelContentStateResult {
+ const [fileContents, setFileContents] = useState>({})
+ const [diffContents, setDiffContents] = useState>({})
+ const fileLoadRetryAttemptsRef = useRef>({})
+ const openFilesRef = useRef(openFiles)
+ openFilesRef.current = openFiles
+ const editorViewModeRef = useRef(editorViewMode)
+ editorViewModeRef.current = editorViewMode
+
+ const loadFileContent = useCallback(
+ async (filePath: string, id: string, worktreeId?: string): Promise => {
+ try {
+ const connectionId = getConnectionId(worktreeId ?? null) ?? undefined
+ const restoredOpenFile = openFilesRef.current.find((file) => file.id === id)
+ const activeSettings = useAppStore.getState().settings
+ const readSettings = settingsForRuntimeOwner(
+ activeSettings,
+ restoredOpenFile?.runtimeEnvironmentId
+ )
+ if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) {
+ if (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) {
+ // Why: restored external-file tabs contain client-local absolute
+ // paths. Remote runtime and SSH workspaces cannot read those paths
+ // without an explicit upload/import flow.
+ throw new Error('External local files are not available for remote workspaces.')
+ }
+ // Why: restored external-file tabs need their main-process path grant
+ // refreshed because that authorization is only held in memory.
+ await window.api.fs.authorizeExternalPath({ targetPath: filePath })
+ }
+ const readScope = getRuntimeFileReadScope(readSettings, connectionId)
+ const key = inFlightReadKey(readScope, filePath)
+ let pending = inFlightFileReads.get(key)
+ if (!pending) {
+ pending = readRuntimeFileContent({
+ settings: readSettings,
+ filePath,
+ relativePath: restoredOpenFile?.relativePath,
+ worktreeId,
+ connectionId
+ }) as Promise
+ inFlightFileReads.set(key, pending)
+ queueMicrotask(() => {
+ if (inFlightFileReads.get(key) === pending) {
+ inFlightFileReads.delete(key)
+ }
+ })
+ }
+ const result = await pending
+ delete fileLoadRetryAttemptsRef.current[id]
+ setFileContents((prev) => ({ ...prev, [id]: result }))
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ setFileContents((prev) => ({
+ ...prev,
+ [id]: { content: '', isBinary: false, loadError: message }
+ }))
+ }
+ },
+ []
+ )
+
+ const loadDiffContent = useCallback(async (file: OpenFile | null): Promise => {
+ if (!file || (file.mode === 'edit' && !canUseChangesModeForFile(file))) {
+ return
+ }
+ try {
+ const worktreePath = file.filePath.slice(
+ 0,
+ file.filePath.length - file.relativePath.length - 1
+ )
+ const branchCompare =
+ file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
+ ? file.branchCompare
+ : null
+ const connectionId = getConnectionId(file.worktreeId) ?? undefined
+ const activeSettings = useAppStore.getState().settings
+ const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId)
+ const gitScope = getRuntimeGitScope(fileSettings, connectionId)
+ const effectiveDiffSource: typeof file.diffSource =
+ file.mode === 'edit' ? 'unstaged' : file.diffSource
+ const compareAgainstHead = file.mode === 'edit'
+ const key = inFlightDiffKey(
+ { ...file, diffSource: effectiveDiffSource },
+ gitScope,
+ compareAgainstHead
+ )
+ let pending = inFlightDiffReads.get(key)
+ if (!pending) {
+ pending = (
+ effectiveDiffSource === 'branch' && branchCompare
+ ? getRuntimeGitBranchDiff(
+ {
+ settings: fileSettings,
+ worktreeId: file.worktreeId,
+ worktreePath,
+ connectionId
+ },
+ {
+ compare: {
+ baseRef: branchCompare.baseRef,
+ baseOid: branchCompare.baseOid!,
+ headOid: branchCompare.headOid!,
+ mergeBase: branchCompare.mergeBase!
+ },
+ filePath: file.relativePath,
+ oldPath: file.branchOldPath
+ }
+ )
+ : getRuntimeGitDiff(
+ {
+ settings: fileSettings,
+ worktreeId: file.worktreeId,
+ worktreePath,
+ connectionId
+ },
+ {
+ filePath: file.relativePath,
+ staged: effectiveDiffSource === 'staged',
+ compareAgainstHead
+ }
+ )
+ ) as Promise
+ inFlightDiffReads.set(key, pending)
+ queueMicrotask(() => {
+ if (inFlightDiffReads.get(key) === pending) {
+ inFlightDiffReads.delete(key)
+ }
+ })
+ }
+ const result = await pending
+ setDiffContents((prev) => ({ ...prev, [file.id]: result }))
+ } catch (err) {
+ setDiffContents((prev) => ({
+ ...prev,
+ [file.id]: {
+ kind: 'text',
+ originalContent: '',
+ modifiedContent: `Error loading diff: ${err}`,
+ originalIsBinary: false,
+ modifiedIsBinary: false
+ }
+ }))
+ }
+ }, [])
+
+ const reloadFileContent = useCallback(
+ (file: OpenFile): void => {
+ delete fileLoadRetryAttemptsRef.current[file.id]
+ setFileContents((prev) => {
+ if (!prev[file.id]) {
+ return prev
+ }
+ const next = { ...prev }
+ delete next[file.id]
+ return next
+ })
+ void loadFileContent(file.filePath, file.id, file.worktreeId)
+ },
+ [loadFileContent]
+ )
+
+ useEffect(() => {
+ if (!activeFile || activeFile.mode === 'conflict-review') {
+ return
+ }
+ if (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') {
+ if (activeFile.conflict?.kind === 'conflict-placeholder') {
+ return
+ }
+ if (!fileContents[activeFile.id]) {
+ void loadFileContent(activeFile.filePath, activeFile.id, activeFile.worktreeId)
+ }
+ if (isChangesMode && !diffContents[activeFile.id]) {
+ void loadDiffContent(activeFile)
+ }
+ } else if (
+ activeFile.mode === 'diff' &&
+ activeFile.diffSource !== undefined &&
+ activeFile.diffSource !== 'combined-uncommitted' &&
+ activeFile.diffSource !== 'combined-branch' &&
+ !diffContents[activeFile.id]
+ ) {
+ void loadDiffContent(activeFile)
+ }
+ }, [activeFile?.id, isChangesMode]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEditorPanelFileLoadRetry({
+ activeFile,
+ fileContents,
+ fileLoadRetryAttemptsRef,
+ loadFileContent,
+ openFilesRef,
+ setFileContents
+ })
+
+ const changesStatusEntries = activeFile?.worktreeId
+ ? gitStatusByWorktree[activeFile.worktreeId]
+ : undefined
+ useEffect(() => {
+ if (!isChangesMode || !activeFile?.id) {
+ return
+ }
+ const current = openFilesRef.current.find((f) => f.id === activeFile.id)
+ if (current) {
+ void loadDiffContent(current)
+ }
+ }, [
+ changesStatusEntries,
+ isChangesMode,
+ activeFile?.id,
+ activeFile?.worktreeId,
+ activeFile?.relativePath,
+ loadDiffContent
+ ])
+
+ useEditorPanelExternalContentEvents({
+ loadDiffContent,
+ loadFileContent,
+ openFilesRef,
+ editorViewModeRef,
+ setFileContents,
+ setDiffContents
+ })
+ usePruneClosedEditorContent(openFiles, fileLoadRetryAttemptsRef, setFileContents, setDiffContents)
+
+ return { fileContents, diffContents, reloadFileContent }
+}
diff --git a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts
new file mode 100644
index 00000000000..bf82c7bbc09
--- /dev/null
+++ b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts
@@ -0,0 +1,132 @@
+import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
+import type { useAppStore } from '@/store'
+import type { OpenFile } from '@/store/slices/editor'
+import {
+ getOpenFilesForExternalFileChange,
+ ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
+ ORCA_EDITOR_FILE_SAVED_EVENT,
+ type EditorFileSavedDetail,
+ type EditorPathMutationTarget
+} from './editor-autosave'
+import type { DiffContent, FileContent } from './editor-panel-content-types'
+
+type EditorViewModeByFile = ReturnType['editorViewMode']
+
+type UseEditorPanelExternalContentEventsParams = {
+ loadDiffContent: (file: OpenFile | null) => Promise
+ loadFileContent: (filePath: string, id: string, worktreeId?: string) => Promise
+ openFilesRef: MutableRefObject
+ editorViewModeRef: MutableRefObject
+ setFileContents: Dispatch>>
+ setDiffContents: Dispatch>>
+}
+
+export function useEditorPanelExternalContentEvents({
+ loadDiffContent,
+ loadFileContent,
+ openFilesRef,
+ editorViewModeRef,
+ setFileContents,
+ setDiffContents
+}: UseEditorPanelExternalContentEventsParams): void {
+ useEffect(() => {
+ const handler = (event: Event): void => {
+ const detail = (event as CustomEvent).detail
+ if (!detail) {
+ return
+ }
+ for (const file of getOpenFilesForExternalFileChange(openFilesRef.current, detail)) {
+ if (file.mode === 'edit' || file.mode === 'markdown-preview') {
+ void loadFileContent(file.filePath, file.id, file.worktreeId)
+ if (editorViewModeRef.current[file.id] === 'changes') {
+ void loadDiffContent(file)
+ }
+ } else if (
+ file.mode === 'diff' &&
+ file.diffSource !== 'combined-uncommitted' &&
+ file.diffSource !== 'combined-branch'
+ ) {
+ void loadDiffContent(file)
+ }
+ }
+ }
+ window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
+ return () =>
+ window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener)
+ }, [editorViewModeRef, loadDiffContent, loadFileContent, openFilesRef])
+
+ useEffect(() => {
+ const handler = (event: Event): void => {
+ const detail = (event as CustomEvent).detail
+ if (!detail) {
+ return
+ }
+ const file = openFilesRef.current.find((openFile) => openFile.id === detail.fileId)
+ if (!file) {
+ return
+ }
+ if (file.mode === 'edit' || file.mode === 'markdown-preview') {
+ setFileContents((prev) => ({
+ ...prev,
+ [file.id]: { content: detail.content, isBinary: false }
+ }))
+ }
+ updateSavedPreviewTabs(openFilesRef.current, detail, setFileContents)
+ if (file.mode === 'edit' || file.mode === 'markdown-preview') {
+ return
+ }
+ setDiffContents((prev) => {
+ const existing = prev[file.id]
+ if (!existing || existing.kind !== 'text') {
+ return prev
+ }
+ return { ...prev, [file.id]: { ...existing, modifiedContent: detail.content } }
+ })
+ }
+ window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, handler as EventListener)
+ return () => window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, handler as EventListener)
+ }, [openFilesRef, setDiffContents, setFileContents])
+}
+
+function updateSavedPreviewTabs(
+ openFiles: OpenFile[],
+ detail: EditorFileSavedDetail,
+ setFileContents: Dispatch>>
+): void {
+ const previewTabs = openFiles.filter(
+ (openFile) =>
+ openFile.mode === 'markdown-preview' && openFile.markdownPreviewSourceFileId === detail.fileId
+ )
+ if (previewTabs.length === 0) {
+ return
+ }
+ setFileContents((prev) => {
+ const next = { ...prev }
+ for (const previewTab of previewTabs) {
+ next[previewTab.id] = { content: detail.content, isBinary: false }
+ }
+ return next
+ })
+}
+
+export function usePruneClosedEditorContent(
+ openFiles: OpenFile[],
+ fileLoadRetryAttemptsRef: MutableRefObject>,
+ setFileContents: Dispatch>>,
+ setDiffContents: Dispatch>>
+): void {
+ useEffect(() => {
+ const openIds = new Set(openFiles.map((f) => f.id))
+ for (const fileId of Object.keys(fileLoadRetryAttemptsRef.current)) {
+ if (!openIds.has(fileId)) {
+ delete fileLoadRetryAttemptsRef.current[fileId]
+ }
+ }
+ setFileContents((prev) =>
+ Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key)))
+ )
+ setDiffContents((prev) =>
+ Object.fromEntries(Object.entries(prev).filter(([key]) => openIds.has(key)))
+ )
+ }, [fileLoadRetryAttemptsRef, openFiles, setDiffContents, setFileContents])
+}
diff --git a/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
new file mode 100644
index 00000000000..0c9f7bff42c
--- /dev/null
+++ b/src/renderer/src/components/editor/useEditorPanelFileLoadRetry.ts
@@ -0,0 +1,80 @@
+import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
+import type { OpenFile } from '@/store/slices/editor'
+import type { FileContent } from './editor-panel-content-types'
+
+const FILE_LOAD_RETRY_DELAYS_MS = [250, 1000, 2500]
+
+type UseEditorPanelFileLoadRetryParams = {
+ activeFile: OpenFile | null
+ fileContents: Record
+ fileLoadRetryAttemptsRef: MutableRefObject>
+ loadFileContent: (filePath: string, id: string, worktreeId?: string) => Promise
+ openFilesRef: MutableRefObject
+ setFileContents: Dispatch>>
+}
+
+function shouldRetryFileLoadError(message: string): boolean {
+ const lower = message.toLowerCase()
+ return (
+ !lower.includes('access denied') &&
+ !lower.includes('enoent') &&
+ !lower.includes('no such file') &&
+ !lower.includes('file too large')
+ )
+}
+
+export function useEditorPanelFileLoadRetry({
+ activeFile,
+ fileContents,
+ fileLoadRetryAttemptsRef,
+ loadFileContent,
+ openFilesRef,
+ setFileContents
+}: UseEditorPanelFileLoadRetryParams): void {
+ const activeFileLoadRetryId = activeFile?.id ?? null
+ const activeFileLoadError = activeFileLoadRetryId
+ ? fileContents[activeFileLoadRetryId]?.loadError
+ : undefined
+
+ useEffect(() => {
+ if (
+ !activeFileLoadRetryId ||
+ !activeFileLoadError ||
+ !shouldRetryFileLoadError(activeFileLoadError)
+ ) {
+ return
+ }
+ const retryCount = fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] ?? 0
+ if (retryCount >= FILE_LOAD_RETRY_DELAYS_MS.length) {
+ return
+ }
+ const delayMs = FILE_LOAD_RETRY_DELAYS_MS[retryCount] ?? FILE_LOAD_RETRY_DELAYS_MS[0]
+ fileLoadRetryAttemptsRef.current[activeFileLoadRetryId] = retryCount + 1
+ const timeoutId = window.setTimeout(() => {
+ const currentFile = openFilesRef.current.find((file) => file.id === activeFileLoadRetryId)
+ if (
+ !currentFile ||
+ (currentFile.mode !== 'edit' && currentFile.mode !== 'markdown-preview')
+ ) {
+ return
+ }
+ setFileContents((prev) => {
+ if (prev[currentFile.id]?.loadError !== activeFileLoadError) {
+ return prev
+ }
+ const next = { ...prev }
+ delete next[currentFile.id]
+ return next
+ })
+ void loadFileContent(currentFile.filePath, currentFile.id, currentFile.worktreeId)
+ }, delayMs)
+ return () => window.clearTimeout(timeoutId)
+ }, [
+ activeFileLoadRetryId,
+ activeFileLoadError,
+ fileLoadRetryAttemptsRef,
+ loadFileContent,
+ openFilesRef,
+ setFileContents
+ ])
+}
diff --git a/src/renderer/src/components/editor/useLinkBubble.ts b/src/renderer/src/components/editor/useLinkBubble.ts
index a91b199b110..76941e0dc43 100644
--- a/src/renderer/src/components/editor/useLinkBubble.ts
+++ b/src/renderer/src/components/editor/useLinkBubble.ts
@@ -20,6 +20,7 @@ export function useLinkBubble(
sourceFilePath: string
worktreeId: string
worktreeRoot: string | null
+ runtimeEnvironmentId?: string | null
}
): {
handleLinkSave: (href: string) => void
@@ -108,7 +109,8 @@ export function useLinkBubble(
void activateMarkdownLink(linkBubble.href, {
sourceFilePath: linkContext.sourceFilePath,
worktreeId: linkContext.worktreeId,
- worktreeRoot: linkContext.worktreeRoot
+ worktreeRoot: linkContext.worktreeRoot,
+ runtimeEnvironmentId: linkContext.runtimeEnvironmentId
})
}, [
activateMarkdownLink,
@@ -116,6 +118,7 @@ export function useLinkBubble(
linkContext.sourceFilePath,
linkContext.worktreeId,
linkContext.worktreeRoot,
+ linkContext.runtimeEnvironmentId,
rootRef
])
diff --git a/src/renderer/src/components/editor/useLocalImagePick.ts b/src/renderer/src/components/editor/useLocalImagePick.ts
index 8c1446ac236..444a849ca1d 100644
--- a/src/renderer/src/components/editor/useLocalImagePick.ts
+++ b/src/renderer/src/components/editor/useLocalImagePick.ts
@@ -2,8 +2,18 @@ import { useCallback } from 'react'
import { toast } from 'sonner'
import type { Editor } from '@tiptap/react'
import { extractIpcErrorMessage, getImageCopyDestination } from './rich-markdown-image-utils'
+import { useAppStore } from '@/store'
+import { getConnectionId } from '@/lib/connection-context'
+import { basename, dirname } from '@/lib/path'
+import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
+import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
-export function useLocalImagePick(editor: Editor | null, filePath: string): () => Promise {
+export function useLocalImagePick(
+ editor: Editor | null,
+ filePath: string,
+ worktreeId: string | null,
+ runtimeEnvironmentId?: string | null
+): () => Promise {
return useCallback(async () => {
if (!editor) {
return
@@ -18,6 +28,45 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () =
if (!srcPath) {
return
}
+ const connectionId = getConnectionId(worktreeId) ?? undefined
+ const settings = settingsForRuntimeOwner(
+ useAppStore.getState().settings,
+ runtimeEnvironmentId
+ )
+ if (settings?.activeRuntimeEnvironmentId?.trim() || connectionId) {
+ const worktreePath = getWorktreePath(worktreeId)
+ if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) {
+ toast.error('Worktree path not available.')
+ return
+ }
+ // Why: picked images are client-local files while remote markdown lives
+ // on the server. Upload beside the markdown file before inserting the
+ // relative image path so preview/save works from any client.
+ const { results } = await importExternalPathsToRuntime(
+ {
+ settings,
+ worktreeId,
+ worktreePath,
+ connectionId
+ },
+ [srcPath],
+ dirname(filePath)
+ )
+ const imported = results.find((result) => result.status === 'imported')
+ if (!imported) {
+ toast.error('Failed to insert image.')
+ return
+ }
+ editor
+ .chain()
+ .focus()
+ .insertContentAt(insertPos, {
+ type: 'image',
+ attrs: { src: basename(imported.destPath) }
+ })
+ .run()
+ return
+ }
// Why: copy the image next to the markdown file and insert a relative path
// so the markdown stays portable and doesn't bloat with base64 data.
const { imageName, destPath } = await getImageCopyDestination(filePath, srcPath)
@@ -35,5 +84,14 @@ export function useLocalImagePick(editor: Editor | null, filePath: string): () =
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to insert image.'))
}
- }, [editor, filePath])
+ }, [editor, filePath, runtimeEnvironmentId, worktreeId])
+}
+
+function getWorktreePath(worktreeId: string | null): string | null {
+ if (!worktreeId) {
+ return null
+ }
+ const state = useAppStore.getState()
+ const worktrees = Object.values(state.worktreesByRepo ?? {}).flat()
+ return worktrees.find((worktree) => worktree.id === worktreeId)?.path ?? null
}
diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts
new file mode 100644
index 00000000000..736be5a7672
--- /dev/null
+++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import { getLocalImageCacheKey } from './useLocalImageSrc'
+
+describe('getLocalImageCacheKey', () => {
+ it('scopes local markdown image cache entries by runtime owner', () => {
+ const localKey = getLocalImageCacheKey('/repo/docs/logo.png', null, {
+ settings: { activeRuntimeEnvironmentId: null },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ })
+ const remoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, {
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ })
+ const otherRemoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, {
+ settings: { activeRuntimeEnvironmentId: 'env-2' },
+ worktreeId: 'wt-1',
+ worktreePath: '/repo'
+ })
+
+ expect(localKey).not.toBe(remoteKey)
+ expect(remoteKey).not.toBe(otherRemoteKey)
+ })
+})
diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts
index 3206e99b353..1f55756fba7 100644
--- a/src/renderer/src/components/editor/useLocalImageSrc.ts
+++ b/src/renderer/src/components/editor/useLocalImageSrc.ts
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { resolveImageAbsolutePath } from './markdown-preview-links'
+import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
+import { readRuntimeFilePreview } from '@/runtime/runtime-file-client'
// Why: the renderer is served from http://localhost in dev mode, so file://
// URLs in tags are blocked by cross-origin restrictions. Loading images
@@ -9,6 +11,21 @@ import { resolveImageAbsolutePath } from './markdown-preview-links'
const BLOB_URL_CACHE_MAX_SIZE = 100
const blobUrlCache = new Map()
+export function getLocalImageCacheKey(
+ absolutePath: string,
+ connectionId?: string | null,
+ runtimeContext?: Omit & { connectionId?: string | null }
+): string {
+ const runtimeEnvironmentId =
+ runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client'
+ return [
+ runtimeEnvironmentId,
+ runtimeContext?.connectionId ?? connectionId ?? 'local',
+ runtimeContext?.worktreeId ?? 'unknown-worktree',
+ absolutePath
+ ].join('\0')
+}
+
// Why: blob URLs hold references to in-memory Blob objects; without eviction
// the cache grows without bound and leaks memory. We evict the oldest entry
// (Map iteration order is insertion order) and revoke its blob URL so the
@@ -97,7 +114,8 @@ function isExternalUrl(src: string): boolean {
export function useLocalImageSrc(
rawSrc: string | undefined,
filePath: string,
- connectionId?: string | null
+ connectionId?: string | null,
+ runtimeContext?: Omit & { connectionId?: string | null }
): string | undefined {
const [generation, setGeneration] = useState(cacheGeneration)
@@ -113,8 +131,11 @@ export function useLocalImageSrc(
return rawSrc
}
const absolutePath = resolveImageAbsolutePath(rawSrc, filePath)
- if (absolutePath && blobUrlCache.has(absolutePath)) {
- return blobUrlCache.get(absolutePath)
+ if (absolutePath) {
+ const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext)
+ if (blobUrlCache.has(cacheKey)) {
+ return blobUrlCache.get(cacheKey)
+ }
}
return undefined
})
@@ -136,21 +157,21 @@ export function useLocalImageSrc(
return
}
- if (blobUrlCache.has(absolutePath)) {
- setDisplaySrc(blobUrlCache.get(absolutePath))
+ const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext)
+ if (blobUrlCache.has(cacheKey)) {
+ setDisplaySrc(blobUrlCache.get(cacheKey))
return
}
let cancelled = false
- window.api.fs
- .readFile({ filePath: absolutePath, connectionId: connectionId ?? undefined })
+ readImagePreview(absolutePath, connectionId, runtimeContext)
.then((result) => {
if (cancelled) {
return
}
if (result.isBinary && result.content) {
const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png')
- cacheBlobUrl(absolutePath, url)
+ cacheBlobUrl(cacheKey, url)
setDisplaySrc(url)
} else {
// Why: if the file exists but is not binary (e.g. an SVG stored as
@@ -168,7 +189,7 @@ export function useLocalImageSrc(
return () => {
cancelled = true
}
- }, [rawSrc, filePath, generation, connectionId])
+ }, [rawSrc, filePath, generation, connectionId, runtimeContext])
return displaySrc
}
@@ -181,7 +202,8 @@ export function useLocalImageSrc(
export async function loadLocalImageSrc(
rawSrc: string,
filePath: string,
- connectionId?: string | null
+ connectionId?: string | null,
+ runtimeContext?: Omit & { connectionId?: string | null }
): Promise {
if (
rawSrc.startsWith('http://') ||
@@ -197,19 +219,17 @@ export async function loadLocalImageSrc(
return null
}
- const cached = blobUrlCache.get(absolutePath)
+ const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext)
+ const cached = blobUrlCache.get(cacheKey)
if (cached) {
return cached
}
try {
- const result = await window.api.fs.readFile({
- filePath: absolutePath,
- connectionId: connectionId ?? undefined
- })
+ const result = await readImagePreview(absolutePath, connectionId, runtimeContext)
if (result.isBinary && result.content) {
const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png')
- cacheBlobUrl(absolutePath, url)
+ cacheBlobUrl(cacheKey, url)
return url
}
// Why: if the file is not binary (e.g. an SVG stored as text) or content
@@ -222,3 +242,23 @@ export async function loadLocalImageSrc(
return null
}
+
+function readImagePreview(
+ absolutePath: string,
+ connectionId?: string | null,
+ runtimeContext?: Omit & { connectionId?: string | null }
+) {
+ if (!runtimeContext) {
+ return window.api.fs.readFile({
+ filePath: absolutePath,
+ connectionId: connectionId ?? undefined
+ })
+ }
+ return readRuntimeFilePreview(
+ {
+ ...runtimeContext,
+ connectionId: runtimeContext.connectionId ?? connectionId ?? undefined
+ },
+ absolutePath
+ )
+}
diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts
index f0fdb092c7d..96a0c3ec682 100644
--- a/src/renderer/src/components/editor/useMarkdownDocuments.ts
+++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts
@@ -3,6 +3,8 @@ import type { MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { getConnectionId } from '@/lib/connection-context'
+import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client'
+import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import { createMarkdownDocumentIndex, resolveMarkdownDocLink } from './markdown-doc-links'
@@ -48,10 +50,18 @@ export function useMarkdownDocuments(
const requestId = requestRef.current + 1
requestRef.current = requestId
try {
- const documents = await window.api.fs.listMarkdownDocuments({
- rootPath: worktreePath,
- connectionId: connectionId ?? undefined
- })
+ const documents = await listRuntimeMarkdownDocuments(
+ {
+ settings: settingsForRuntimeOwner(
+ useAppStore.getState().settings,
+ activeFile.runtimeEnvironmentId
+ ),
+ worktreeId,
+ worktreePath,
+ connectionId: connectionId ?? undefined
+ },
+ worktreePath
+ )
if (requestRef.current !== requestId) {
return
}
@@ -68,18 +78,26 @@ export function useMarkdownDocuments(
}))
}
}
- }, [connectionId, worktreeId, worktreePath])
+ }, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath])
const openMarkdownDocument = useCallback(
async (document: MarkdownDocument): Promise => {
- if (!worktreeId) {
+ if (!worktreeId || !worktreePath) {
return
}
try {
- const stats = await window.api.fs.stat({
- filePath: document.filePath,
- connectionId: connectionId ?? undefined
- })
+ const stats = await statRuntimePath(
+ {
+ settings: settingsForRuntimeOwner(
+ useAppStore.getState().settings,
+ activeFile.runtimeEnvironmentId
+ ),
+ worktreeId,
+ worktreePath,
+ connectionId: connectionId ?? undefined
+ },
+ document.filePath
+ )
if (stats.isDirectory) {
await refreshMarkdownDocuments()
return
@@ -94,10 +112,18 @@ export function useMarkdownDocuments(
relativePath: document.relativePath,
worktreeId,
language: 'markdown',
+ runtimeEnvironmentId: activeFile.runtimeEnvironmentId,
mode: 'edit'
})
},
- [connectionId, openFile, refreshMarkdownDocuments, worktreeId]
+ [
+ activeFile.runtimeEnvironmentId,
+ connectionId,
+ openFile,
+ refreshMarkdownDocuments,
+ worktreeId,
+ worktreePath
+ ]
)
useEffect(() => {
diff --git a/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts b/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts
new file mode 100644
index 00000000000..cc60f0e4089
--- /dev/null
+++ b/src/renderer/src/components/editor/useMarkdownPreviewShortcut.ts
@@ -0,0 +1,80 @@
+import { useEffect, type RefObject } from 'react'
+import { detectLanguage } from '@/lib/language-detect'
+import type { OpenFile } from '@/store/slices/editor'
+import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-preview-controls'
+
+type UseMarkdownPreviewShortcutParams = {
+ activeFile: OpenFile | null
+ panelRef: RefObject
+ isMac: boolean
+ openMarkdownPreview: (file: {
+ filePath: string
+ relativePath: string
+ worktreeId: string
+ runtimeEnvironmentId?: string
+ language: string
+ }) => void
+}
+
+export function useMarkdownPreviewShortcut({
+ activeFile,
+ panelRef,
+ isMac,
+ openMarkdownPreview
+}: UseMarkdownPreviewShortcutParams): void {
+ const activeFilePath = activeFile?.filePath ?? null
+ const activeFileRelativePath = activeFile?.relativePath ?? null
+ const activeFileWorktreeId = activeFile?.worktreeId ?? null
+ const activeFileMode = activeFile?.mode ?? null
+ const activeFileDiffSource = activeFile?.diffSource
+ const activeFileRuntimeEnvironmentId = activeFile?.runtimeEnvironmentId
+
+ useEffect(() => {
+ if (!activeFilePath || !activeFileRelativePath || !activeFileWorktreeId || !activeFileMode) {
+ return
+ }
+ const shortcutLanguage =
+ activeFileMode === 'diff'
+ ? detectLanguage(activeFileRelativePath)
+ : detectLanguage(activeFilePath)
+ const canShowMarkdownPreview = canOpenMarkdownPreview({
+ language: shortcutLanguage,
+ mode: activeFileMode,
+ diffSource: activeFileDiffSource
+ })
+ if (!canShowMarkdownPreview) {
+ return
+ }
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.defaultPrevented || !isMarkdownPreviewShortcut(event, isMac)) {
+ return
+ }
+ const root = panelRef.current
+ const target = event.target
+ if (!root || !(target instanceof Node) || !root.contains(target)) {
+ return
+ }
+ event.preventDefault()
+ event.stopPropagation()
+ openMarkdownPreview({
+ filePath: activeFilePath,
+ relativePath: activeFileRelativePath,
+ worktreeId: activeFileWorktreeId,
+ runtimeEnvironmentId: activeFileRuntimeEnvironmentId,
+ language: shortcutLanguage
+ })
+ }
+ window.addEventListener('keydown', handleKeyDown, { capture: true })
+ return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
+ }, [
+ activeFileDiffSource,
+ activeFileMode,
+ activeFilePath,
+ activeFileRelativePath,
+ activeFileRuntimeEnvironmentId,
+ activeFileWorktreeId,
+ isMac,
+ openMarkdownPreview,
+ panelRef
+ ])
+}
diff --git a/src/renderer/src/components/editor/useUntitledFileRename.ts b/src/renderer/src/components/editor/useUntitledFileRename.ts
new file mode 100644
index 00000000000..a301ce6811a
--- /dev/null
+++ b/src/renderer/src/components/editor/useUntitledFileRename.ts
@@ -0,0 +1,135 @@
+import { useCallback, useState } from 'react'
+import { getConnectionId } from '@/lib/connection-context'
+import { detectLanguage } from '@/lib/language-detect'
+import { dirname, joinPath } from '@/lib/path'
+import { useAppStore } from '@/store'
+import type { OpenFile } from '@/store/slices/editor'
+import {
+ createRuntimePath,
+ renameRuntimePath,
+ runtimePathExists
+} from '@/runtime/runtime-file-client'
+import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
+import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
+
+type UseUntitledFileRenameParams = {
+ openFiles: OpenFile[]
+ closeFile: (filePath: string) => void
+ openFile: (file: {
+ filePath: string
+ relativePath: string
+ worktreeId: string
+ runtimeEnvironmentId?: string
+ language: string
+ mode: 'edit'
+ }) => void
+ clearUntitled: (fileId: string) => void
+}
+
+type UseUntitledFileRenameResult = {
+ renameDialogFileId: string | null
+ renameDialogFile: OpenFile | null
+ renameError: string | null
+ requestRenameForFile: (fileId: string) => void
+ closeRenameDialog: () => void
+ handleRenameConfirm: (newRelPath: string) => Promise
+}
+
+export function useUntitledFileRename({
+ openFiles,
+ closeFile,
+ openFile,
+ clearUntitled
+}: UseUntitledFileRenameParams): UseUntitledFileRenameResult {
+ const [renameDialogFileId, setRenameDialogFileId] = useState(null)
+ const [renameError, setRenameError] = useState(null)
+ const renameDialogFile = renameDialogFileId
+ ? (openFiles.find((f) => f.id === renameDialogFileId) ?? null)
+ : null
+
+ const closeRenameDialog = useCallback((): void => {
+ setRenameDialogFileId(null)
+ setRenameError(null)
+ }, [])
+
+ const handleRenameConfirm = useCallback(
+ async (newRelPath: string) => {
+ if (!renameDialogFile) {
+ return
+ }
+ const oldPath = renameDialogFile.filePath
+ // Why: derive the worktree root from the old relative path so nested
+ // untitled saves resolve relative to the worktree, not the current folder.
+ const worktreeRoot = oldPath.slice(
+ 0,
+ oldPath.length - renameDialogFile.relativePath.length - 1
+ )
+ const newPath = joinPath(worktreeRoot, newRelPath)
+ const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined
+ const fileContext = {
+ settings: settingsForRuntimeOwner(
+ useAppStore.getState().settings,
+ renameDialogFile.runtimeEnvironmentId
+ ),
+ worktreeId: renameDialogFile.worktreeId,
+ worktreePath: worktreeRoot,
+ connectionId
+ }
+
+ if (newPath !== oldPath && (await runtimePathExists(fileContext, newPath))) {
+ setRenameError('A file with that name already exists')
+ return
+ }
+
+ await requestEditorSaveQuiesce({ fileId: renameDialogFile.id })
+ const draft = useAppStore.getState().editorDrafts[renameDialogFile.id]
+ if (draft !== undefined) {
+ try {
+ await requestEditorFileSave({ fileId: renameDialogFile.id, fallbackContent: draft })
+ } catch {
+ setRenameError('Failed to save file')
+ return
+ }
+ }
+
+ if (newPath === oldPath) {
+ clearUntitled(renameDialogFile.id)
+ closeRenameDialog()
+ return
+ }
+
+ const newDir = dirname(newPath)
+ if (newDir !== worktreeRoot && !(await runtimePathExists(fileContext, newDir))) {
+ await createRuntimePath(fileContext, newDir, 'directory')
+ }
+
+ try {
+ await renameRuntimePath(fileContext, oldPath, newPath)
+ } catch (err) {
+ setRenameError(err instanceof Error ? err.message : 'Failed to rename file')
+ return
+ }
+
+ closeFile(oldPath)
+ openFile({
+ filePath: newPath,
+ relativePath: newRelPath,
+ worktreeId: renameDialogFile.worktreeId,
+ runtimeEnvironmentId: renameDialogFile.runtimeEnvironmentId,
+ language: detectLanguage(newRelPath),
+ mode: 'edit'
+ })
+ closeRenameDialog()
+ },
+ [clearUntitled, closeFile, closeRenameDialog, openFile, renameDialogFile]
+ )
+
+ return {
+ renameDialogFileId,
+ renameDialogFile,
+ renameError,
+ requestRenameForFile: setRenameDialogFileId,
+ closeRenameDialog,
+ handleRenameConfirm
+ }
+}
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
index b16d06ffaf6..be9bfa9e2e2 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
@@ -1,10 +1,7 @@
-/* eslint-disable max-lines -- Why: the floating surface owns both terminal chrome and local notes tabs until the shared floating shell is extracted. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Maximize2, Minimize2, Minus } from 'lucide-react'
import TabBar from '@/components/tab-bar/TabBar'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { Button } from '@/components/ui/button'
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
@@ -13,14 +10,12 @@ import {
isOrchestrationSetupDismissed,
notifyOrchestrationSetupStateChanged
} from '@/lib/orchestration-setup-state'
-import { notifyProjectNotesSelectionChanged } from '@/lib/open-project-notes-tab'
-import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request'
import { useAppStore } from '@/store'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { TerminalTab } from '../../../../shared/types'
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
-import ProjectNotesTabContent from '@/components/notes/ProjectNotesTabContent'
import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles'
+import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton'
import {
clampFloatingTerminalBounds,
@@ -29,13 +24,19 @@ import {
type FloatingTerminalPanelBounds
} from './floating-terminal-panel-bounds'
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
-type FloatingNotesTab = { id: string; label: string; noteId: string | null; isDirty: boolean }
type FloatingTerminalPanelProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
+const FLOATING_TERMINAL_NO_DRAG_SELECTOR =
+ 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]'
+
+function isFloatingTerminalDragTarget(target: EventTarget): boolean {
+ return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR))
+}
+
export function FloatingTerminalPanel({
open,
onOpenChange
@@ -52,7 +53,6 @@ export function FloatingTerminalPanel({
const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
const tabBarOrder = useAppStore((s) => s.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID])
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~')
- const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const [cwd, setCwd] = useState(null)
const [bounds, setBounds] = useState(() => getDefaultFloatingTerminalBounds())
@@ -61,9 +61,6 @@ export function FloatingTerminalPanel({
const [showOrchestrationSetup, setShowOrchestrationSetup] = useState(
() => !hasOrchestrationSetupMarker() && !isOrchestrationSetupDismissed()
)
- const [notesTabs, setNotesTabs] = useState([])
- const [activeNotesTabId, setActiveNotesTabId] = useState(null)
- const [activeSurface, setActiveSurface] = useState<'terminal' | 'notes'>('terminal')
const restoreBoundsRef = useRef(null)
const normalizedInitialBoundsRef = useRef(false)
const panelRef = useRef(null)
@@ -154,7 +151,6 @@ export function FloatingTerminalPanel({
const createFloatingTab = useCallback(() => {
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
- setActiveSurface('terminal')
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
const stored = state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
@@ -173,7 +169,6 @@ export function FloatingTerminalPanel({
const closeFloatingTab = useCallback(
(tabId: string) => {
closeTab(tabId)
- setActiveSurface('terminal')
},
[closeTab]
)
@@ -202,72 +197,6 @@ export function FloatingTerminalPanel({
[closeTab, tabs]
)
- const openFloatingNotesTab = useCallback(
- async (noteId?: string) => {
- if (!activeWorktreeId) {
- return
- }
- const state = useAppStore.getState()
- const worktree = Object.values(state.worktreesByRepo)
- .flat()
- .find((candidate) => candidate.id === activeWorktreeId)
- const projectId =
- state.repos.find((candidate) => candidate.id === worktree?.repoId)?.id ?? worktree?.repoId
- let label = 'Project Notes'
- if (noteId && projectId) {
- try {
- const result = await window.api.notes.show({
- projectId,
- worktreeId: activeWorktreeId,
- note: noteId
- })
- label = result.note.title
- } catch {
- label = 'Project Notes'
- }
- if (projectId) {
- await window.api.notes.link({
- projectId,
- worktreeId: activeWorktreeId,
- note: noteId,
- kind: 'active'
- })
- notifyProjectNotesSelectionChanged()
- }
- }
- const id = `floating-project-notes:${globalThis.crypto.randomUUID()}`
- setNotesTabs((current) => [...current, { id, label, noteId: noteId ?? null, isDirty: false }])
- setActiveNotesTabId(id)
- setActiveSurface('notes')
- },
- [activeWorktreeId]
- )
-
- const closeFloatingNotesTab = useCallback((tabId: string) => {
- requestProjectNotesTabClose(tabId, () => {
- setNotesTabs((current) => current.filter((tab) => tab.id !== tabId))
- setActiveNotesTabId((current) => (current === tabId ? null : current))
- setActiveSurface('terminal')
- })
- }, [])
-
- useEffect(() => {
- if (!open || activeSurface !== 'notes' || !activeNotesTabId) {
- return
- }
- const isMac = navigator.userAgent.includes('Mac')
- const onKeyDown = (event: KeyboardEvent): void => {
- const mod = isMac ? event.metaKey : event.ctrlKey
- if (!mod || event.shiftKey || event.repeat || event.key.toLowerCase() !== 'w') {
- return
- }
- event.preventDefault()
- closeFloatingNotesTab(activeNotesTabId)
- }
- window.addEventListener('keydown', onKeyDown, { capture: true })
- return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
- }, [activeNotesTabId, activeSurface, closeFloatingNotesTab, open])
-
const toggleMaximized = useCallback(() => {
setMaximized((current) => {
if (current) {
@@ -289,12 +218,7 @@ export function FloatingTerminalPanel({
return
}
const target = event.target
- if (
- target instanceof HTMLElement &&
- target.closest(
- 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]'
- )
- ) {
+ if (!isFloatingTerminalDragTarget(target)) {
return
}
dragRef.current = {
@@ -327,6 +251,14 @@ export function FloatingTerminalPanel({
}
}
+ const handleTitlebarDoubleClick = (event: React.MouseEvent): void => {
+ if (event.button !== 0 || !isFloatingTerminalDragTarget(event.target)) {
+ return
+ }
+ event.preventDefault()
+ toggleMaximized()
+ }
+
const dismissOrchestrationSetup = useCallback(() => {
localStorage.setItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY, '1')
setShowOrchestrationSetup(false)
@@ -358,11 +290,12 @@ export function FloatingTerminalPanel({
>
{
- setActiveSurface('terminal')
- setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)
- }}
+ onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)}
onClose={closeFloatingTab}
onCloseOthers={closeOthers}
onCloseToRight={closeToRight}
onNewTerminalTab={createFloatingTab}
onNewBrowserTab={() => {}}
- onNewNotesTab={activeWorktreeId ? openFloatingNotesTab : undefined}
- notesWorktreeId={activeWorktreeId}
terminalOnly
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
onTogglePaneExpand={(tabId) =>
setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true)
}
- notesTabs={activeWorktreeId ? notesTabs : []}
- activeNotesTabId={activeSurface === 'notes' ? activeNotesTabId : null}
- onActivateNotesTab={(tabId) => {
- setActiveNotesTabId(tabId)
- setActiveSurface('notes')
- }}
- onCloseNotesTab={closeFloatingNotesTab}
- activeTabType={activeSurface}
+ activeTabType="terminal"
tabBarOrder={tabBarOrder}
/>
-
-
-
-
- {maximized ? (
-
- ) : (
-
- )}
-
-
-
- {maximized ? 'Restore' : 'Maximize'}
-
-
-
-
- onOpenChange(false)}
- >
-
-
-
-
- Minimize
-
-
-
+
onOpenChange(false)}
+ />
- {activeSurface === 'notes' && activeWorktreeId && activeNotesTabId ? (
-
tab.id === activeNotesTabId)?.noteId ?? null}
- forceNew={notesTabs.find((tab) => tab.id === activeNotesTabId)?.noteId === null}
- onDirtyChange={(dirty) => {
- setNotesTabs((current) =>
- current.map((tab) =>
- tab.id === activeNotesTabId ? { ...tab, isDirty: dirty } : tab
- )
- )
- }}
- />
- ) : cwd ? (
- tabs.map((tab) => (
-
- closeTab(tab.id)}
- onCloseTab={() => closeTab(tab.id)}
- />
-
- ))
- ) : null}
+ {cwd
+ ? tabs.map((tab) => (
+
+ closeTab(tab.id)}
+ onCloseTab={() => closeTab(tab.id)}
+ />
+
+ ))
+ : null}
{showOrchestrationSetup ? (
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx
index 314f00efb6e..986f1612669 100644
--- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx
@@ -23,7 +23,7 @@ export function FloatingTerminalToggleButton({
type="button"
variant="outline"
size="icon-sm"
- className="bg-card/95 shadow-xs"
+ className="border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground"
data-floating-terminal-toggle
aria-label={open ? 'Minimize floating terminal' : 'Show floating terminal'}
aria-pressed={open}
diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx
new file mode 100644
index 00000000000..f09949cb510
--- /dev/null
+++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx
@@ -0,0 +1,144 @@
+import { useCallback, useMemo } from 'react'
+import { Maximize2, Minimize2, Minus } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
+import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
+import { CLIENT_PLATFORM } from '@/lib/new-workspace'
+import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
+import { tuiAgentToAgentKind } from '@/lib/telemetry'
+import { useAppStore } from '@/store'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
+
+type FloatingTerminalWindowControlsProps = {
+ maximized: boolean
+ onToggleMaximized: () => void
+ onMinimize: () => void
+}
+
+const controlButtonClassName =
+ 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground'
+
+export function FloatingTerminalWindowControls({
+ maximized,
+ onToggleMaximized,
+ onMinimize
+}: FloatingTerminalWindowControlsProps): React.JSX.Element {
+ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
+ const createTab = useAppStore((s) => s.createTab)
+ const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree)
+
+ const defaultAgent = defaultTuiAgent && defaultTuiAgent !== 'blank' ? defaultTuiAgent : null
+ const defaultAgentLabel = useMemo(
+ () =>
+ defaultAgent
+ ? (AGENT_CATALOG.find((agent) => agent.id === defaultAgent)?.label ?? defaultAgent)
+ : null,
+ [defaultAgent]
+ )
+
+ const launchDefaultAgent = useCallback(() => {
+ if (!defaultAgent) {
+ return
+ }
+ const state = useAppStore.getState()
+ const startupPlan = buildAgentStartupPlan({
+ agent: defaultAgent,
+ prompt: '',
+ cmdOverrides: state.settings?.agentCmdOverrides ?? {},
+ platform: CLIENT_PLATFORM,
+ allowEmptyPromptLaunch: true
+ })
+ if (!startupPlan) {
+ toast.error(`Could not build launch command for ${defaultAgentLabel ?? defaultAgent}.`)
+ return
+ }
+ const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
+ state.queueTabStartupCommand(tab.id, {
+ command: startupPlan.launchCommand,
+ ...(startupPlan.env ? { env: startupPlan.env } : {}),
+ telemetry: {
+ agent_kind: tuiAgentToAgentKind(defaultAgent),
+ launch_source: 'shortcut',
+ request_kind: 'new'
+ }
+ })
+ setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
+ const fresh = useAppStore.getState()
+ const currentTabs = fresh.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
+ const stored = fresh.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
+ const validIds = new Set(currentTabs.map((entry) => entry.id))
+ const order = stored.filter((id) => validIds.has(id) && id !== tab.id)
+ for (const entry of currentTabs) {
+ if (entry.id !== tab.id && !order.includes(entry.id)) {
+ order.push(entry.id)
+ }
+ }
+ order.push(tab.id)
+ fresh.setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order)
+ focusTerminalTabSurface(tab.id)
+ }, [createTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree])
+
+ return (
+
+
+
+
+
+
+
+
+ {defaultAgentLabel ? `Open ${defaultAgentLabel}` : 'Choose a default agent first'}
+
+
+
+
+
+ {maximized ? : }
+
+
+
+ {maximized ? 'Restore' : 'Maximize'}
+
+
+
+
+
+
+
+
+
+ Minimize
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts
new file mode 100644
index 00000000000..8cce47e3f26
--- /dev/null
+++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ TITLEBAR_SAFE_TOP,
+ clampFloatingTerminalBounds,
+ getDefaultFloatingTerminalBounds,
+ getMaximizedFloatingTerminalBounds
+} from './floating-terminal-panel-bounds'
+
+function stubViewport(width: number, height: number, userAgent: string): void {
+ vi.stubGlobal('window', { innerWidth: width, innerHeight: height })
+ vi.stubGlobal('navigator', { userAgent })
+}
+
+describe('floating terminal panel bounds', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('lets dragged panels touch the macOS titlebar without going above it', () => {
+ stubViewport(1200, 800, 'Macintosh')
+
+ const bounds = clampFloatingTerminalBounds({
+ left: 32,
+ top: 8,
+ width: 640,
+ height: 360
+ })
+
+ expect(bounds.top).toBe(TITLEBAR_SAFE_TOP)
+ })
+
+ it('keeps dragged panels below the renderer titlebar on other platforms', () => {
+ stubViewport(1200, 800, 'Windows NT')
+
+ const bounds = clampFloatingTerminalBounds({
+ left: 32,
+ top: 8,
+ width: 640,
+ height: 360
+ })
+
+ expect(bounds.top).toBe(TITLEBAR_SAFE_TOP)
+ })
+
+ it('maximizes below the renderer titlebar on non-mac platforms', () => {
+ stubViewport(1200, 800, 'Windows NT')
+
+ expect(getMaximizedFloatingTerminalBounds()).toEqual(
+ expect.objectContaining({
+ top: TITLEBAR_SAFE_TOP,
+ height: 800 - TITLEBAR_SAFE_TOP - 36
+ })
+ )
+ })
+
+ it('defaults at or below the titlebar on compact macOS windows', () => {
+ stubViewport(760, 420, 'Macintosh')
+
+ expect(getDefaultFloatingTerminalBounds().top).toBeGreaterThanOrEqual(TITLEBAR_SAFE_TOP)
+ })
+})
diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts
index c9892cba06f..f083abe8fee 100644
--- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts
+++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-bounds.ts
@@ -4,7 +4,7 @@ export const MIN_PANEL_WIDTH = 420
export const MIN_PANEL_HEIGHT = 280
export const MAXIMIZED_MARGIN = 12
export const MAXIMIZED_BOTTOM_GAP = 36
-export const MAC_TITLEBAR_SAFE_TOP = 44
+export const TITLEBAR_SAFE_TOP = 36
const DEFAULT_RIGHT_GAP = 24
const DEFAULT_BOTTOM_GAP = 84
@@ -18,11 +18,14 @@ export type FloatingTerminalPanelBounds = {
export function getDefaultFloatingTerminalBounds(): FloatingTerminalPanelBounds {
const viewportWidth = typeof window === 'undefined' ? 1200 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 800 : window.innerHeight
+ // Why: the floating panel may touch the renderer titlebar, but must not
+ // overlap it or the native window controls above it.
+ const safeTop = TITLEBAR_SAFE_TOP
const width = Math.min(DEFAULT_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, viewportWidth - 48))
const height = Math.min(DEFAULT_PANEL_HEIGHT, Math.max(MIN_PANEL_HEIGHT, viewportHeight - 96))
return {
left: Math.max(16, viewportWidth - width - DEFAULT_RIGHT_GAP),
- top: Math.max(40, viewportHeight - height - DEFAULT_BOTTOM_GAP),
+ top: Math.max(safeTop, viewportHeight - height - DEFAULT_BOTTOM_GAP),
width,
height
}
@@ -35,20 +38,18 @@ export function clampFloatingTerminalBounds(
typeof window === 'undefined' ? bounds.left + bounds.width : window.innerWidth
const viewportHeight =
typeof window === 'undefined' ? bounds.top + bounds.height : window.innerHeight
+ const safeTop = TITLEBAR_SAFE_TOP
return {
...bounds,
left: Math.min(Math.max(8, bounds.left), Math.max(8, viewportWidth - 80)),
- top: Math.min(Math.max(8, bounds.top), Math.max(8, viewportHeight - 80))
+ top: Math.min(Math.max(safeTop, bounds.top), Math.max(safeTop, viewportHeight - 80))
}
}
export function getMaximizedFloatingTerminalBounds(): FloatingTerminalPanelBounds {
const viewportWidth = typeof window === 'undefined' ? 1200 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 800 : window.innerHeight
- const top =
- typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
- ? MAC_TITLEBAR_SAFE_TOP
- : MAXIMIZED_MARGIN
+ const top = TITLEBAR_SAFE_TOP
return {
left: MAXIMIZED_MARGIN,
top,
diff --git a/src/renderer/src/components/github-project/ProjectCell.tsx b/src/renderer/src/components/github-project/ProjectCell.tsx
index 4182e068d92..59ccf7ea2d2 100644
--- a/src/renderer/src/components/github-project/ProjectCell.tsx
+++ b/src/renderer/src/components/github-project/ProjectCell.tsx
@@ -9,14 +9,17 @@ import { TYPE_FIELD_DATA_TYPE } from './columns'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
-import type { GitHubAssignableUser } from '../../../../shared/types'
+import { useRepoAssigneesBySlug, useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata'
+import { useAppStore } from '@/store'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type {
GitHubIssueType,
GitHubProjectField,
GitHubProjectFieldMutationValue,
GitHubProjectLabel,
GitHubProjectRow,
- GitHubProjectUser
+ GitHubProjectUser,
+ ListIssueTypesBySlugResult
} from '../../../../shared/github-project-types'
type Props = {
@@ -264,6 +267,7 @@ function IssueTypeCell({
const [open, setOpen] = useState(false)
const [options, setOptions] = useState([])
const [loading, setLoading] = useState(false)
+ const settings = useAppStore((s) => s.settings)
const [owner, repo] = (row.content.repository ?? '').split('/')
React.useEffect(() => {
@@ -272,8 +276,17 @@ function IssueTypeCell({
}
let cancelled = false
setLoading(true)
- window.api.gh
- .listIssueTypesBySlug({ owner, repo })
+ const target = getActiveRuntimeTarget(settings)
+ const request =
+ target.kind === 'environment'
+ ? callRuntimeRpc(
+ target,
+ 'github.project.listIssueTypesBySlug',
+ { owner, repo },
+ { timeoutMs: 30_000 }
+ )
+ : window.api.gh.listIssueTypesBySlug({ owner, repo })
+ request
.then((res) => {
if (cancelled) {
return
@@ -290,7 +303,7 @@ function IssueTypeCell({
return () => {
cancelled = true
}
- }, [open, owner, repo])
+ }, [open, owner, repo, settings])
const trigger = (
@@ -713,8 +726,7 @@ function AssigneesCell({
}): React.JSX.Element {
const assignees = row.content.assignees
const [open, setOpen] = useState(false)
- const [options, setOptions] = useState([])
- const [loading, setLoading] = useState(false)
+ const settings = useAppStore((s) => s.settings)
const [owner, repo] = (row.content.repository ?? '').split('/')
@@ -732,37 +744,12 @@ function AssigneesCell({
[assignees]
)
- // Why: only hit the slug-addressed user list when the popover actually
- // opens — the assignable-users query can be expensive for large repos.
- React.useEffect(() => {
- if (!open || !owner || !repo) {
- return
- }
- let cancelled = false
- setLoading(true)
- window.api.gh
- .listAssignableUsersBySlug({
- owner,
- repo,
- seedLogins: seedKey ? seedKey.split(',') : []
- })
- .then((res) => {
- if (cancelled) {
- return
- }
- if (res.ok) {
- setOptions(res.users)
- }
- })
- .finally(() => {
- if (!cancelled) {
- setLoading(false)
- }
- })
- return () => {
- cancelled = true
- }
- }, [open, owner, repo, seedKey])
+ const metadata = useRepoAssigneesBySlug(
+ open ? owner : null,
+ open ? repo : null,
+ seedKey ? seedKey.split(',') : [],
+ settings
+ )
const labelContent =
assignees.length === 0 ? null : assignees.map((u) => )
@@ -791,10 +778,10 @@ function AssigneesCell({
{!owner || !repo ? (
Row has no repo slug.
- ) : loading ? (
+ ) : metadata.loading ? (
Loading…
) : (
- options.map((u) => {
+ metadata.data.map((u) => {
const isOn = assignees.some((a) => a.login === u.login)
return (
([])
- const [loading, setLoading] = useState(false)
+ const settings = useAppStore((s) => s.settings)
const [owner, repo] = (row.content.repository ?? '').split('/')
-
- // Why: only fetch the slug-addressed labels list when the popover actually
- // opens — listing labels is cheap but still a network round-trip per row.
- React.useEffect(() => {
- if (!open || !owner || !repo) {
- return
- }
- let cancelled = false
- setLoading(true)
- window.api.gh
- .listLabelsBySlug({ owner, repo })
- .then((res) => {
- if (cancelled) {
- return
- }
- if (res.ok) {
- setOptions(res.labels)
- }
- })
- .finally(() => {
- if (!cancelled) {
- setLoading(false)
- }
- })
- return () => {
- cancelled = true
- }
- }, [open, owner, repo])
+ const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings)
const labelContent =
labels.length === 0 ? null : labels.map((l) => )
@@ -893,12 +852,12 @@ function LabelsCell({
{!owner || !repo ? (
Row has no repo slug.
- ) : loading ? (
+ ) : metadata.loading ? (
Loading…
- ) : options.length === 0 ? (
+ ) : metadata.data.length === 0 ? (
No labels in this repo.
) : (
- options.map((name) => {
+ metadata.data.map((name) => {
const isOn = labels.some((l) => l.name === name)
return (
[0]
+): Promise {
+ const target = getActiveRuntimeTarget(settings)
+ return target.kind === 'environment'
+ ? callRuntimeRpc(
+ target,
+ 'github.project.listAccessible',
+ {},
+ { timeoutMs: 60_000 }
+ )
+ : window.api.gh.listAccessibleProjects()
+}
+
+async function listProjectViewsForRuntime(
+ settings: Parameters[0],
+ args: { owner: string; ownerType: GitHubProjectOwnerType; projectNumber: number }
+): Promise {
+ const target = getActiveRuntimeTarget(settings)
+ return target.kind === 'environment'
+ ? callRuntimeRpc(target, 'github.project.listViews', args, {
+ timeoutMs: 30_000
+ })
+ : window.api.gh.listProjectViews(args)
+}
+
+async function resolveProjectRefForRuntime(
+ settings: Parameters[0],
+ input: string
+): Promise {
+ const target = getActiveRuntimeTarget(settings)
+ return target.kind === 'environment'
+ ? callRuntimeRpc(
+ target,
+ 'github.project.resolveRef',
+ { input },
+ { timeoutMs: 30_000 }
+ )
+ : window.api.gh.resolveProjectRef({ input })
+}
+
export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
- const projectSettings: GitHubProjectSettings = settings?.githubProjects ?? {
- pinned: [],
- recent: [],
- lastViewByProject: {},
- activeProject: null
- }
+ const projectSettings: GitHubProjectSettings = useMemo(
+ () =>
+ settings?.githubProjects ?? {
+ pinned: [],
+ recent: [],
+ lastViewByProject: {},
+ activeProject: null
+ },
+ [settings?.githubProjects]
+ )
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
@@ -86,7 +135,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
setBrowseLoading(true)
setBrowseError(null)
try {
- const res = await window.api.gh.listAccessibleProjects()
+ const res = await listAccessibleProjectsForRuntime(settings)
if (res.ok) {
browseCache = {
fetchedAt: Date.now(),
@@ -106,7 +155,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
} finally {
setBrowseLoading(false)
}
- }, [])
+ }, [settings])
useEffect(() => {
if (open && !viewPickFor) {
@@ -199,7 +248,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
})
setViewLoading(true)
try {
- const res = await window.api.gh.listProjectViews({
+ const res = await listProjectViewsForRuntime(settings, {
owner: selection.owner,
ownerType: selection.ownerType,
projectNumber: selection.number
@@ -240,7 +289,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
setViewLoading(false)
}
},
- [commitSelection, projectSettings.lastViewByProject]
+ [commitSelection, projectSettings.lastViewByProject, settings]
)
const handlePaste = useCallback(async () => {
@@ -252,7 +301,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
setPasteError(null)
setPasteBusy(true)
try {
- const res = await window.api.gh.resolveProjectRef({ input: pasteInput.trim() })
+ const res = await resolveProjectRefForRuntime(settings, pasteInput.trim())
if (!res.ok) {
setPasteError(res.error.message)
return
@@ -270,7 +319,7 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
} finally {
setPasteBusy(false)
}
- }, [handleChooseProject, pasteInput])
+ }, [handleChooseProject, pasteInput, settings])
const filteredBrowse = useMemo(() => {
const q = query.trim().toLowerCase()
diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx
index 8a07793a3af..6c90f7609e1 100644
--- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx
+++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx
@@ -2,7 +2,7 @@
// Why: top-level container for Project mode. Handles the picker, header,
// filter label, count pill, Open-in-GitHub, and all Interaction States
// documented in the design doc.
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ExternalLink,
Loader,
@@ -29,6 +29,7 @@ import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { useRepoSlugIndex } from '@/lib/repo-slug-index'
import { cn } from '@/lib/utils'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { projectViewCacheKey } from '@/store/slices/github'
import type {
@@ -38,7 +39,8 @@ import type {
GitHubProjectRow,
GitHubProjectTable,
GitHubProjectViewError,
- GitHubProjectViewSummary
+ GitHubProjectViewSummary,
+ ListProjectViewsResult
} from '../../../../shared/github-project-types'
import type { GitHubWorkItem } from '../../../../shared/types'
import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker'
@@ -47,6 +49,18 @@ import ProjectItemSlugDialog from './ProjectItemSlugDialog'
type Props = Record
+function listProjectViewsForRuntime(
+ settings: Parameters[0],
+ args: { owner: string; ownerType: 'organization' | 'user'; projectNumber: number }
+): Promise {
+ const target = getActiveRuntimeTarget(settings)
+ return target.kind === 'environment'
+ ? callRuntimeRpc(target, 'github.project.listViews', args, {
+ timeoutMs: 30_000
+ })
+ : window.api.gh.listProjectViews(args)
+}
+
export default function ProjectViewWrapper(_props: Props = {} as Props): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const projectViewCache = useAppStore((s) => s.projectViewCache)
@@ -59,7 +73,10 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const lookupSlug = useRepoSlugIndex()
const activeProject = settings?.githubProjects?.activeProject ?? null
- const lastViewByProject = settings?.githubProjects?.lastViewByProject ?? {}
+ const lastViewByProject = useMemo(
+ () => settings?.githubProjects?.lastViewByProject ?? {},
+ [settings?.githubProjects?.lastViewByProject]
+ )
const [loading, setLoading] = useState(false)
const [error, setError] = useState<{
@@ -165,12 +182,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
return
}
let cancelled = false
- void window.api.gh
- .listProjectViews({
- owner: activeProject.owner,
- ownerType: activeProject.ownerType,
- projectNumber: activeProject.number
- })
+ void listProjectViewsForRuntime(settings, {
+ owner: activeProject.owner,
+ ownerType: activeProject.ownerType,
+ projectNumber: activeProject.number
+ })
.then((res) => {
if (cancelled) {
return
@@ -192,7 +208,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
return () => {
cancelled = true
}
- }, [activeProject, viewListByProject])
+ }, [activeProject, viewListByProject, settings])
const handleSwitchView = useCallback(
async (viewId: string) => {
@@ -232,7 +248,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
viewId
})
},
- [activeProject, doFetch, lastViewByProject, settings]
+ [activeProject, doFetch, lastViewByProject]
)
const currentProjectViewKey = useMemo(() => {
@@ -301,6 +317,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
const [dialogRepoItem, setDialogRepoItem] = useState<{
workItem: GitHubWorkItem
repoPath: string
+ repoId: string
origin: GitHubItemDialogProjectOrigin
} | null>(null)
// Why: the slug dialog is only opened for rows whose repo isn't registered
@@ -389,11 +406,12 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
}
return
}
- const matched = lookupSlug(`${origin.owner}/${origin.repo}`)
+ const matches = lookupSlug(`${origin.owner}/${origin.repo}`)
+ const matched = matches.length === 1 ? matches[0] : null
if (matched) {
const workItem = buildWorkItem(row, matched.id)
if (workItem) {
- setDialogRepoItem({ workItem, repoPath: matched.path, origin })
+ setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin })
return
}
}
@@ -412,7 +430,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
if (!origin) {
return
}
- const matched = lookupSlug(`${origin.owner}/${origin.repo}`)
+ const matches = lookupSlug(`${origin.owner}/${origin.repo}`)
+ const matched = matches.length === 1 ? matches[0] : null
if (!matched) {
setRepoNotInOrca({
owner: origin.owner,
@@ -684,6 +703,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J
{
const current = dialogRepoItem
@@ -783,6 +803,7 @@ function ProjectSearchInput({
}): React.JSX.Element {
const initial = appliedOverride !== undefined ? appliedOverride : viewFilter
const [value, setValue] = useState(initial)
+ const inputRef = useRef(null)
const applied = appliedOverride !== undefined ? appliedOverride : viewFilter
const dirty = value !== applied
@@ -792,14 +813,55 @@ function ProjectSearchInput({
onApply(next === viewFilter ? undefined : next)
}
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent): void => {
+ const isMac = navigator.userAgent.includes('Mac')
+ const modifierPressed = isMac ? event.metaKey : event.ctrlKey
+ if (!modifierPressed || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'f') {
+ return
+ }
+ if (document.querySelector('[role="dialog"]')) {
+ return
+ }
+
+ const input = inputRef.current
+ if (!input) {
+ return
+ }
+ const target = event.target
+ if (
+ target instanceof HTMLElement &&
+ target !== input &&
+ (target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.isContentEditable)
+ ) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+ input.focus()
+ input.select()
+ }
+
+ window.addEventListener('keydown', onKeyDown, { capture: true })
+ return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
+ }, [])
+
return (
setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
+ if (e.nativeEvent.isComposing) {
+ return
+ }
e.preventDefault()
apply(value)
} else if (e.key === 'Escape') {
@@ -819,13 +881,14 @@ function ProjectSearchInput({
dirty && 'border-amber-500/50'
)}
/>
- {value && value !== viewFilter ? (
+ {value ? (
e.preventDefault()}
onClick={() => {
- setValue(viewFilter)
- apply(viewFilter)
+ setValue('')
+ apply('')
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx
index 6ccc3a2e05b..5378801dba5 100644
--- a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx
+++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx
@@ -1,7 +1,8 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React, { useMemo, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
-import type { GitHubAssignableUser } from '../../../../../shared/types'
+import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
+import { useAppStore } from '@/store'
export function AssigneesEditor({
owner,
@@ -17,45 +18,17 @@ export function AssigneesEditor({
onChange: (add: string[], remove: string[]) => void | Promise
}): React.JSX.Element {
const [open, setOpen] = useState(false)
- const [users, setUsers] = useState([])
- const [loading, setLoading] = useState(false)
+ const settings = useAppStore((s) => s.settings)
// Why: stabilize the assignee seed identity. `selected` is a fresh array on
// every parent render — depending on it directly would refire the IPC for
// every unrelated re-render while the popover is open.
const seedKey = useMemo(() => selected.slice().sort().join(','), [selected])
- useEffect(() => {
- if (!open) {
- return
- }
- // Why: guard against late responses overwriting newer state when
- // owner/repo/seedKey change (or the popover toggles) before the IPC
- // resolves. Mirrors the requestIdRef pattern used for the details fetch.
- let cancelled = false
- setLoading(true)
- window.api.gh
- .listAssignableUsersBySlug({
- owner,
- repo,
- seedLogins: seedKey ? seedKey.split(',') : []
- })
- .then((res) => {
- if (cancelled) {
- return
- }
- if (res.ok) {
- setUsers(res.users)
- }
- })
- .finally(() => {
- if (cancelled) {
- return
- }
- setLoading(false)
- })
- return () => {
- cancelled = true
- }
- }, [open, owner, repo, seedKey])
+ const metadata = useRepoAssigneesBySlug(
+ open ? owner : null,
+ open ? repo : null,
+ seedKey ? seedKey.split(',') : [],
+ settings
+ )
return (
!disabled && setOpen(o)}>
@@ -68,10 +41,10 @@ export function AssigneesEditor({
- {loading ? (
+ {metadata.loading ? (
Loading…
) : (
- users.map((u) => {
+ metadata.data.map((u) => {
const isOn = selected.includes(u.login)
return (
{
- const res = await window.api.gh.deleteIssueCommentBySlug({
+ const target = getRuntimeTarget()
+ const args = {
owner,
repo,
commentId: c.id
- })
+ }
+ const res = target
+ ? await callRuntimeRpc(
+ target,
+ 'github.project.deleteIssueCommentBySlug',
+ args,
+ { timeoutMs: 30_000 }
+ )
+ : await window.api.gh.deleteIssueCommentBySlug(args)
if (!res.ok) {
toast.error(res.error.message)
return
@@ -40,12 +60,21 @@ export function CommentsList({
onChange(comments.filter((x) => x.id !== c.id))
}}
onEdit={async (next) => {
- const res = await window.api.gh.updateIssueCommentBySlug({
+ const target = getRuntimeTarget()
+ const args = {
owner,
repo,
commentId: c.id,
body: next
- })
+ }
+ const res = target
+ ? await callRuntimeRpc(
+ target,
+ 'github.project.updateIssueCommentBySlug',
+ args,
+ { timeoutMs: 30_000 }
+ )
+ : await window.api.gh.updateIssueCommentBySlug(args)
if (!res.ok) {
toast.error(res.error.message)
return
@@ -154,7 +183,16 @@ export function NewCommentForm({
}
setSubmitting(true)
try {
- const res = await window.api.gh.addIssueCommentBySlug({ owner, repo, number, body })
+ const target = getRuntimeTarget()
+ const args = { owner, repo, number, body }
+ const res = target
+ ? await callRuntimeRpc(
+ target,
+ 'github.project.addIssueCommentBySlug',
+ args,
+ { timeoutMs: 30_000 }
+ )
+ : await window.api.gh.addIssueCommentBySlug(args)
if (!res.ok) {
toast.error(res.error.message)
return
diff --git a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx
index 6ac9eaed9a6..9f8962ad032 100644
--- a/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx
+++ b/src/renderer/src/components/github-project/slug-dialog/LabelsEditor.tsx
@@ -1,6 +1,8 @@
-import React, { useEffect, useState } from 'react'
+import React, { useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
+import { useRepoLabelsBySlug } from '@/hooks/useGitHubSlugMetadata'
+import { useAppStore } from '@/store'
export function LabelsEditor({
owner,
@@ -16,37 +18,8 @@ export function LabelsEditor({
onChange: (add: string[], remove: string[]) => void | Promise
}): React.JSX.Element {
const [open, setOpen] = useState(false)
- const [options, setOptions] = useState([])
- const [loading, setLoading] = useState(false)
- useEffect(() => {
- if (!open) {
- return
- }
- // Why: guard against late responses overwriting newer state when the
- // popover toggles owner/repo (or closes/reopens) while the IPC is still
- // in flight. Mirrors the requestIdRef pattern used for the details fetch.
- let cancelled = false
- setLoading(true)
- window.api.gh
- .listLabelsBySlug({ owner, repo })
- .then((res) => {
- if (cancelled) {
- return
- }
- if (res.ok) {
- setOptions(res.labels)
- }
- })
- .finally(() => {
- if (cancelled) {
- return
- }
- setLoading(false)
- })
- return () => {
- cancelled = true
- }
- }, [open, owner, repo])
+ const settings = useAppStore((s) => s.settings)
+ const metadata = useRepoLabelsBySlug(open ? owner : null, open ? repo : null, settings)
return (
!disabled && setOpen(o)}>
@@ -59,10 +32,10 @@ export function LabelsEditor({
- {loading ? (
+ {metadata.loading ? (
Loading…
) : (
- options.map((name) => {
+ metadata.data.map((name) => {
const isOn = selected.includes(name)
return (
toneFor(snapshot[b.key].remaining, snapshot[b.key].limit))
- if (tones.includes('crit')) {
- return 'crit'
- }
- if (tones.includes('warn')) {
- return 'warn'
- }
- return 'ok'
-}
-
-function tightestBucket(snapshot: GitHubRateLimitSnapshot): BucketMeta {
- // Why: "N left" in the pill shows the bucket closest to exhaustion by
- // ratio — that's the actionable one. Absolute remaining would always
- // favor GraphQL (5000 pts) over Search (30) even when Search is 1 away.
- let worst = BUCKETS[0]
- let worstPct = 1
- for (const b of BUCKETS) {
- const { remaining, limit } = snapshot[b.key]
- const pct = limit > 0 ? remaining / limit : 1
- if (pct < worstPct) {
- worstPct = pct
- worst = b
- }
- }
- return worst
-}
+import React from 'react'
+import { GitHubRateLimitCompact } from './github-rate-limit-display'
export default function GitHubRateLimitPill(): React.JSX.Element | null {
- const [snapshot, setSnapshot] = useState(null)
- const [hasError, setHasError] = useState(false)
- // Why: StrictMode double-invokes effects in dev. Without this guard the
- // first mount fires two rate_limit IPCs back-to-back — benign (exempt
- // endpoint, cached) but noisy in logs. Tracks the latest in-flight token
- // so stale responses from an unmounted instance are dropped.
- const latestToken = useRef(0)
-
- const fetchSnapshot = useCallback(async (force: boolean): Promise => {
- const token = ++latestToken.current
- try {
- const res = (await window.api.gh.rateLimit(force ? { force: true } : undefined)) as
- | GetRateLimitResult
- | undefined
- if (token !== latestToken.current) {
- return
- }
- if (res?.ok) {
- setSnapshot(res.snapshot)
- setHasError(false)
- } else {
- setHasError(true)
- }
- } catch {
- if (token !== latestToken.current) {
- return
- }
- setHasError(true)
- }
- }, [])
-
- useEffect(() => {
- const fetchIfVisible = (): void => {
- if (document.visibilityState === 'visible' && document.hasFocus()) {
- void fetchSnapshot(false)
- }
- }
- void fetchSnapshot(false)
- // Why: rate-limit probes are only useful while the TaskPage is visible.
- // Skipping hidden-window ticks avoids spending gh/API work just to keep an
- // invisible pill current; focus refreshes restore freshness before use.
- const handle = window.setInterval(fetchIfVisible, REFRESH_INTERVAL_MS)
- window.addEventListener('focus', fetchIfVisible)
- document.addEventListener('visibilitychange', fetchIfVisible)
- return () => {
- window.clearInterval(handle)
- window.removeEventListener('focus', fetchIfVisible)
- document.removeEventListener('visibilitychange', fetchIfVisible)
- }
- }, [fetchSnapshot])
-
- // Why: silently render nothing on error or before first load. The pill is
- // informational — surfacing a red error here would mislead users into
- // thinking their actual gh workflow is broken when only the probe failed.
- if (!snapshot || hasError) {
- return null
- }
-
- const tone = worstTone(snapshot)
- // Why: hide the pill entirely at healthy levels. It only earns screen
- // real estate when the user is actually approaching a wall — otherwise
- // "30/30" is ambiguous noise that users can't act on.
- if (tone === 'ok') {
- return null
- }
- const tight = tightestBucket(snapshot)
- const tightBucket = snapshot[tight.key]
-
- return (
-
-
- void fetchSnapshot(true)}
- aria-label="GitHub rate limit"
- className={cn(
- 'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium transition',
- tone === 'crit' &&
- 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300 hover:bg-red-500/20',
- tone === 'warn' &&
- 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300 hover:bg-amber-500/20'
- )}
- >
-
-
- {/* Why: "N left" is unambiguous; "N/M" reads as either
- used-of-total or remaining-of-total depending on the user's
- prior. We only show this pill when low, so the count is
- already actionable — no need for the denominator here. */}
- {tightBucket.remaining} {tight.label.toLowerCase()} left · resets in{' '}
- {formatReset(tightBucket.resetAt)}
-
-
-
-
- GitHub API budget
-
- {BUCKETS.map((b) => {
- const v = snapshot[b.key]
- const t = toneFor(v.remaining, v.limit)
- return (
-
- {b.description}
-
- {v.remaining} of {v.limit} left · resets in {formatReset(v.resetAt)}
-
-
- )
- })}
-
- Click to refresh
-
-
- )
+ return
}
diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx
new file mode 100644
index 00000000000..52fa0d66e6b
--- /dev/null
+++ b/src/renderer/src/components/github/github-rate-limit-display.tsx
@@ -0,0 +1,267 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { Gauge, RefreshCw } from 'lucide-react'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { useAppStore } from '@/store'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
+import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types'
+
+const REFRESH_INTERVAL_MS = 60_000
+
+type BucketKey = 'core' | 'search' | 'graphql'
+
+type BucketMeta = {
+ key: BucketKey
+ label: string
+ description: string
+}
+
+const BUCKETS: BucketMeta[] = [
+ { key: 'core', label: 'REST', description: 'REST API' },
+ { key: 'search', label: 'Search', description: 'Search API' },
+ { key: 'graphql', label: 'GraphQL', description: 'GraphQL API' }
+]
+
+export function formatGitHubRateLimitReset(resetAt: number): string {
+ const deltaSec = Math.max(0, resetAt - Math.floor(Date.now() / 1000))
+ if (deltaSec < 60) {
+ return `${deltaSec}s`
+ }
+ const mins = Math.round(deltaSec / 60)
+ return `${mins}m`
+}
+
+export function toneForGitHubBucket(remaining: number, limit: number): 'ok' | 'warn' | 'crit' {
+ if (limit <= 0) {
+ return 'ok'
+ }
+ const pct = remaining / limit
+ if (pct < 0.1) {
+ return 'crit'
+ }
+ if (pct < 0.25) {
+ return 'warn'
+ }
+ return 'ok'
+}
+
+function worstGitHubRateLimitTone(snapshot: GitHubRateLimitSnapshot): 'ok' | 'warn' | 'crit' {
+ const tones = BUCKETS.map((b) =>
+ toneForGitHubBucket(snapshot[b.key].remaining, snapshot[b.key].limit)
+ )
+ if (tones.includes('crit')) {
+ return 'crit'
+ }
+ if (tones.includes('warn')) {
+ return 'warn'
+ }
+ return 'ok'
+}
+
+function tightestGitHubBucket(snapshot: GitHubRateLimitSnapshot): BucketMeta {
+ let worst = BUCKETS[0]
+ let worstPct = 1
+ for (const b of BUCKETS) {
+ const { remaining, limit } = snapshot[b.key]
+ const pct = limit > 0 ? remaining / limit : 1
+ if (pct < worstPct) {
+ worstPct = pct
+ worst = b
+ }
+ }
+ return worst
+}
+
+export function useGitHubRateLimitSnapshot(options?: { autoRefresh?: boolean }): {
+ snapshot: GitHubRateLimitSnapshot | null
+ hasError: boolean
+ isFetching: boolean
+ refresh: (force?: boolean) => Promise
+} {
+ const [snapshot, setSnapshot] = useState(null)
+ const [hasError, setHasError] = useState(false)
+ const [isFetching, setIsFetching] = useState(false)
+ const settings = useAppStore((s) => s.settings)
+ const latestToken = useRef(0)
+ const autoRefresh = options?.autoRefresh ?? true
+
+ const refresh = useCallback(
+ async (force = false): Promise => {
+ const token = ++latestToken.current
+ setIsFetching(true)
+ try {
+ const target = getActiveRuntimeTarget(settings)
+ const params = force ? { force: true } : undefined
+ const res =
+ target.kind === 'environment'
+ ? await callRuntimeRpc(target, 'github.rateLimit', params ?? {}, {
+ timeoutMs: 30_000
+ })
+ : ((await window.api.gh.rateLimit(params)) as GetRateLimitResult | undefined)
+ if (token !== latestToken.current) {
+ return
+ }
+ if (res?.ok) {
+ setSnapshot(res.snapshot)
+ setHasError(false)
+ } else {
+ setHasError(true)
+ }
+ } catch {
+ if (token === latestToken.current) {
+ setHasError(true)
+ }
+ } finally {
+ if (token === latestToken.current) {
+ setIsFetching(false)
+ }
+ }
+ },
+ [settings]
+ )
+
+ useEffect(() => {
+ if (!autoRefresh) {
+ return
+ }
+ const fetchIfVisible = (): void => {
+ if (document.visibilityState === 'visible' && document.hasFocus()) {
+ void refresh(false)
+ }
+ }
+ void refresh(false)
+ const handle = window.setInterval(fetchIfVisible, REFRESH_INTERVAL_MS)
+ window.addEventListener('focus', fetchIfVisible)
+ document.addEventListener('visibilitychange', fetchIfVisible)
+ return () => {
+ window.clearInterval(handle)
+ window.removeEventListener('focus', fetchIfVisible)
+ document.removeEventListener('visibilitychange', fetchIfVisible)
+ }
+ }, [autoRefresh, refresh])
+
+ return { snapshot, hasError, isFetching, refresh }
+}
+
+function GitHubRateLimitRows({
+ snapshot
+}: {
+ snapshot: GitHubRateLimitSnapshot
+}): React.JSX.Element {
+ return (
+
+ {BUCKETS.map((b) => {
+ const v = snapshot[b.key]
+ const tone = toneForGitHubBucket(v.remaining, v.limit)
+ return (
+
+ {b.description}
+
+ {v.remaining} of {v.limit} left · resets in {formatGitHubRateLimitReset(v.resetAt)}
+
+
+ )
+ })}
+
+ )
+}
+
+export function GitHubRateLimitCompact({
+ className,
+ hideHealthy = true,
+ label = 'GitHub API budget',
+ tooltipSide = 'top'
+}: {
+ className?: string
+ hideHealthy?: boolean
+ label?: string
+ tooltipSide?: 'top' | 'right' | 'bottom' | 'left'
+}): React.JSX.Element | null {
+ const { snapshot, hasError, refresh } = useGitHubRateLimitSnapshot()
+ if (!snapshot || hasError) {
+ return null
+ }
+
+ const tone = worstGitHubRateLimitTone(snapshot)
+ if (hideHealthy && tone === 'ok') {
+ return null
+ }
+ const tight = tightestGitHubBucket(snapshot)
+ const tightBucket = snapshot[tight.key]
+
+ return (
+
+
+ void refresh(true)}
+ aria-label={label}
+ className={cn(
+ 'inline-flex h-6 min-w-0 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium transition',
+ tone === 'ok' && 'border-border bg-secondary text-secondary-foreground hover:bg-accent',
+ tone === 'crit' &&
+ 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300 hover:bg-red-500/20',
+ tone === 'warn' &&
+ 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300 hover:bg-amber-500/20',
+ className
+ )}
+ >
+
+
+ {tightBucket.remaining} {tight.label.toLowerCase()} left · resets in{' '}
+ {formatGitHubRateLimitReset(tightBucket.resetAt)}
+
+
+
+
+ {label}
+
+
+
+ Click to refresh
+
+
+ )
+}
+
+export function GitHubRateLimitPanel({ className }: { className?: string }): React.JSX.Element {
+ const { snapshot, hasError, isFetching, refresh } = useGitHubRateLimitSnapshot()
+
+ return (
+
+
+
+
+
+ GitHub API Budget
+
+
+ Orca uses REST, Search, and GraphQL through the GitHub CLI.
+
+
+
void refresh(true)}
+ disabled={isFetching}
+ className="inline-flex size-7 items-center justify-center rounded-md border border-border bg-secondary text-secondary-foreground transition hover:bg-accent disabled:opacity-50"
+ aria-label="Refresh GitHub API budget"
+ >
+
+
+
+ {hasError ? (
+
GitHub API budget is unavailable.
+ ) : snapshot ? (
+
+ ) : (
+
Loading GitHub API budget…
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts
new file mode 100644
index 00000000000..fdf6842f23e
--- /dev/null
+++ b/src/renderer/src/components/linear-issue-workspace-text.ts
@@ -0,0 +1,46 @@
+import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace'
+import type { LinearIssue } from '../../../shared/types'
+
+const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+
+export function formatLinearIssueRelativeTime(input: string): string {
+ const date = new Date(input)
+ if (Number.isNaN(date.getTime())) {
+ return 'recently'
+ }
+ const diffMs = date.getTime() - Date.now()
+ const diffMinutes = Math.round(diffMs / 60_000)
+ if (Math.abs(diffMinutes) < 60) {
+ return relativeTimeFormatter.format(diffMinutes, 'minute')
+ }
+ const diffHours = Math.round(diffMinutes / 60)
+ if (Math.abs(diffHours) < 24) {
+ return relativeTimeFormatter.format(diffHours, 'hour')
+ }
+ const diffDays = Math.round(diffHours / 24)
+ return relativeTimeFormatter.format(diffDays, 'day')
+}
+
+export function buildLinearIssueBranchName(issue: LinearIssue): string {
+ const titleSlug = getLinkedWorkItemSuggestedName(issue)
+ const key = issue.identifier.toLowerCase()
+ return titleSlug ? `${key}-${titleSlug}` : key
+}
+
+export function buildLinearIssuePrompt(issue: LinearIssue): string {
+ const lines = [
+ `Linear issue: ${issue.identifier} ${issue.title}`,
+ `URL: ${issue.url}`,
+ `State: ${issue.state.name}`,
+ `Assignee: ${issue.assignee?.displayName ?? 'Unassigned'}`,
+ `Team: ${issue.team.name}`
+ ]
+ if (issue.workspaceName) {
+ lines.push(`Workspace: ${issue.workspaceName}`)
+ }
+ const description = issue.description?.trim()
+ if (description) {
+ lines.push('', 'Description:', description)
+ }
+ return lines.join('\n')
+}
diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
index c6625cfbfb4..8ea4d0fa4c8 100644
--- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
+++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
@@ -159,7 +159,6 @@ export default function SmartWorkspaceNameField({
searchLinearIssues: s.searchLinearIssues
}))
)
-
const selectedRepo = useMemo(
() => repos.find((repo) => repo.id === repoId) ?? null,
[repoId, repos]
@@ -227,7 +226,7 @@ export default function SmartWorkspaceNameField({
const shouldQueryLinear = mode === 'smart' || mode === 'linear'
useEffect(() => {
- if (!shouldQueryGithub || !selectedRepo?.path || selectedRepo.connectionId) {
+ if (!shouldQueryGithub || !selectedRepo?.path) {
setGithubItems([])
setGithubLoading(false)
return
@@ -246,6 +245,7 @@ export default function SmartWorkspaceNameField({
handledCrossRepoUrlRef.current = debouncedQuery.trim()
const item = await window.api.gh.workItemByOwnerRepo({
repoPath: selectedRepo.path,
+ repoId: selectedRepo.id,
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
@@ -282,12 +282,17 @@ export default function SmartWorkspaceNameField({
directLink !== null
? window.api.gh.workItemByOwnerRepo({
repoPath: selectedRepo.path,
+ repoId: selectedRepo.id,
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
type: directLink.type
})
- : window.api.gh.workItem({ repoPath: selectedRepo.path, number: directNumber })
+ : window.api.gh.workItem({
+ repoPath: selectedRepo.path,
+ repoId: selectedRepo.id,
+ number: directNumber
+ })
void request
.then((item) => {
if (!stale) {
@@ -311,7 +316,7 @@ export default function SmartWorkspaceNameField({
const trimmed = normalizedGhQuery.query.trim()
const query = trimmed ? normalizedGhQuery.query : ''
- const cached = getCachedWorkItems(selectedRepo.path, RESULT_LIMIT, query)
+ const cached = getCachedWorkItems(selectedRepo.id, RESULT_LIMIT, query)
if (cached) {
setGithubItems(cached.slice(0, RESULT_LIMIT))
setGithubLoading(false)
@@ -707,6 +712,7 @@ export default function SmartWorkspaceNameField({
try {
const item = await window.api.gh.workItemByOwnerRepo({
repoPath: targetRepo.path,
+ repoId: targetRepo.id,
owner: crossRepoPrompt.link.slug.owner,
repo: crossRepoPrompt.link.slug.repo,
number: crossRepoPrompt.link.number,
@@ -1169,19 +1175,16 @@ async function getRepoSlugCached(
repo: RepoOption,
cache: Map
): Promise {
- if (cache.has(repo.id)) {
- return cache.get(repo.id) ?? null
- }
- if (repo.connectionId) {
- cache.set(repo.id, null)
- return null
+ const cacheKey = repo.id
+ if (cache.has(cacheKey)) {
+ return cache.get(cacheKey) ?? null
}
try {
- const slug = await window.api.gh.repoSlug({ repoPath: repo.path })
- cache.set(repo.id, slug)
+ const slug = await window.api.gh.repoSlug({ repoPath: repo.path, repoId: repo.id })
+ cache.set(cacheKey, slug)
return slug
} catch {
- cache.set(repo.id, null)
+ cache.set(cacheKey, null)
return null
}
}
diff --git a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx b/src/renderer/src/components/notes/ProjectNotesTabContent.tsx
deleted file mode 100644
index 4f511405a88..00000000000
--- a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx
+++ /dev/null
@@ -1,681 +0,0 @@
-/* eslint-disable max-lines -- Why: project notes keep picker, save-as, and
- rich/source/preview markdown modes together so unsaved note creation and
- active-note switching cannot drift across separate surfaces. */
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { Copy, FileText, MoreHorizontal, Plus, Save } from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from '@/components/ui/dialog'
-import RichMarkdownEditor from '@/components/editor/RichMarkdownEditor'
-import MarkdownPreview from '@/components/editor/MarkdownPreview'
-import EditorViewToggle from '@/components/editor/EditorViewToggle'
-import { useAppStore } from '@/store'
-import { useRepoById, useWorktreeById } from '@/store/selectors'
-import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
-import {
- getProjectNotesEntityId,
- notifyProjectNotesSelectionChanged
-} from '@/lib/open-project-notes-tab'
-import {
- ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT,
- type ProjectNotesCloseRequestDetail
-} from '@/lib/project-notes-close-request'
-import { toast } from 'sonner'
-import type { MarkdownDocument } from '../../../../shared/types'
-import type { NoteRecord, NoteSummary, NotesPanelOpenState } from '../../../../shared/notes-types'
-import type { MarkdownViewMode } from '@/store/slices/editor'
-
-const SAVE_DEBOUNCE_MS = 700
-
-type Draft = {
- id: string | null
- filePath: string | null
- relativePath: string | null
- title: string
- bodyMarkdown: string
- revision: number | null
-}
-
-function emptyDraft(): Draft {
- return {
- id: null,
- filePath: null,
- relativePath: null,
- title: 'Untitled note',
- bodyMarkdown: '',
- revision: null
- }
-}
-
-export default function ProjectNotesTabContent({
- worktreeId,
- tabId,
- noteId = null,
- forceNew = false,
- onDirtyChange
-}: {
- worktreeId: string
- tabId: string
- noteId?: string | null
- forceNew?: boolean
- onDirtyChange?: (dirty: boolean) => void
-}): React.JSX.Element {
- const worktree = useWorktreeById(worktreeId)
- const repo = useRepoById(worktree?.repoId ?? null)
- const projectId = repo?.id ?? worktree?.repoId ?? null
-
- const [panelState, setPanelState] = useState({ state: 'noProject' })
- const [notes, setNotes] = useState([])
- const [draft, setDraft] = useState(() => emptyDraft())
- const [dirty, setDirty] = useState(false)
- const [saving, setSaving] = useState(false)
- const [error, setError] = useState(null)
- const [viewMode, setViewMode] = useState('rich')
- const [saveAsOpen, setSaveAsOpen] = useState(false)
- const [closePromptOpen, setClosePromptOpen] = useState(false)
- const [saveAsTitle, setSaveAsTitle] = useState('Untitled note')
- const [selectedNoteId, setSelectedNoteId] = useState(noteId)
- const saveAsInputRef = useRef(null)
- const pendingCreateBodyRef = useRef(null)
- const pendingCloseRef = useRef<(() => void) | null>(null)
-
- const canSave = projectId !== null && (draft.id === null || draft.title.trim().length > 0)
-
- const refreshNotes = useCallback(async (): Promise => {
- if (!projectId) {
- setNotes([])
- return
- }
- const result = await window.api.notes.list({ projectId, worktreeId, limit: 100 })
- setNotes(result.notes)
- }, [projectId, worktreeId])
-
- const loadPanelState = useCallback(async (): Promise => {
- try {
- setError(null)
- if (selectedNoteId && projectId) {
- const result = await window.api.notes.show({ projectId, worktreeId, note: selectedNoteId })
- setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
- setDraft({
- id: result.note.id,
- filePath: result.note.filePath,
- relativePath: result.note.relativePath,
- title: result.note.title,
- bodyMarkdown: result.note.bodyMarkdown,
- revision: result.note.revision
- })
- setDirty(false)
- const state = useAppStore.getState()
- state.setTabLabel(tabId, result.note.title)
- state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
- await refreshNotes()
- return
- }
- if (forceNew && projectId) {
- setPanelState({ state: 'emptyDraft', projectId, worktreeId })
- setDraft(emptyDraft())
- setDirty(false)
- useAppStore.getState().setTabLabel(tabId, 'Project Notes')
- await refreshNotes()
- return
- }
- const next = await window.api.notes.panelState({ projectId, worktreeId })
- setPanelState(next)
- if (next.state === 'active') {
- setDraft({
- id: next.note.id,
- filePath: next.note.filePath,
- relativePath: next.note.relativePath,
- title: next.note.title,
- bodyMarkdown: next.note.bodyMarkdown,
- revision: next.note.revision
- })
- const state = useAppStore.getState()
- state.setTabLabel(tabId, next.note.title)
- state.setTabEntityId(tabId, getProjectNotesEntityId(next.projectId, next.note.id))
- setDirty(false)
- } else {
- setDraft(emptyDraft())
- useAppStore
- .getState()
- .setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId))
- setDirty(false)
- }
- if (next.state === 'pickerRequired') {
- setNotes(next.notes)
- } else {
- await refreshNotes()
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- }
- }, [forceNew, projectId, refreshNotes, selectedNoteId, tabId, worktreeId])
-
- useEffect(() => {
- void loadPanelState()
- }, [loadPanelState])
-
- useEffect(() => {
- setSelectedNoteId(noteId)
- }, [noteId])
-
- useEffect(() => {
- useAppStore.getState().setTabDirty(tabId, dirty)
- onDirtyChange?.(dirty)
- }, [dirty, onDirtyChange, tabId])
-
- useEffect(() => {
- const listener = (): void => {
- if (dirty) {
- return
- }
- void loadPanelState()
- }
- window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
- return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
- }, [dirty, loadPanelState])
-
- useEffect(() => {
- const listener = (event: Event): void => {
- const detail = (event as CustomEvent).detail
- if (!detail || detail.tabId !== tabId) {
- return
- }
- detail.claim()
- if (!dirty) {
- detail.close()
- return
- }
- pendingCloseRef.current = detail.close
- setClosePromptOpen(true)
- }
- window.addEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener)
- return () => window.removeEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener)
- }, [dirty, tabId])
-
- const selectNote = useCallback(
- async (noteId: string): Promise => {
- if (!projectId) {
- return
- }
- const result = await window.api.notes.show({ projectId, worktreeId, note: noteId })
- await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' })
- setSelectedNoteId(noteId)
- setDraft({
- id: result.note.id,
- filePath: result.note.filePath,
- relativePath: result.note.relativePath,
- title: result.note.title,
- bodyMarkdown: result.note.bodyMarkdown,
- revision: result.note.revision
- })
- setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
- const state = useAppStore.getState()
- state.setTabLabel(tabId, result.note.title)
- state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
- setDirty(false)
- await refreshNotes()
- },
- [projectId, refreshNotes, tabId, worktreeId]
- )
-
- const saveDraft = useCallback(
- async (bodyOverride?: string): Promise => {
- if (!canSave || !projectId) {
- return null
- }
- const bodyMarkdown = bodyOverride ?? draft.bodyMarkdown
- if (!draft.id) {
- // Why: project notes are markdown files. Match untitled Markdown tabs
- // by asking for the user-facing name before creating the file instead
- // of silently persisting "Untitled note" from autosave.
- pendingCreateBodyRef.current = bodyMarkdown
- setSaveAsTitle(draft.title.trim() || 'Untitled note')
- setSaveAsOpen(true)
- return null
- }
- setSaving(true)
- try {
- const result = await window.api.notes.save({
- projectId,
- worktreeId,
- note: draft.id,
- title: draft.title,
- bodyMarkdown,
- revision: draft.revision ?? undefined,
- makeActive: true
- })
- setDraft({
- id: result.note.id,
- filePath: result.note.filePath,
- relativePath: result.note.relativePath,
- title: result.note.title,
- bodyMarkdown: result.note.bodyMarkdown,
- revision: result.note.revision
- })
- setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
- setSelectedNoteId(result.note.id)
- const state = useAppStore.getState()
- state.setTabLabel(tabId, result.note.title)
- state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
- setDirty(false)
- await refreshNotes()
- notifyProjectNotesSelectionChanged()
- return result.note
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- return null
- } finally {
- setSaving(false)
- }
- },
- [canSave, draft, projectId, refreshNotes, tabId, worktreeId]
- )
-
- useEffect(() => {
- if (!dirty || !canSave || !draft.id) {
- return
- }
- const timer = window.setTimeout(() => {
- void saveDraft()
- }, SAVE_DEBOUNCE_MS)
- return () => window.clearTimeout(timer)
- }, [canSave, dirty, draft.id, saveDraft])
-
- const createNewDraft = useCallback(() => {
- setSelectedNoteId(null)
- setDraft(emptyDraft())
- const state = useAppStore.getState()
- state.setTabLabel(tabId, 'Project Notes')
- state.setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId))
- setPanelState(
- projectId ? { state: 'emptyDraft', projectId, worktreeId } : { state: 'noProject' }
- )
- setDirty(false)
- }, [projectId, tabId, worktreeId])
-
- const markdownDocuments = useMemo(
- () =>
- notes.map((note) => ({
- filePath: note.filePath,
- relativePath: note.relativePath,
- basename: note.relativePath.split('/').pop() ?? note.title,
- name: note.title
- })),
- [notes]
- )
-
- const editorFilePath =
- draft.filePath ?? `orca://project-notes/${projectId ?? 'project'}/untitled.md`
- const editorPathLabel = draft.filePath
- ? (draft.relativePath ?? draft.title)
- : `notes/${draft.title.trim() || 'untitled'}.md`
-
- useEffect(() => {
- if (error) {
- toast.error(error)
- }
- }, [error])
-
- useEffect(() => {
- if (!saveAsOpen) {
- return
- }
- requestAnimationFrame(() => saveAsInputRef.current?.select())
- }, [saveAsOpen])
-
- const confirmCreateNote = useCallback(async (): Promise => {
- if (!projectId) {
- return
- }
- const title = saveAsTitle.trim().replace(/\.md$/i, '')
- if (!title) {
- setError('Name cannot be empty')
- return
- }
- setSaving(true)
- try {
- const result = await window.api.notes.create({
- projectId,
- worktreeId,
- title,
- bodyMarkdown: pendingCreateBodyRef.current ?? draft.bodyMarkdown,
- makeActive: true
- })
- setDraft({
- id: result.note.id,
- filePath: result.note.filePath,
- relativePath: result.note.relativePath,
- title: result.note.title,
- bodyMarkdown: result.note.bodyMarkdown,
- revision: result.note.revision
- })
- setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
- setSelectedNoteId(result.note.id)
- const state = useAppStore.getState()
- state.setTabLabel(tabId, result.note.title)
- state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
- setDirty(false)
- setSaveAsOpen(false)
- pendingCreateBodyRef.current = null
- await refreshNotes()
- notifyProjectNotesSelectionChanged()
- const pendingClose = pendingCloseRef.current
- if (pendingClose) {
- pendingCloseRef.current = null
- pendingClose()
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- } finally {
- setSaving(false)
- }
- }, [draft.bodyMarkdown, projectId, refreshNotes, saveAsTitle, tabId, worktreeId])
-
- const handleClosePromptSave = useCallback(async (): Promise => {
- setClosePromptOpen(false)
- const saved = await saveDraft()
- if (!saved) {
- if (draft.id) {
- setClosePromptOpen(true)
- }
- return
- }
- const pendingClose = pendingCloseRef.current
- pendingCloseRef.current = null
- pendingClose?.()
- }, [draft.id, saveDraft])
-
- const handleClosePromptDiscard = useCallback(() => {
- setClosePromptOpen(false)
- setDirty(false)
- const pendingClose = pendingCloseRef.current
- pendingCloseRef.current = null
- pendingClose?.()
- }, [])
-
- const handleClosePromptCancel = useCallback(() => {
- setClosePromptOpen(false)
- pendingCloseRef.current = null
- }, [])
-
- const handleSaveAsCancel = useCallback(() => {
- setSaveAsOpen(false)
- pendingCreateBodyRef.current = null
- pendingCloseRef.current = null
- }, [])
-
- return (
-
-
-
-
- {
- void window.api.ui.writeClipboardText(editorFilePath)
- }}
- >
- {editorPathLabel}
-
-
-
-
setViewMode(next as MarkdownViewMode)}
- />
-
-
-
-
-
-
-
- Current note
-
- {
- setDraft((current) => ({ ...current, title: event.target.value }))
- setDirty(true)
- }}
- className="h-8 text-xs"
- placeholder="Note title"
- />
-
-
-
- New note
-
- {
- void window.api.ui.writeClipboardText(draft.bodyMarkdown)
- }}
- >
-
- Copy Markdown
-
- void saveDraft()}>
-
- Save now
-
- {notes.length > 0 ? (
- <>
-
- Switch note
- {notes.map((note) => (
- void selectNote(note.id)}>
-
- {note.title}
-
- ))}
- >
- ) : null}
-
-
-
-
-
- {panelState.state === 'noProject' ? (
-
- Open a project worktree to use notes.
-
- ) : panelState.state === 'pickerRequired' ? (
-
-
-
-
Project Notes
-
- Choose a saved note or start a new one.
-
-
-
-
- New note
-
-
-
- {notes.map((note) => (
- void selectNote(note.id)}
- >
-
-
-
- {note.title}
-
-
- {note.preview || note.relativePath}
-
-
-
- ))}
-
-
- ) : viewMode === 'source' ? (
-
-
{
- if (isOpen) {
- return
- }
- handleSaveAsCancel()
- }}
- >
-
-
- Save project note
-
- Name this markdown note before saving it in Orca project notes.
-
-
-
-
Name
-
- setSaveAsTitle(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault()
- void confirmCreateNote()
- }
- }}
- className="h-8 text-sm"
- placeholder="note name"
- />
- .md
-
-
-
-
- Cancel
-
- void confirmCreateNote()}>
- Save
-
-
-
-
-
{
- if (!isOpen) {
- handleClosePromptCancel()
- }
- }}
- >
-
-
- Unsaved Changes
-
- "{draft.title.trim() || 'Project Notes'}" has unsaved changes. Do you want
- to save before closing?
-
-
-
-
- Cancel
-
-
- Don't Save
-
- void handleClosePromptSave()}
- >
- Save
-
-
-
-
-
- )
-}
diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx
index 755afab67ca..dd23fa1d162 100644
--- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx
@@ -173,8 +173,14 @@ export default function OnboardingFlow({
cloneUrl={flow.cloneUrl}
onCloneUrlChange={flow.setCloneUrl}
onOpenFolder={() => void flow.openFolder()}
+ onOpenServerFolder={(kind) => void flow.openFolder(kind)}
onClone={() => void flow.clone()}
+ serverPath={flow.serverPath}
+ onServerPathChange={flow.setServerPath}
+ cloneDestination={flow.cloneDestination}
+ onCloneDestinationChange={flow.setCloneDestination}
workspaceDir={flow.settings?.workspaceDir ?? ''}
+ runtimeActive={Boolean(flow.settings?.activeRuntimeEnvironmentId?.trim())}
busyLabel={flow.busyLabel}
error={flow.error}
/>
diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx
index ddaada2bf07..36f5c86ebf5 100644
--- a/src/renderer/src/components/onboarding/RepoStep.tsx
+++ b/src/renderer/src/components/onboarding/RepoStep.tsx
@@ -4,8 +4,14 @@ type RepoStepProps = {
cloneUrl: string
onCloneUrlChange: (value: string) => void
onOpenFolder: () => void
+ onOpenServerFolder: (kind: 'git' | 'folder') => void
onClone: () => void
+ serverPath: string
+ onServerPathChange: (value: string) => void
+ cloneDestination: string
+ onCloneDestinationChange: (value: string) => void
workspaceDir: string
+ runtimeActive: boolean
busyLabel: string | null
error: string | null
}
@@ -14,36 +20,89 @@ export function RepoStep({
cloneUrl,
onCloneUrlChange,
onOpenFolder,
+ onOpenServerFolder,
onClone,
+ serverPath,
+ onServerPathChange,
+ cloneDestination,
+ onCloneDestinationChange,
workspaceDir,
+ runtimeActive,
busyLabel,
error
}: RepoStepProps) {
const disabled = Boolean(busyLabel)
return (
-
-
-
-
-
-
Open a folder
-
- Choose any local directory, git repo or not.
+ {runtimeActive ? (
+
-
- Browse…
-
-
+
+ onServerPathChange(event.target.value)}
+ />
+
+ Add Git Project
+
+ onOpenServerFolder('folder')}
+ >
+ Open as Folder
+
+
+
+ ) : (
+
+
+
+
+
+
Open a folder
+
+ Choose any local directory, git repo or not.
+
+
+
+ Browse...
+
+
+ )}
+ {runtimeActive && (
+
+
+ Clone into server path
+
+ onCloneDestinationChange(event.target.value)}
+ />
+
+ )}
Workspace
- {workspaceDir}
+
+ {runtimeActive ? 'Runtime server' : workspaceDir}
+
- SSH? Set hosts up in Settings
+ {runtimeActive ? 'Server paths only' : 'SSH? Set hosts up in Settings'}
diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts
index 7fc319534ca..29c6a2fa58d 100644
--- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts
+++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts
@@ -52,6 +52,8 @@ const INSTALLED_CLI_STATUS: CliInstallStatus = {
const GRANTED_COMPUTER_USE_STATUS: ComputerUsePermissionStatusResult = {
platform: 'darwin',
+ helperAppPath: '/Applications/Orca Computer Use.app',
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'granted' }
@@ -157,6 +159,8 @@ describe('onboarding feature setup runner', () => {
getComputerUsePermissionStatus: vi.fn(
async (): Promise => ({
platform: 'darwin',
+ helperAppPath: '/Applications/Orca Computer Use.app',
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'granted' }
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts
index df477c1eb3a..dc0c0bbd405 100644
--- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts
+++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts
@@ -8,7 +8,7 @@ import { applyDocumentTheme } from '@/lib/document-theme'
import { track } from '@/lib/telemetry'
import { buildAgentPickedPayload } from './agent-picked-payload'
import { isGitRepoKind } from '../../../../shared/repo-kind'
-import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types'
+import type { GlobalSettings, OnboardingState, Repo, TuiAgent } from '../../../../shared/types'
import type { NotificationDraft } from './NotificationStep'
import {
DEFAULT_ONBOARDING_FEATURE_SETUP_SELECTION,
@@ -19,6 +19,7 @@ import {
} from './onboarding-feature-setup'
import { STEPS, type StepNumber } from './use-onboarding-flow-types'
import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
export { STEPS } from './use-onboarding-flow-types'
export type { StepId, StepNumber } from './use-onboarding-flow-types'
@@ -38,6 +39,7 @@ export function useOnboardingFlow(
const pathFailureReason = useAppStore((s) => s.pathFailureReason)
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
+ const addRepoPath = useAppStore((s) => s.addRepoPath)
const openModal = useAppStore((s) => s.openModal)
const initialStep = Math.min(Math.max(onboarding.lastCompletedStep, 0), STEPS.length - 1)
@@ -69,6 +71,8 @@ export function useOnboardingFlow(
const [featureSetupTerminalSelection, setFeatureSetupTerminalSelection] =
useState(null)
const [cloneUrl, setCloneUrl] = useState('')
+ const [serverPath, setServerPath] = useState('')
+ const [cloneDestination, setCloneDestination] = useState('')
const [busyLabel, setBusyLabel] = useState(null)
const [error, setError] = useState(null)
@@ -394,35 +398,62 @@ export function useOnboardingFlow(
]
)
- const openFolder = useCallback(async () => {
- // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers.
- if (busyLabel !== null) {
- return
- }
- setError(null)
- track('onboarding_step4_path_clicked', { path: 'open_folder' })
- const path = await window.api.repos.pickFolder()
- if (!path) {
- track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' })
- return
- }
- setBusyLabel('Opening project…')
- try {
- let result = await window.api.repos.add({ path })
- if ('error' in result && result.error.includes('Not a valid git repository')) {
- result = await window.api.repos.add({ path, kind: 'folder' })
+ const openFolder = useCallback(
+ async (kind: 'git' | 'folder' = 'git') => {
+ // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers.
+ if (busyLabel !== null) {
+ return
}
- if ('error' in result) {
- throw new Error(result.error)
+ setError(null)
+ if (settings?.activeRuntimeEnvironmentId?.trim()) {
+ const path = serverPath.trim()
+ if (!path) {
+ const message = 'Enter a server path.'
+ setError(message)
+ return
+ }
+ track('onboarding_step4_path_clicked', { path: 'open_folder' })
+ setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…')
+ try {
+ const repo = await addRepoPath(path, kind)
+ if (!repo) {
+ track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
+ return
+ }
+ await completeRepo(repo.id, isGitRepoKind(repo), 'open_folder')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err))
+ track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
+ } finally {
+ setBusyLabel(null)
+ }
+ return
}
- await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder')
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
- } finally {
- setBusyLabel(null)
- }
- }, [busyLabel, completeRepo])
+ track('onboarding_step4_path_clicked', { path: 'open_folder' })
+ const path = await window.api.repos.pickFolder()
+ if (!path) {
+ track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' })
+ return
+ }
+ setBusyLabel('Opening project…')
+ try {
+ let result = await window.api.repos.add({ path })
+ if ('error' in result && result.error.includes('Not a valid git repository')) {
+ result = await window.api.repos.add({ path, kind: 'folder' })
+ }
+ if ('error' in result) {
+ throw new Error(result.error)
+ }
+ await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder')
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err))
+ track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' })
+ } finally {
+ setBusyLabel(null)
+ }
+ },
+ [addRepoPath, busyLabel, completeRepo, serverPath, settings?.activeRuntimeEnvironmentId]
+ )
const clone = useCallback(async () => {
// Why: re-entry guard — prevents Enter spamming from triggering duplicate clones.
@@ -435,12 +466,30 @@ export function useOnboardingFlow(
}
setError(null)
track('onboarding_step4_path_clicked', { path: 'clone_url' })
+ const target = getActiveRuntimeTarget(settings)
+ const destination =
+ target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir
+ if (!destination) {
+ const message = 'Enter a server path for the clone destination.'
+ setError(message)
+ return
+ }
setBusyLabel('Cloning repo…')
try {
- const repo = await window.api.repos.clone({
- url: trimmed,
- destination: settings.workspaceDir
- })
+ const repo =
+ target.kind === 'environment'
+ ? (
+ await callRuntimeRpc<{ repo: Repo }>(
+ target,
+ 'repo.clone',
+ { url: trimmed, destination },
+ { timeoutMs: 10 * 60_000 }
+ )
+ ).repo
+ : await window.api.repos.clone({
+ url: trimmed,
+ destination
+ })
await completeRepo(repo.id, true, 'clone_url')
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
@@ -451,7 +500,7 @@ export function useOnboardingFlow(
} finally {
setBusyLabel(null)
}
- }, [busyLabel, cloneUrl, completeRepo, settings])
+ }, [busyLabel, cloneDestination, cloneUrl, completeRepo, settings])
const skip = useCallback(async () => {
if (busyLabel) {
@@ -521,6 +570,10 @@ export function useOnboardingFlow(
hasSelectedFeatureSetup,
cloneUrl,
setCloneUrl,
+ serverPath,
+ setServerPath,
+ cloneDestination,
+ setCloneDestination,
busyLabel,
error,
detectedSet,
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index 1e644af9a83..582a552a77c 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -5,6 +5,7 @@ import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide-
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
import { isFolderRepo } from '../../../../shared/repo-kind'
import PRActions from './PRActions'
import {
@@ -14,9 +15,18 @@ import {
MergeConflictNotice,
ChecksList,
PRCommentsList
-} from './checks-helpers'
+} from './checks-panel-content'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
+import { getConnectionId } from '@/lib/connection-context'
+import { CreatePullRequestDialog } from './CreatePullRequestDialog'
+import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
+import { toast } from 'sonner'
+import {
+ classifyHostedReview,
+ type HostedReviewClassificationOptions
+} from '../../../../shared/hosted-review-queue'
+import { hostedReviewSummaryFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
export default function ChecksPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
@@ -24,7 +34,17 @@ export default function ChecksPanel(): React.JSX.Element {
const repo = useRepoById(activeWorktree?.repoId ?? null)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
+ const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
+ const getHostedReviewCreationEligibility = useAppStore(
+ (s) => s.getHostedReviewCreationEligibility
+ )
const gitConflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
+ const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
+ const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree)
+ const pushBranch = useAppStore((s) => s.pushBranch)
+ const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus)
+ const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
+ const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
// Why: the sidebar stays mounted when closed (for performance). Gate
// polling on visibility so we don't fetch checks/comments in the background
@@ -43,6 +63,11 @@ export default function ChecksPanel(): React.JSX.Element {
const [commentsLoading, setCommentsLoading] = useState(false)
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
+ const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
+ const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false)
+ const [createPrPushFirst, setCreatePrPushFirst] = useState(false)
+ const [hostedReviewCreation, setHostedReviewCreation] =
+ useState(null)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const [titleSaving, setTitleSaving] = useState(false)
@@ -67,15 +92,22 @@ export default function ChecksPanel(): React.JSX.Element {
setTitleSaving(false)
setIsRefreshing(false)
setEmptyRefreshing(false)
+ setConflictDetailsRefreshing(false)
+ setCreatePrDialogOpen(false)
+ setCreatePrPushFirst(false)
conflictSummaryRefreshKeyRef.current = null
}
// Find active worktree and repo
const branch = activeWorktree ? activeWorktree.branch.replace(/^refs\/heads\//, '') : ''
const isFolder = repo ? isFolderRepo(repo) : false
- const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
+ const prCacheKey = repo && branch ? `${repo.id}::${branch}` : ''
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
const prNumber = pr?.number ?? null
+ const remoteStatus = activeWorktreeId ? remoteStatusesByWorktree[activeWorktreeId] : undefined
+ const hasUncommittedChanges = activeWorktreeId
+ ? (gitStatusByWorktree[activeWorktreeId]?.length ?? 0) > 0
+ : false
const conflictOperation = activeWorktreeId
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
: 'unknown'
@@ -86,8 +118,8 @@ export default function ChecksPanel(): React.JSX.Element {
const prFetchedAt = useAppStore((s) =>
prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined
)
- const checksCacheKey = repo && prNumber ? `${repo.path}::pr-checks::${prNumber}` : ''
- const commentsCacheKey = repo && prNumber ? `${repo.path}::pr-comments::${prNumber}` : ''
+ const checksCacheKey = repo && prNumber ? `${repo.id}::pr-checks::${prNumber}` : ''
+ const commentsCacheKey = repo && prNumber ? `${repo.id}::pr-comments::${prNumber}` : ''
const checksFetchedAt = useAppStore((s) =>
checksCacheKey ? s.checksCache[checksCacheKey]?.fetchedAt : undefined
)
@@ -101,13 +133,56 @@ export default function ChecksPanel(): React.JSX.Element {
const linkedPR = activeWorktree?.linkedPR ?? null
useEffect(() => {
if (repo && !isFolder && branch) {
- void fetchPRForBranch(repo.path, branch, { linkedPRNumber: linkedPR })
+ void fetchPRForBranch(repo.path, branch, { repoId: repo.id, linkedPRNumber: linkedPR })
}
}, [repo, isFolder, branch, linkedPR, fetchPRForBranch])
+ useEffect(() => {
+ if (!repo || isFolder || !branch || !isPanelVisible) {
+ setHostedReviewCreation(null)
+ return
+ }
+ let stale = false
+ void getHostedReviewCreationEligibility({
+ repoPath: repo.path,
+ branch,
+ base: repo.worktreeBaseRef ?? null,
+ hasUncommittedChanges,
+ hasUpstream: remoteStatus?.hasUpstream,
+ ahead: remoteStatus?.ahead,
+ behind: remoteStatus?.behind,
+ linkedGitHubPR: linkedPR
+ })
+ .then((result) => {
+ if (!stale) {
+ setHostedReviewCreation(result)
+ }
+ })
+ .catch(() => {
+ if (!stale) {
+ setHostedReviewCreation(null)
+ }
+ })
+ return () => {
+ stale = true
+ }
+ }, [
+ branch,
+ getHostedReviewCreationEligibility,
+ hasUncommittedChanges,
+ isFolder,
+ isPanelVisible,
+ linkedPR,
+ remoteStatus?.ahead,
+ remoteStatus?.behind,
+ remoteStatus?.hasUpstream,
+ repo
+ ])
+
useEffect(() => {
if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') {
conflictSummaryRefreshKeyRef.current = null
+ setConflictDetailsRefreshing(false)
return
}
@@ -121,7 +196,19 @@ export default function ChecksPanel(): React.JSX.Element {
// them so we don't keep rendering cached branch summaries or empty file
// lists from an older payload.
conflictSummaryRefreshKeyRef.current = refreshKey
- void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
+ setConflictDetailsRefreshing(true)
+ void fetchPRForBranch(repo.path, branch, {
+ force: true,
+ repoId: repo.id,
+ linkedPRNumber: linkedPR
+ }).finally(() => {
+ // Why: fetchPRForBranch updates the PR cache before resolving, which
+ // can rerun this effect. Only the current refresh key may clear the
+ // spinner so stale requests don't race newer worktrees/branches.
+ if (conflictSummaryRefreshKeyRef.current === refreshKey) {
+ setConflictDetailsRefreshing(false)
+ }
+ })
}, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch])
// Fetch checks via cached store method
@@ -137,7 +224,8 @@ export default function ChecksPanel(): React.JSX.Element {
setChecksLoading(true)
try {
const result = await fetchPRChecks(repo.path, targetPRNumber, branch, pr?.headSha, {
- force
+ force,
+ repoId: repo.id
})
setChecks(result)
@@ -205,7 +293,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
setCommentsLoading(true)
try {
- const result = await fetchPRComments(repo.path, targetPRNumber, { force })
+ const result = await fetchPRComments(repo.path, targetPRNumber, { force, repoId: repo.id })
setComments(result)
} catch (err) {
console.warn('Failed to fetch PR comments:', err)
@@ -226,7 +314,7 @@ export default function ChecksPanel(): React.JSX.Element {
// state after the user switches worktrees, showing the wrong PR's comments.
let cancelled = false
setCommentsLoading(true)
- void fetchPRComments(repo.path, prNumber).then(
+ void fetchPRComments(repo.path, prNumber, { repoId: repo.id }).then(
(result) => {
if (!cancelled) {
setComments(result)
@@ -253,6 +341,7 @@ export default function ChecksPanel(): React.JSX.Element {
try {
const refreshedPR = await fetchPRForBranch(repo.path, branch, {
force: true,
+ repoId: repo.id,
linkedPRNumber: linkedPR
})
if (refreshedPR) {
@@ -265,7 +354,7 @@ export default function ChecksPanel(): React.JSX.Element {
refreshedPR.number,
branch,
refreshedPR.headSha,
- { force: true }
+ { force: true, repoId: repo.id }
).then(
(result) => {
setChecks(result)
@@ -366,12 +455,17 @@ export default function ChecksPanel(): React.JSX.Element {
try {
const ok = await window.api.gh.updatePRTitle({
repoPath: repo.path,
+ repoId: repo.id,
prNumber: pr.number,
title: titleDraft.trim()
})
if (ok) {
// Re-fetch PR to get updated title
- await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
+ await fetchPRForBranch(repo.path, branch, {
+ force: true,
+ repoId: repo.id,
+ linkedPRNumber: linkedPR
+ })
}
} finally {
setTitleSaving(false)
@@ -396,14 +490,18 @@ export default function ChecksPanel(): React.JSX.Element {
if (!repo || !prNumber) {
return
}
- void resolveReviewThread(repo.path, prNumber, threadId, resolve).then((ok) => {
- if (ok) {
- // Update local state to match the optimistic store update
- setComments((prev) =>
- prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c))
- )
+ void resolveReviewThread(repo.path, prNumber, threadId, resolve, { repoId: repo.id }).then(
+ (ok) => {
+ if (ok) {
+ // Update local state to match the optimistic store update
+ setComments((prev) =>
+ prev.map((c) => (c.threadId === threadId ? { ...c, isResolved: resolve } : c))
+ )
+ } else {
+ toast.error('Could not update review thread. Check the GitHub API budget.')
+ }
}
- })
+ )
},
[repo, prNumber, resolveReviewThread]
)
@@ -411,7 +509,11 @@ export default function ChecksPanel(): React.JSX.Element {
// Refresh PR (passed to PRActions)
const handleRefreshPR = useCallback(async () => {
if (repo && branch) {
- await fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
+ await fetchPRForBranch(repo.path, branch, {
+ force: true,
+ repoId: repo.id,
+ linkedPRNumber: linkedPR
+ })
}
}, [repo, branch, linkedPR, fetchPRForBranch])
@@ -422,6 +524,128 @@ export default function ChecksPanel(): React.JSX.Element {
}
}, [pr])
+ const pushBeforeCreatePullRequest = useCallback(async (): Promise => {
+ if (!activeWorktreeId || !activeWorktree?.path) {
+ return false
+ }
+ const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ try {
+ await pushBranch(
+ activeWorktreeId,
+ activeWorktree.path,
+ false,
+ connectionId,
+ activeWorktree.pushTarget
+ )
+ await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId)
+ return true
+ } catch {
+ return false
+ }
+ }, [activeWorktree, activeWorktreeId, fetchUpstreamStatus, pushBranch])
+
+ const handlePullRequestCreated = useCallback(
+ async (result: { number: number; url: string }): Promise => {
+ if (!repo || !branch) {
+ return
+ }
+ setRightSidebarOpen(true)
+ setRightSidebarTab('checks')
+ try {
+ const refreshedPR = await fetchPRForBranch(repo.path, branch, {
+ force: true,
+ linkedPRNumber: result.number
+ })
+ await fetchHostedReviewForBranch(repo.path, branch, {
+ force: true,
+ linkedGitHubPR: result.number
+ })
+ if (refreshedPR) {
+ await Promise.all([
+ fetchPRChecks(repo.path, refreshedPR.number, branch, refreshedPR.headSha, {
+ force: true
+ }).then(setChecks),
+ fetchPRComments(repo.path, refreshedPR.number, { force: true }).then(setComments)
+ ])
+ }
+ } catch {
+ // The success toast keeps the hosted URL available; Checks can be refreshed manually.
+ }
+ },
+ [
+ branch,
+ fetchHostedReviewForBranch,
+ fetchPRChecks,
+ fetchPRComments,
+ fetchPRForBranch,
+ repo,
+ setRightSidebarOpen,
+ setRightSidebarTab
+ ]
+ )
+
+ const activeReviewClassification = React.useMemo(() => {
+ if (!pr || !repo) {
+ return null
+ }
+ let host = 'github.com'
+ let owner = 'unknown'
+ let repoName = 'unknown'
+ try {
+ const parsed = new URL(pr.url)
+ host = parsed.host || host
+ const segments = parsed.pathname.split('/').filter(Boolean)
+ if (segments.length >= 2) {
+ owner = segments[0]
+ repoName = segments[1]
+ }
+ } catch {
+ // Why: malformed URLs should not block queue-state classification.
+ }
+
+ // Why: unresolved thread data is paginated and fetched separately. Until
+ // comments have loaded for this PR, do not let queue badges imply a clean review.
+ const commentsForClassification =
+ commentsFetchedAt !== undefined && !commentsLoading ? comments : undefined
+ const summary = hostedReviewSummaryFromGitHubPRInfo({
+ pr,
+ owner,
+ repo: repoName,
+ host,
+ comments: commentsForClassification,
+ checks
+ })
+ const options: HostedReviewClassificationOptions = {
+ agentAuthorLogins: [],
+ viewer: null
+ }
+ return classifyHostedReview(summary, options)
+ }, [pr, repo, comments, commentsFetchedAt, commentsLoading, checks])
+
+ const queueBadges = React.useMemo(() => {
+ if (!activeReviewClassification) {
+ return [] as string[]
+ }
+ const badges: string[] = []
+ if (activeReviewClassification.needsResponse) {
+ badges.push('Needs response')
+ }
+ if (activeReviewClassification.readyToMerge) {
+ badges.push('Ready to merge')
+ }
+ if (activeReviewClassification.requested) {
+ badges.push('Review requested')
+ }
+ if (activeReviewClassification.state === 'mine') {
+ badges.push('My PR')
+ } else if (activeReviewClassification.state === 'agent') {
+ badges.push('AI PR')
+ } else {
+ badges.push('Teammate PR')
+ }
+ return badges
+ }, [activeReviewClassification])
+
// ── Empty state ──
if (!activeWorktree) {
return (
@@ -459,34 +683,67 @@ export default function ChecksPanel(): React.JSX.Element {
? 'Cherry-pick'
: null
+ const canCreate = hostedReviewCreation?.canCreate
+ const canPushCreate = hostedReviewCreation?.blockedReason === 'needs_push'
return (
-
-
- {operationInProgress ? `${operationLabel} in progress` : 'No pull request found'}
-
-
- {operationInProgress
- ? 'PR checks will be available after the operation completes'
- : 'Push your branch and open a PR to see checks here'}
-
- {!operationInProgress && (
-
{
- if (!activeWorktreeId) {
- return
- }
- setEmptyRefreshing(true)
- void handleRefresh().finally(() => {
- setEmptyRefreshing(false)
- })
- }}
- >
- {emptyRefreshing ? 'Refreshing…' : 'Refresh'}
-
+ <>
+ {repo && (
+
)}
-
+
+
+ {operationInProgress ? `${operationLabel} in progress` : 'No pull request found'}
+
+
+ {operationInProgress
+ ? 'PR checks will be available after the operation completes'
+ : canPushCreate
+ ? 'Push your branch before creating a pull request.'
+ : 'Create a pull request to start checks and review.'}
+
+ {!operationInProgress && (
+
+ {(canCreate || canPushCreate) && (
+ {
+ setCreatePrPushFirst(canPushCreate)
+ setCreatePrDialogOpen(true)
+ }}
+ >
+ {canPushCreate ? 'Push & Create PR' : 'Create PR'}
+
+ )}
+ {
+ if (!activeWorktreeId) {
+ return
+ }
+ setEmptyRefreshing(true)
+ void handleRefresh().finally(() => {
+ setEmptyRefreshing(false)
+ })
+ }}
+ >
+ {emptyRefreshing ? 'Refreshing…' : 'Refresh'}
+
+
+ )}
+
+ >
)
}
@@ -573,6 +830,19 @@ export default function ChecksPanel(): React.JSX.Element {
)}
+ {queueBadges.length > 0 && (
+
+ {queueBadges.map((badge) => (
+
+ {badge}
+
+ ))}
+
+ )}
+
{/* Merge / Delete Worktree actions */}
{activeWorktree && repo && (
@@ -580,7 +850,10 @@ export default function ChecksPanel(): React.JSX.Element {
-
+
{/* Why: when the PR has merge conflicts and no checks have been fetched,
showing "No checks configured" is misleading — checks may exist but
simply cannot run until conflicts are resolved. Hide the empty state. */}
diff --git a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx
index 0d84e6517ce..bf85b036506 100644
--- a/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx
+++ b/src/renderer/src/components/right-sidebar/CommitArea.chevron-spinner.test.tsx
@@ -85,11 +85,19 @@ function baseProps(overrides: Partial = {}) {
commitMessage: 'feat: add commit area',
commitError: null as string | null,
isCommitting: inputs.isCommitting,
+ aiEnabled: false,
+ aiAgentConfigured: false,
+ isGenerating: false,
+ generateError: null as string | null,
+ stagedCount: inputs.stagedCount,
+ hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
+ onGenerate: vi.fn(),
+ onCancelGenerate: vi.fn(),
onPrimaryAction: vi.fn(),
onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
}
diff --git a/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx
new file mode 100644
index 00000000000..cce1ecae339
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/CommitArea.generate.test.tsx
@@ -0,0 +1,213 @@
+import { describe, expect, it, vi } from 'vitest'
+import { CommitArea } from './SourceControl'
+import { Button } from '@/components/ui/button'
+import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
+import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items'
+
+type ReactElementLike = {
+ type: unknown
+ props: Record
+}
+
+function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
+ if (node == null || typeof node === 'string' || typeof node === 'number') {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach((entry) => visit(entry, cb))
+ return
+ }
+ const element = node as ReactElementLike
+ cb(element)
+ if (element.props?.children) {
+ visit(element.props.children, cb)
+ }
+}
+
+function findNativeButtonByAriaLabel(node: unknown, ariaLabel: string): ReactElementLike {
+ let found: ReactElementLike | null = null
+ visit(node, (entry) => {
+ if (entry.type === 'button' && entry.props['aria-label'] === ariaLabel) {
+ found = entry
+ }
+ })
+ if (!found) {
+ throw new Error(`button not found: ${ariaLabel}`)
+ }
+ return found
+}
+
+function hasNativeButtonByAriaLabel(node: unknown, ariaLabel: string): boolean {
+ let found = false
+ visit(node, (entry) => {
+ if (entry.type === 'button' && entry.props['aria-label'] === ariaLabel) {
+ found = true
+ }
+ })
+ return found
+}
+
+function findTextarea(node: unknown): ReactElementLike {
+ let found: ReactElementLike | null = null
+ visit(node, (entry) => {
+ if (entry.type === 'textarea') {
+ found = entry
+ }
+ })
+ if (!found) {
+ throw new Error('textarea not found')
+ }
+ return found
+}
+
+function hasText(node: unknown, text: string): boolean {
+ let found = false
+ const walk = (value: unknown): void => {
+ if (typeof value === 'string') {
+ if (value.includes(text)) {
+ found = true
+ }
+ return
+ }
+ if (Array.isArray(value)) {
+ value.forEach(walk)
+ return
+ }
+ const element = value as ReactElementLike | null
+ if (element && typeof element === 'object' && 'props' in element) {
+ walk(element.props?.children)
+ }
+ }
+ visit(node, (entry) => {
+ walk(entry.props?.children)
+ })
+ return found
+}
+
+function buildInputs(overrides: Partial = {}): PrimaryActionInputs {
+ return {
+ stagedCount: 1,
+ hasUnstagedChanges: false,
+ hasMessage: true,
+ hasUnresolvedConflicts: false,
+ isCommitting: false,
+ isRemoteOperationActive: false,
+ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
+ ...overrides
+ }
+}
+
+function baseProps(overrides: Partial = {}) {
+ const inputs = buildInputs(overrides)
+ return {
+ commitMessage: 'feat: add commit area',
+ commitError: null as string | null,
+ isCommitting: inputs.isCommitting,
+ aiEnabled: false,
+ aiAgentConfigured: false,
+ isGenerating: false,
+ generateError: null as string | null,
+ stagedCount: inputs.stagedCount,
+ hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
+ isRemoteOperationActive: inputs.isRemoteOperationActive,
+ inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
+ primaryAction: resolvePrimaryAction(inputs),
+ dropdownItems: resolveDropdownItems(inputs),
+ onCommitMessageChange: vi.fn(),
+ onGenerate: vi.fn(),
+ onCancelGenerate: vi.fn(),
+ onPrimaryAction: vi.fn(),
+ onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
+ }
+}
+
+describe('CommitArea AI generation', () => {
+ it('does not render the AI generate affordance when the feature is disabled', () => {
+ const element = CommitArea(baseProps())
+ expect(hasNativeButtonByAriaLabel(element, 'Generate commit message with AI')).toBe(false)
+ })
+
+ it('enables AI generation only when an agent is configured, changes are staged, and the message is empty', () => {
+ const onGenerate = vi.fn()
+ const props = baseProps({ hasMessage: false })
+ const element = CommitArea({
+ ...props,
+ commitMessage: '',
+ aiEnabled: true,
+ aiAgentConfigured: true,
+ onGenerate
+ })
+
+ const button = findNativeButtonByAriaLabel(element, 'Generate commit message with AI')
+ expect(button.props.disabled).toBe(false)
+ ;(button.props.onClick as () => void)()
+ expect(onGenerate).toHaveBeenCalledTimes(1)
+ })
+
+ it('disables AI generation when the textarea already has user text', () => {
+ const element = CommitArea({
+ ...baseProps(),
+ aiEnabled: true,
+ aiAgentConfigured: true
+ })
+
+ const button = findNativeButtonByAriaLabel(element, 'Generate commit message with AI')
+ expect(button.props.disabled).toBe(true)
+ expect(button.props.title).toBe('Clear the message to regenerate.')
+ })
+
+ it('disables AI generation until the configured agent can actually run', () => {
+ const props = baseProps({ hasMessage: false })
+ const element = CommitArea({
+ ...props,
+ commitMessage: '',
+ aiEnabled: true,
+ aiAgentConfigured: false
+ })
+
+ const button = findNativeButtonByAriaLabel(element, 'Generate commit message with AI')
+ expect(button.props.disabled).toBe(true)
+ expect(button.props.title).toBe('Pick an agent in Settings → AI Commit Messages.')
+ })
+
+ it('turns the generating icon into a stop affordance', () => {
+ const onCancelGenerate = vi.fn()
+ const props = baseProps({ hasMessage: false })
+ const element = CommitArea({
+ ...props,
+ commitMessage: '',
+ aiEnabled: true,
+ aiAgentConfigured: true,
+ isGenerating: true,
+ onCancelGenerate
+ })
+
+ const button = findNativeButtonByAriaLabel(element, 'Stop generating commit message')
+ expect(button.props.title).toBe('Stop generating')
+ ;(button.props.onClick as () => void)()
+ expect(onCancelGenerate).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows generation errors separately from commit errors and links them to the textarea', () => {
+ const element = CommitArea({
+ ...baseProps(),
+ commitError: null,
+ generateError: 'No staged changes to summarize.'
+ })
+
+ expect(hasText(element, 'No staged changes to summarize.')).toBe(true)
+ expect(findTextarea(element).props['aria-describedby']).toBe('commit-area-generate-error')
+ })
+
+ it('continues to render the split commit button alongside generation controls', () => {
+ const element = CommitArea({ ...baseProps(), aiEnabled: true, aiAgentConfigured: true })
+ let primaryFound = false
+ visit(element, (entry) => {
+ if (entry.type === Button) {
+ primaryFound = true
+ }
+ })
+
+ expect(primaryFound).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx
index 1ae4ef8fc95..4cf98fceffc 100644
--- a/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx
+++ b/src/renderer/src/components/right-sidebar/CommitArea.primary-icons.test.tsx
@@ -73,11 +73,19 @@ function baseProps(overrides: Partial = {}) {
commitMessage: 'feat: add commit area',
commitError: null as string | null,
isCommitting: inputs.isCommitting,
+ aiEnabled: false,
+ aiAgentConfigured: false,
+ isGenerating: false,
+ generateError: null as string | null,
+ stagedCount: inputs.stagedCount,
+ hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
+ onGenerate: vi.fn(),
+ onCancelGenerate: vi.fn(),
onPrimaryAction: vi.fn(),
onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
}
diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx
index cf4d95bcddd..cf37df70c2c 100644
--- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx
+++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx
@@ -1,3 +1,6 @@
+/* eslint-disable max-lines -- Why: these CommitArea regression tests share
+ element-tree helpers that keep the assertions independent from a DOM test
+ harness; splitting the remaining cases would mostly duplicate setup. */
import { describe, expect, it, vi } from 'vitest'
import { Check, RefreshCw } from 'lucide-react'
import { CommitArea } from './SourceControl'
@@ -123,11 +126,19 @@ function baseProps(overrides: Partial = {}) {
commitMessage: 'feat: add commit area',
commitError: null as string | null,
isCommitting: inputs.isCommitting,
+ aiEnabled: false,
+ aiAgentConfigured: false,
+ isGenerating: false,
+ generateError: null as string | null,
+ stagedCount: inputs.stagedCount,
+ hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
+ onGenerate: vi.fn(),
+ onCancelGenerate: vi.fn(),
onPrimaryAction: vi.fn(),
onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
}
diff --git a/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx
new file mode 100644
index 00000000000..bcfefc5f50d
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/CreatePullRequestDialog.tsx
@@ -0,0 +1,357 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { cn } from '@/lib/utils'
+import { useAppStore } from '@/store'
+import {
+ getRuntimeRepoBaseRefDefault,
+ searchRuntimeRepoBaseRefs
+} from '@/runtime/runtime-repo-client'
+import type {
+ CreateHostedReviewResult,
+ HostedReviewCreationEligibility
+} from '../../../../shared/hosted-review'
+import {
+ normalizeHostedReviewBaseRef,
+ normalizeHostedReviewHeadRef
+} from '../../../../shared/hosted-review-refs'
+
+type CreatePullRequestDialogProps = {
+ open: boolean
+ repoId: string
+ repoPath: string
+ branch: string
+ eligibility: HostedReviewCreationEligibility | null
+ pushBeforeCreate: boolean
+ onOpenChange: (open: boolean) => void
+ onPushBeforeCreate: () => Promise
+ onCreated: (result: { number: number; url: string }) => Promise
+}
+
+function stripBaseRef(ref: string): string {
+ return normalizeHostedReviewBaseRef(ref)
+}
+
+function formatCreateError(result: CreateHostedReviewResult, pushed: boolean): string {
+ if (result.ok) {
+ return ''
+ }
+ if (pushed) {
+ return `Push succeeded, but PR creation failed: ${result.error.replace(/^Create PR failed:\s*/i, '')}`
+ }
+ return result.error
+}
+
+export function CreatePullRequestDialog({
+ open,
+ repoId,
+ repoPath,
+ branch,
+ eligibility,
+ pushBeforeCreate,
+ onOpenChange,
+ onPushBeforeCreate,
+ onCreated
+}: CreatePullRequestDialogProps): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const createHostedReview = useAppStore((s) => s.createHostedReview)
+ const submitInFlightRef = useRef(false)
+ const initializedFromEligibilityRef = useRef(null)
+ const [base, setBase] = useState('')
+ const [title, setTitle] = useState('')
+ const [body, setBody] = useState('')
+ const [draft, setDraft] = useState(false)
+ const [baseQuery, setBaseQuery] = useState('')
+ const [baseResults, setBaseResults] = useState([])
+ const [baseSearchError, setBaseSearchError] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!open) {
+ submitInFlightRef.current = false
+ initializedFromEligibilityRef.current = null
+ setSubmitting(false)
+ setError(null)
+ return
+ }
+ const initializationKey = `${repoId}:${branch}:${eligibility ? 'ready' : 'pending'}`
+ if (initializedFromEligibilityRef.current === initializationKey) {
+ return
+ }
+ // Why: eligibility refreshes while the dialog is open; only seed fields
+ // once per branch so late refreshes do not overwrite user edits.
+ initializedFromEligibilityRef.current = initializationKey
+ const initialBase = eligibility?.defaultBaseRef ?? ''
+ setBase(stripBaseRef(initialBase))
+ setTitle(eligibility?.title ?? '')
+ setBody(eligibility?.body ?? '')
+ setDraft(false)
+ setBaseQuery('')
+ setBaseResults([])
+ setBaseSearchError(null)
+ }, [branch, eligibility, open, repoId])
+
+ useEffect(() => {
+ if (!open || base) {
+ return
+ }
+ let stale = false
+ void getRuntimeRepoBaseRefDefault(settings, repoId)
+ .then((result) => {
+ if (!stale && result.defaultBaseRef) {
+ setBase(stripBaseRef(result.defaultBaseRef))
+ }
+ })
+ .catch(() => undefined)
+ return () => {
+ stale = true
+ }
+ }, [base, open, repoId, settings])
+
+ useEffect(() => {
+ if (!open || baseQuery.trim().length < 2) {
+ setBaseResults([])
+ setBaseSearchError(null)
+ return
+ }
+ let stale = false
+ const timer = window.setTimeout(() => {
+ void searchRuntimeRepoBaseRefs(settings, repoId, baseQuery.trim(), 20)
+ .then((results) => {
+ if (!stale) {
+ setBaseResults(results.map(stripBaseRef))
+ setBaseSearchError(null)
+ }
+ })
+ .catch(() => {
+ if (!stale) {
+ setBaseResults([])
+ setBaseSearchError('Branch discovery failed.')
+ }
+ })
+ }, 200)
+ return () => {
+ stale = true
+ window.clearTimeout(timer)
+ }
+ }, [baseQuery, open, repoId, settings])
+
+ const submitDisabled =
+ submitting ||
+ title.trim().length === 0 ||
+ base.trim().length === 0 ||
+ stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase()
+
+ const handleSubmit = useCallback(async (): Promise => {
+ if (submitDisabled || submitInFlightRef.current) {
+ return
+ }
+ submitInFlightRef.current = true
+ setSubmitting(true)
+ setError(null)
+ let pushed = false
+ try {
+ if (pushBeforeCreate) {
+ const ok = await onPushBeforeCreate()
+ if (!ok) {
+ setError('Push failed. Resolve the push error, then try again.')
+ return
+ }
+ pushed = true
+ }
+ const result = await createHostedReview(repoPath, {
+ provider: 'github',
+ base: stripBaseRef(base.trim()),
+ head: normalizeHostedReviewHeadRef(branch),
+ title: title.trim(),
+ body,
+ draft
+ })
+ if (result.ok) {
+ toast.success(`Pull request #${result.number} created`, {
+ action: {
+ label: 'Open on GitHub',
+ onClick: () => window.api.shell.openUrl(result.url)
+ }
+ })
+ await onCreated(result)
+ onOpenChange(false)
+ return
+ }
+ if (result.existingReview?.url) {
+ const number = result.existingReview.number
+ toast.success(
+ number ? `Pull request #${number} is already open` : 'Pull request is already open',
+ {
+ action: {
+ label: 'Open on GitHub',
+ onClick: () => window.api.shell.openUrl(result.existingReview!.url)
+ }
+ }
+ )
+ if (number) {
+ await onCreated({ number, url: result.existingReview.url })
+ onOpenChange(false)
+ return
+ }
+ }
+ setError(formatCreateError(result, pushed))
+ } finally {
+ submitInFlightRef.current = false
+ setSubmitting(false)
+ }
+ }, [
+ base,
+ body,
+ branch,
+ createHostedReview,
+ draft,
+ onCreated,
+ onOpenChange,
+ onPushBeforeCreate,
+ pushBeforeCreate,
+ repoPath,
+ submitDisabled,
+ title
+ ])
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean): void => {
+ // Why: closing during submit would reset the in-flight guard while the
+ // main-process create call can still complete, allowing duplicate clicks.
+ if (submitting && !nextOpen) {
+ return
+ }
+ onOpenChange(nextOpen)
+ },
+ [onOpenChange, submitting]
+ )
+
+ return (
+
+
+
+ Create Pull Request
+
+ Confirm the target branch and PR details before creating the hosted review.
+
+
+
+
+
+
Head branch
+
+ {branch}
+
+
+
+
+
+
Base branch
+
+ Search remote branches or enter a branch name.
+
+
+
+ {
+ setBaseQuery(event.target.value)
+ setBase(event.target.value)
+ }}
+ placeholder="main"
+ aria-invalid={!base.trim()}
+ className="pr-8"
+ />
+
+
+ {baseSearchError ?
{baseSearchError}
: null}
+ {baseResults.length > 0 ? (
+
+ {baseResults.map((ref) => (
+ {
+ setBase(ref)
+ setBaseQuery('')
+ setBaseResults([])
+ }}
+ >
+ {ref}
+ {stripBaseRef(base) === ref ? : null}
+
+ ))}
+
+ ) : null}
+
+
+
+ Title
+ setTitle(event.target.value)}
+ aria-invalid={!title.trim()}
+ />
+
+
+
+ Description
+
+
+
+ setDraft(event.target.checked)}
+ className="size-4 rounded border-border accent-primary"
+ />
+ Draft
+
+
+ {stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase() ? (
+
+ Choose a different base branch before creating a pull request.
+
+ ) : null}
+ {error ?
{error}
: null}
+
+
+
+ onOpenChange(false)} disabled={submitting}>
+ Cancel
+
+ void handleSubmit()} disabled={submitDisabled}>
+ {submitting ? : null}
+ {pushBeforeCreate ? 'Push & Create PR' : 'Create PR'}
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx
index 54a7e186f32..ecda45f3348 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx
@@ -272,7 +272,7 @@ function FileExplorerInner(): React.JSX.Element {
scrollRef
})
- const handleDuplicate = useFileDuplicate({ worktreePath, refreshDir })
+ const handleDuplicate = useFileDuplicate({ activeWorktreeId, worktreePath, refreshDir })
if (!worktreePath) {
return (
diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
index f0370bced7c..229ee3c070e 100644
--- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
+++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
@@ -29,6 +29,7 @@ import type { GitFileStatus } from '../../../../shared/types'
import { STATUS_LABELS } from './status-display'
import type { TreeNode } from './file-explorer-types'
import { useFileExplorerRowDrag } from './useFileExplorerRowDrag'
+import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
const ORCA_PATH_MIME = 'text/x-orca-file-path'
@@ -372,7 +373,26 @@ export function FileExplorerRow({
Open Markdown Preview
)}
- window.api.shell.openPath(node.path)}>
+ {
+ const state = useAppStore.getState()
+ const activeWorktree = Object.values(state.worktreesByRepo)
+ .flat()
+ .find((worktree) => worktree.id === activeWorktreeId)
+ const activeRepo = activeWorktree
+ ? state.repos.find((repo) => repo.id === activeWorktree.repoId)
+ : null
+ if (
+ isLocalPathOpenBlocked(state.settings, {
+ connectionId: activeRepo?.connectionId ?? null
+ })
+ ) {
+ showLocalPathOpenBlockedToast()
+ return
+ }
+ window.api.shell.openPath(node.path)
+ }}
+ >
{revealLabel}
diff --git a/src/renderer/src/components/right-sidebar/NotesPanel.tsx b/src/renderer/src/components/right-sidebar/NotesPanel.tsx
deleted file mode 100644
index 6b934f7d0bb..00000000000
--- a/src/renderer/src/components/right-sidebar/NotesPanel.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { Copy, FileText, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
-import { toast } from 'sonner'
-import { Button } from '@/components/ui/button'
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger
-} from '@/components/ui/context-menu'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle
-} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { useAppStore } from '@/store'
-import { useActiveWorktree, useRepoById } from '@/store/selectors'
-import { cn } from '@/lib/utils'
-import { getProjectNotesEntityId, openProjectNotesTab } from '@/lib/open-project-notes-tab'
-import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
-import type { NoteSummary } from '../../../../shared/notes-types'
-
-export default function NotesPanel(): React.JSX.Element {
- const activeWorktree = useActiveWorktree()
- const repo = useRepoById(activeWorktree?.repoId ?? null)
- const projectId = repo?.id ?? activeWorktree?.repoId ?? null
- const worktreeId = activeWorktree?.id ?? null
-
- const [notes, setNotes] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [renamingNoteId, setRenamingNoteId] = useState(null)
- const [renameValue, setRenameValue] = useState('')
- const [deleteTarget, setDeleteTarget] = useState(null)
- const [deleting, setDeleting] = useState(false)
- const renameCommittedRef = useRef(false)
- const deleteConfirmButtonRef = useRef(null)
-
- const refresh = useCallback(async (): Promise => {
- if (!projectId || !worktreeId) {
- setNotes([])
- return
- }
- setLoading(true)
- setError(null)
- try {
- const result = await window.api.notes.list({ projectId, worktreeId, limit: 100 })
- setNotes(result.notes)
- } catch (err) {
- setError(err instanceof Error ? err.message : String(err))
- } finally {
- setLoading(false)
- }
- }, [projectId, worktreeId])
-
- useEffect(() => {
- void refresh()
- }, [refresh])
-
- useEffect(() => {
- const listener = (): void => {
- void refresh()
- }
- window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
- return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
- }, [refresh])
-
- const openNotes = useCallback(() => {
- if (!worktreeId) {
- return
- }
- void openProjectNotesTab(worktreeId)
- }, [worktreeId])
-
- const selectNote = useCallback(
- async (noteId: string): Promise => {
- if (!projectId || !worktreeId) {
- return
- }
- await openProjectNotesTab(worktreeId, noteId)
- await refresh()
- },
- [projectId, refresh, worktreeId]
- )
-
- const startRename = useCallback((note: NoteSummary) => {
- renameCommittedRef.current = false
- setRenamingNoteId(note.id)
- setRenameValue(note.title)
- }, [])
-
- const commitRename = useCallback(
- async (note: NoteSummary): Promise => {
- if (renameCommittedRef.current) {
- return
- }
- renameCommittedRef.current = true
- const title = renameValue.trim()
- setRenamingNoteId(null)
- if (!projectId || !worktreeId || !title || title === note.title) {
- return
- }
- try {
- const result = await window.api.notes.rename({
- projectId,
- worktreeId,
- note: note.id,
- title
- })
- const entityId = getProjectNotesEntityId(projectId, note.id)
- const state = useAppStore.getState()
- for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
- for (const tab of tabs) {
- if (tab.contentType === 'notes' && tab.entityId === entityId) {
- state.setTabLabel(tab.id, result.note.title)
- }
- }
- }
- await refresh()
- } catch (err) {
- toast.error(err instanceof Error ? err.message : `Failed to rename '${note.title}'.`)
- }
- },
- [projectId, refresh, renameValue, worktreeId]
- )
-
- const cancelRename = useCallback(() => {
- renameCommittedRef.current = true
- setRenamingNoteId(null)
- }, [])
-
- const confirmDeleteNote = useCallback(async (): Promise => {
- if (!projectId || !worktreeId || !deleteTarget || deleting) {
- return
- }
- setDeleting(true)
- try {
- await window.api.notes.delete({ projectId, worktreeId, note: deleteTarget.id })
- const entityId = getProjectNotesEntityId(projectId, deleteTarget.id)
- const state = useAppStore.getState()
- const tabIdsToClose: string[] = []
- for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
- for (const tab of tabs) {
- if (tab.contentType === 'notes' && tab.entityId === entityId) {
- tabIdsToClose.push(tab.id)
- }
- }
- }
- for (const tabId of tabIdsToClose) {
- useAppStore.getState().closeUnifiedTab(tabId)
- }
- await refresh()
- toast.success(`'${deleteTarget.title}' deleted`)
- setDeleteTarget(null)
- } catch (err) {
- toast.error(err instanceof Error ? err.message : `Failed to delete '${deleteTarget.title}'.`)
- } finally {
- setDeleting(false)
- }
- }, [deleteTarget, deleting, projectId, refresh, worktreeId])
-
- const copyNotePath = useCallback(async (note: NoteSummary): Promise => {
- await navigator.clipboard.writeText(note.relativePath)
- toast.success('Note path copied')
- }, [])
-
- if (!projectId || !worktreeId) {
- return (
-
- Open a project worktree to use notes.
-
- )
- }
-
- return (
-
-
-
-
Project Notes
-
- Shared across all workspaces for {repo?.displayName ?? 'this repo'}
-
-
-
void refresh()}
- >
-
-
-
-
-
-
-
- {error ? (
-
{error}
- ) : null}
-
-
- {notes.length === 0 ? (
-
-
No project notes yet
-
- Create one to keep repo context available across every workspace.
-
-
- ) : (
- notes.map((note) => (
-
-
- {
- if (renamingNoteId !== note.id) {
- void selectNote(note.id)
- }
- }}
- onKeyDown={(event) => {
- if (event.key !== 'Enter' && event.key !== ' ') {
- return
- }
- event.preventDefault()
- if (renamingNoteId !== note.id) {
- void selectNote(note.id)
- }
- }}
- >
-
-
- {renamingNoteId === note.id ? (
- event.stopPropagation()}
- onChange={(event) => setRenameValue(event.target.value)}
- onFocus={(event) => event.currentTarget.select()}
- onBlur={() => void commitRename(note)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- event.preventDefault()
- void commitRename(note)
- } else if (event.key === 'Escape') {
- event.preventDefault()
- cancelRename()
- }
- }}
- />
- ) : (
-
- {note.title}
-
- )}
-
- {note.preview || note.relativePath}
-
-
-
-
-
- void selectNote(note.id)}>
-
- Open
-
- startRename(note)}>
-
- Rename
-
- void copyNotePath(note)}>
-
- Copy Path
-
-
- setDeleteTarget(note)}>
-
- Delete
-
-
-
- ))
- )}
-
-
-
{
- if (open) {
- return
- }
- setDeleteTarget(null)
- setDeleting(false)
- }}
- >
- {
- event.preventDefault()
- deleteConfirmButtonRef.current?.focus()
- }}
- >
-
- Delete Project Note
-
- Delete{' '}
- {deleteTarget?.title} ?
- This cannot be undone.
-
-
- {deleteTarget ? (
-
-
{deleteTarget.title}
-
- {deleteTarget.relativePath}
-
-
- ) : null}
-
- {
- setDeleteTarget(null)
- setDeleting(false)
- }}
- >
- Cancel
-
- void confirmDeleteNote()}
- >
-
- {deleting ? 'Deleting...' : 'Delete'}
-
-
-
-
-
- )
-}
diff --git a/src/renderer/src/components/right-sidebar/PRActions.tsx b/src/renderer/src/components/right-sidebar/PRActions.tsx
index bcb51d55324..17a7fdac4b8 100644
--- a/src/renderer/src/components/right-sidebar/PRActions.tsx
+++ b/src/renderer/src/components/right-sidebar/PRActions.tsx
@@ -41,6 +41,7 @@ export default function PRActions({
try {
const result = await window.api.gh.mergePR({
repoPath: repo.path,
+ repoId: repo.id,
prNumber: pr.number,
method
})
@@ -55,7 +56,7 @@ export default function PRActions({
setMerging(false)
}
},
- [repo.path, pr.number, onRefreshPR]
+ [repo.id, repo.path, pr.number, onRefreshPR]
)
useEffect(() => {
diff --git a/src/renderer/src/components/right-sidebar/Search.tsx b/src/renderer/src/components/right-sidebar/Search.tsx
index d85c5228a8c..c5c535317d0 100644
--- a/src/renderer/src/components/right-sidebar/Search.tsx
+++ b/src/renderer/src/components/right-sidebar/Search.tsx
@@ -3,6 +3,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
import { useActiveWorktree } from '@/store/selectors'
import { getConnectionId } from '@/lib/connection-context'
+import { searchRuntimeFiles } from '@/runtime/runtime-file-client'
import type { SearchFileResult, SearchMatch } from '../../../../shared/types'
import { buildSearchRows } from './search-rows'
import { cancelRevealFrame, openMatchResult } from './search-match-open'
@@ -173,20 +174,27 @@ export default function Search(): React.JSX.Element {
try {
const state = useAppStore.getState()
const connectionId = getConnectionId(activeWorktreeId!) ?? undefined
- const results = await window.api.fs.search({
- query: query.trim(),
- rootPath: worktreePath,
- connectionId,
- caseSensitive:
- state.fileSearchStateByWorktree[activeWorktreeId!]?.caseSensitive ?? false,
- wholeWord: state.fileSearchStateByWorktree[activeWorktreeId!]?.wholeWord ?? false,
- useRegex: state.fileSearchStateByWorktree[activeWorktreeId!]?.useRegex ?? false,
- includePattern:
- state.fileSearchStateByWorktree[activeWorktreeId!]?.includePattern || undefined,
- excludePattern:
- state.fileSearchStateByWorktree[activeWorktreeId!]?.excludePattern || undefined,
- maxResults: SEARCH_MAX_RESULTS
- })
+ const results = await searchRuntimeFiles(
+ {
+ settings: state.settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ {
+ query: query.trim(),
+ rootPath: worktreePath,
+ caseSensitive:
+ state.fileSearchStateByWorktree[activeWorktreeId!]?.caseSensitive ?? false,
+ wholeWord: state.fileSearchStateByWorktree[activeWorktreeId!]?.wholeWord ?? false,
+ useRegex: state.fileSearchStateByWorktree[activeWorktreeId!]?.useRegex ?? false,
+ includePattern:
+ state.fileSearchStateByWorktree[activeWorktreeId!]?.includePattern || undefined,
+ excludePattern:
+ state.fileSearchStateByWorktree[activeWorktreeId!]?.excludePattern || undefined,
+ maxResults: SEARCH_MAX_RESULTS
+ }
+ )
if (latestSearchIdRef.current === searchId) {
updateActiveSearchState({ results })
}
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 0af146ae5dc..1e05fb0ab0b 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -9,6 +9,8 @@ import {
Plus,
RefreshCw,
Settings2,
+ Sparkles,
+ Square,
Undo2,
FileEdit,
FileMinus,
@@ -30,6 +32,7 @@ import {
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
+import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
@@ -85,6 +88,7 @@ import {
} from '@/components/ui/dialog'
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
import { formatDiffComment, formatDiffComments } from '@/lib/diff-comments-format'
+import { getDiffCommentLineLabel, getDiffCommentSource } from '@/lib/diff-comment-compat'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
@@ -92,7 +96,18 @@ import {
requestEditorSaveQuiesce
} from '@/components/editor/editor-autosave'
import { getConnectionId } from '@/lib/connection-context'
-import { PullRequestIcon } from './checks-helpers'
+import {
+ bulkStageRuntimeGitPaths,
+ bulkUnstageRuntimeGitPaths,
+ commitRuntimeGit,
+ discardRuntimeGitPath,
+ getRuntimeGitBranchCompare,
+ stageRuntimeGitPath,
+ unstageRuntimeGitPath
+} from '@/runtime/runtime-git-client'
+import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
+import { PullRequestIcon } from './checks-panel-content'
+import { CreatePullRequestDialog } from './CreatePullRequestDialog'
import type {
DiffComment,
GitBranchChangeEntry,
@@ -102,7 +117,10 @@ import type {
GitStatusEntry,
GitUpstreamStatus
} from '../../../../shared/types'
-import type { HostedReviewInfo } from '../../../../shared/hosted-review'
+import type {
+ HostedReviewCreationEligibility,
+ HostedReviewInfo
+} from '../../../../shared/hosted-review'
import { STATUS_COLORS, STATUS_LABELS } from './status-display'
type SourceControlScope = 'all' | 'uncommitted'
@@ -136,7 +154,8 @@ const PRIMARY_ICONS: Partial<
stage: Plus,
push: ArrowUp,
sync: ArrowDownUp,
- publish: CloudUpload
+ publish: CloudUpload,
+ create_pr: GitPullRequestArrow
}
// Why: unstaged ("Changes") is listed first so that conflict files — which
@@ -233,8 +252,13 @@ function SourceControlInner(): React.JSX.Element {
const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree)
const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive)
const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind)
+ const settings = useAppStore((s) => s.settings)
const hostedReviewCache = useAppStore((s) => s.hostedReviewCache)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
+ const getHostedReviewCreationEligibility = useAppStore(
+ (s) => s.getHostedReviewCreationEligibility
+ )
+ const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const updateRepo = useAppStore((s) => s.updateRepo)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity)
@@ -251,6 +275,8 @@ function SourceControlInner(): React.JSX.Element {
const openDiff = useAppStore((s) => s.openDiff)
const openFile = useAppStore((s) => s.openFile)
const setEditorViewMode = useAppStore((s) => s.setEditorViewMode)
+ const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode)
+ const setPendingEditorReveal = useAppStore((s) => s.setPendingEditorReveal)
const openConflictFile = useAppStore((s) => s.openConflictFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
@@ -258,6 +284,8 @@ function SourceControlInner(): React.JSX.Element {
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId)
+ const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
+ const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
// Why: pass activeWorktreeId directly (even when null/undefined) so the
// slice's getDiffComments returns its stable EMPTY_COMMENTS sentinel. An
// inline `[]` fallback would allocate a new array each store update, break
@@ -326,6 +354,20 @@ function SourceControlInner(): React.JSX.Element {
{}
)
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
+ // Why: parallel state to commit. Same per-worktree shape so navigating between
+ // worktrees mid-generation never silently cancels the in-flight request.
+ const generateInFlightRef = useRef>({})
+ const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState<
+ Record
+ >({})
+ const [generateErrors, setGenerateErrors] = useState>({})
+ const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false
+ const generateError = generateErrors[activeWorktreeId ?? ''] ?? null
+ const [hostedReviewCreation, setHostedReviewCreation] =
+ useState(null)
+ const [createPrDialogOpen, setCreatePrDialogOpen] = useState(false)
+ const [createPrPushFirst, setCreatePrPushFirst] = useState(false)
+ const commitMessageAi = useAppStore((s) => s.settings?.commitMessageAi)
const filterInputRef = useRef(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
@@ -366,6 +408,7 @@ function SourceControlInner(): React.JSX.Element {
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await refreshGitStatusForWorktree({
+ settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId,
@@ -407,8 +450,7 @@ function SourceControlInner(): React.JSX.Element {
setDefaultBaseRef(null)
let stale = false
- void window.api.repos
- .getBaseRefDefault({ repoId: activeRepo.id })
+ void getRuntimeRepoBaseRefDefault(useAppStore.getState().settings, activeRepo.id)
.then((result) => {
if (!stale) {
// Why: IPC now returns a `{ defaultBaseRef, remoteCount }` envelope;
@@ -436,27 +478,35 @@ function SourceControlInner(): React.JSX.Element {
const hasUncommittedEntries = entries.length > 0
const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD'
- const hostedReviewCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null
+ const hostedReviewCacheKey =
+ activeRepo && branchName
+ ? getHostedReviewCacheKey(activeRepo.path, branchName, settings, activeRepo.id)
+ : null
+ const hostedReviewEntry = hostedReviewCacheKey
+ ? hostedReviewCache[hostedReviewCacheKey]
+ : undefined
const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey
- ? (hostedReviewCache[hostedReviewCacheKey]?.data ?? null)
+ ? (hostedReviewEntry?.data ?? null)
: null
const linkedGitHubPR = activeWorktree?.linkedPR ?? null
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
+ const isHostedReviewStateLoading =
+ (linkedGitHubPR !== null || linkedGitLabMR !== null) && hostedReviewEntry === undefined
useEffect(() => {
if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') {
return
}
- if (activeRepo.connectionId) {
- return
- }
-
// Why: the Source Control panel renders branch review status directly.
// When a terminal checkout moves this worktree onto a new branch, we need
// to fetch that branch's PR/MR immediately instead of waiting for the user
// to reselect the worktree. The linked ids handle create-from-review
// worktrees whose local branch differs from the remote head branch.
- void fetchHostedReviewForBranch(activeRepo.path, branchName, { linkedGitHubPR, linkedGitLabMR })
+ void fetchHostedReviewForBranch(activeRepo.path, branchName, {
+ repoId: activeRepo.id,
+ linkedGitHubPR,
+ linkedGitLabMR
+ })
}, [
activeRepo,
branchName,
@@ -467,6 +517,52 @@ function SourceControlInner(): React.JSX.Element {
linkedGitLabMR
])
+ useEffect(() => {
+ if (!isBranchVisible || !activeRepo || isFolder || !branchName) {
+ setHostedReviewCreation(null)
+ return
+ }
+ let stale = false
+ void getHostedReviewCreationEligibility({
+ repoPath: activeRepo.path,
+ branch: branchName,
+ base: effectiveBaseRef ?? null,
+ hasUncommittedChanges: hasUncommittedEntries,
+ hasUpstream: remoteStatus?.hasUpstream,
+ ahead: remoteStatus?.ahead,
+ behind: remoteStatus?.behind,
+ linkedGitHubPR,
+ linkedGitLabMR
+ })
+ .then((result) => {
+ if (!stale) {
+ setHostedReviewCreation(result)
+ }
+ })
+ .catch((error) => {
+ console.warn('[SourceControl] hosted review creation eligibility failed', error)
+ if (!stale) {
+ setHostedReviewCreation(null)
+ }
+ })
+ return () => {
+ stale = true
+ }
+ }, [
+ activeRepo,
+ branchName,
+ effectiveBaseRef,
+ getHostedReviewCreationEligibility,
+ hasUncommittedEntries,
+ isBranchVisible,
+ isFolder,
+ linkedGitHubPR,
+ linkedGitLabMR,
+ remoteStatus?.ahead,
+ remoteStatus?.behind,
+ remoteStatus?.hasUpstream
+ ])
+
const grouped = useMemo(() => {
const groups = {
staged: [] as GitStatusEntry[],
@@ -559,12 +655,19 @@ function SourceControlInner(): React.JSX.Element {
setCommitDrafts((prev) => pruneRecord(prev))
setCommitErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
+ setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
+ setGenerateErrors((prev) => pruneRecord(prev))
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete commitInFlightRef.current[key]
}
}
+ for (const key of Object.keys(generateInFlightRef.current)) {
+ if (!worktreeMap.has(key)) {
+ delete generateInFlightRef.current[key]
+ }
+ }
}, [worktreeMap])
// Why: the sidebar no longer uses key={activeWorktreeId} to force a full
@@ -585,6 +688,8 @@ function SourceControlInner(): React.JSX.Element {
// repos and back to re-trigger the resolver.
setFilterQuery('')
setIsExecutingBulk(false)
+ setCreatePrDialogOpen(false)
+ setCreatePrPushFirst(false)
// Why: no reset for commit-in-flight state — it now lives in a per-worktree
// map, so it cannot leak across worktrees. Resetting here would actually
// clear in-flight state for the *incoming* worktree if the user is coming
@@ -612,11 +717,15 @@ function SourceControlInner(): React.JSX.Element {
setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
- const commitResult = await window.api.git.commit({
- worktreePath,
- message,
- connectionId
- })
+ const commitResult = await commitRuntimeGit(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ message
+ )
if (!commitResult.success) {
setCommitErrors((prev) => ({
...prev,
@@ -686,6 +795,92 @@ function SourceControlInner(): React.JSX.Element {
worktreePath
])
+ const handleGenerate = useCallback(async (): Promise => {
+ if (!activeWorktreeId || !worktreePath) {
+ return
+ }
+ if (generateInFlightRef.current[activeWorktreeId]) {
+ return
+ }
+ if (!commitMessageAi?.enabled || !commitMessageAi.agentId) {
+ return
+ }
+
+ if (commitMessageAi.agentId === 'custom') {
+ const command = commitMessageAi.customAgentCommand?.trim() ?? ''
+ if (!command) {
+ setGenerateErrors((prev) => ({
+ ...prev,
+ [activeWorktreeId]:
+ 'Custom command is empty. Add one in Settings → Git → AI Commit Messages.'
+ }))
+ return
+ }
+ }
+
+ generateInFlightRef.current[activeWorktreeId] = true
+ const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
+ setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
+ try {
+ const result = (await window.api.git.generateCommitMessage({
+ worktreePath,
+ connectionId
+ })) as
+ | { success: true; message: string; agentLabel?: string }
+ | { success: false; error: string; canceled?: boolean }
+
+ if (!result.success) {
+ // Why: cancellation is a deliberate user action, not a failure to
+ // surface. Clear any prior error and stay quiet.
+ if (result.canceled) {
+ setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
+ return
+ }
+ setGenerateErrors((prev) => ({
+ ...prev,
+ [activeWorktreeId]: result.error
+ }))
+ return
+ }
+
+ // Why: race protection — the user may have started typing into the
+ // textarea while the agent was running. In that case we silently drop
+ // the generated message rather than overwrite their in-progress edits.
+ setCommitDrafts((prev) => {
+ const current = prev[activeWorktreeId]
+ if (current && current.length > 0) {
+ return prev
+ }
+ return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
+ })
+ setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
+ } catch (error) {
+ setGenerateErrors((prev) => ({
+ ...prev,
+ [activeWorktreeId]:
+ error instanceof Error ? error.message : 'Failed to generate commit message'
+ }))
+ } finally {
+ setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
+ generateInFlightRef.current[activeWorktreeId] = false
+ }
+ }, [activeWorktreeId, commitMessageAi, worktreePath])
+
+ const handleCancelGenerate = useCallback((): void => {
+ if (!activeWorktreeId || !worktreePath) {
+ return
+ }
+ if (!generateInFlightRef.current[activeWorktreeId]) {
+ return
+ }
+ const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ // Why: fire-and-forget — the in-flight generateCommitMessage promise
+ // resolves with `{canceled: true}` once the kill propagates, which is
+ // where the spinner is cleared. Awaiting here would just delay UI feedback.
+ void window.api.git.cancelGenerateCommitMessage({ worktreePath, connectionId })
+ }, [activeWorktreeId, worktreePath])
+
// Why: a single dispatcher for every remote-only action the split button or
// chevron dropdown can trigger. Keeps the error-swallow pattern in one
// place — store slices already surface actionable toasts, so additional
@@ -760,6 +955,76 @@ function SourceControlInner(): React.JSX.Element {
[handleCommit, runRemoteAction]
)
+ const openCreatePullRequestDialog = useCallback((pushFirst: boolean): void => {
+ setCreatePrPushFirst(pushFirst)
+ setCreatePrDialogOpen(true)
+ }, [])
+
+ const pushBeforeCreatePullRequest = useCallback(async (): Promise => {
+ if (!activeWorktreeId || !worktreePath) {
+ return false
+ }
+ const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ try {
+ await pushBranch(
+ activeWorktreeId,
+ worktreePath,
+ false,
+ connectionId,
+ activeWorktree?.pushTarget
+ )
+ await refreshActiveGitStatusAfterMutation()
+ return true
+ } catch {
+ return false
+ }
+ }, [
+ activeWorktree?.pushTarget,
+ activeWorktreeId,
+ pushBranch,
+ refreshActiveGitStatusAfterMutation,
+ worktreePath
+ ])
+
+ const handlePullRequestCreated = useCallback(
+ async (result: { number: number; url: string }): Promise => {
+ if (!activeRepo || !branchName) {
+ return
+ }
+ setRightSidebarOpen(true)
+ setRightSidebarTab('checks')
+ try {
+ await Promise.all([
+ fetchHostedReviewForBranch(activeRepo.path, branchName, {
+ force: true,
+ linkedGitHubPR: result.number,
+ linkedGitLabMR
+ }),
+ fetchPRForBranch(activeRepo.path, branchName, {
+ force: true,
+ linkedPRNumber: result.number
+ })
+ ])
+ } catch {
+ toast.warning('Pull request created, but Orca could not refresh it yet.', {
+ action: {
+ label: 'Open on GitHub',
+ onClick: () => window.api.shell.openUrl(result.url)
+ }
+ })
+ }
+ },
+ [
+ activeRepo,
+ branchName,
+ fetchHostedReviewForBranch,
+ fetchPRForBranch,
+ linkedGitLabMR,
+ setRightSidebarOpen,
+ setRightSidebarTab
+ ]
+ )
+
const hasUnstagedChanges = grouped.unstaged.length > 0 || grouped.untracked.length > 0
const primaryAction: PrimaryAction = useMemo(
@@ -772,7 +1037,10 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
upstreamStatus: remoteStatus,
- inFlightRemoteOpKind
+ prState: hostedReview?.state ?? null,
+ isPRStateLoading: isHostedReviewStateLoading,
+ inFlightRemoteOpKind,
+ hostedReviewCreation
}),
[
commitMessage,
@@ -781,6 +1049,9 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
inFlightRemoteOpKind,
+ hostedReviewCreation,
+ isHostedReviewStateLoading,
+ hostedReview?.state,
remoteStatus,
unresolvedConflicts.length
]
@@ -796,7 +1067,10 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
upstreamStatus: remoteStatus,
- inFlightRemoteOpKind
+ prState: hostedReview?.state ?? null,
+ isPRStateLoading: isHostedReviewStateLoading,
+ inFlightRemoteOpKind,
+ hostedReviewCreation
}),
[
commitMessage,
@@ -805,6 +1079,9 @@ function SourceControlInner(): React.JSX.Element {
isCommitting,
isRemoteOperationActive,
inFlightRemoteOpKind,
+ hostedReviewCreation,
+ isHostedReviewStateLoading,
+ hostedReview?.state,
remoteStatus,
unresolvedConflicts.length
]
@@ -826,6 +1103,12 @@ function SourceControlInner(): React.JSX.Element {
case 'commit_sync':
void runCompoundCommitAction('sync')
return
+ case 'create_pr':
+ openCreatePullRequestDialog(false)
+ return
+ case 'push_create_pr':
+ openCreatePullRequestDialog(true)
+ return
case 'push':
case 'pull':
case 'sync':
@@ -842,7 +1125,7 @@ function SourceControlInner(): React.JSX.Element {
}
}
},
- [handleCommit, runCompoundCommitAction, runRemoteAction]
+ [handleCommit, openCreatePullRequestDialog, runCompoundCommitAction, runRemoteAction]
)
const handleOpenDiff = useCallback(
@@ -948,7 +1231,15 @@ function SourceControlInner(): React.JSX.Element {
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.bulkStage({ worktreePath, filePaths: bulkStagePaths, connectionId })
+ await bulkStageRuntimeGitPaths(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ bulkStagePaths
+ )
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
@@ -969,7 +1260,15 @@ function SourceControlInner(): React.JSX.Element {
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.bulkUnstage({ worktreePath, filePaths: bulkUnstagePaths, connectionId })
+ await bulkUnstageRuntimeGitPaths(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ bulkUnstagePaths
+ )
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
@@ -999,7 +1298,15 @@ function SourceControlInner(): React.JSX.Element {
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.bulkStage({ worktreePath, filePaths: paths, connectionId })
+ await bulkStageRuntimeGitPaths(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ paths
+ )
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
@@ -1034,7 +1341,15 @@ function SourceControlInner(): React.JSX.Element {
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.bulkStage({ worktreePath, filePaths, connectionId })
+ await bulkStageRuntimeGitPaths(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ filePaths
+ )
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
@@ -1066,6 +1381,7 @@ function SourceControlInner(): React.JSX.Element {
case 'pull':
case 'sync':
case 'publish':
+ case 'create_pr':
handleActionInvoke(primaryAction.kind)
return
default: {
@@ -1086,7 +1402,15 @@ function SourceControlInner(): React.JSX.Element {
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.bulkUnstage({ worktreePath, filePaths: paths, connectionId })
+ await bulkUnstageRuntimeGitPaths(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ paths
+ )
await refreshActiveGitStatusAfterMutation()
clearSelection()
} finally {
@@ -1132,11 +1456,15 @@ function SourceControlInner(): React.JSX.Element {
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- const result = await window.api.git.branchCompare({
- worktreePath,
- baseRef: effectiveBaseRef,
- connectionId
- })
+ const result = await getRuntimeGitBranchCompare(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ effectiveBaseRef
+ )
setGitBranchCompareResult(activeWorktreeId, requestKey, result)
} catch (error) {
setGitBranchCompareResult(activeWorktreeId, requestKey, {
@@ -1245,13 +1573,41 @@ function SourceControlInner(): React.JSX.Element {
// first, so the editor-tab fallback then leaves the global null and a
// future DiffViewer mount can't accidentally consume a stale id.
const handleOpenComment = useCallback(
- (filePath: string, commentId?: string) => {
+ (comment: DiffComment) => {
if (!activeWorktreeId || !worktreePath) {
return
}
+ const filePath = comment.filePath
+ const commentId = comment.id
// Defensively clear any dangling prior scroll request before routing
// this click; only the diff branches below will re-stamp it.
setScrollToDiffCommentId(null)
+ if (getDiffCommentSource(comment) === 'markdown') {
+ const absPath = joinPath(worktreePath, filePath)
+ const language = detectLanguage(filePath)
+ setEditorViewMode(absPath, 'edit')
+ setMarkdownViewMode(absPath, 'source')
+ openFile({
+ filePath: absPath,
+ relativePath: filePath,
+ worktreeId: activeWorktreeId,
+ language,
+ mode: 'edit'
+ })
+ setPendingEditorReveal(null)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setPendingEditorReveal({
+ filePath: absPath,
+ line: comment.lineNumber,
+ column: 1,
+ matchLength: 0
+ })
+ setScrollToDiffCommentId(commentId)
+ })
+ })
+ return
+ }
const matches = entries.filter((e) => e.path === filePath)
const uncommitted =
matches.find((e) => e.area === 'unstaged') ??
@@ -1303,6 +1659,8 @@ function SourceControlInner(): React.JSX.Element {
openFile,
setEditorViewMode,
setScrollToDiffCommentId,
+ setMarkdownViewMode,
+ setPendingEditorReveal,
worktreePath
]
)
@@ -1314,7 +1672,15 @@ function SourceControlInner(): React.JSX.Element {
}
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.stage({ worktreePath, filePath, connectionId })
+ await stageRuntimeGitPath(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ filePath
+ )
await refreshActiveGitStatusAfterMutation()
} catch {
// git operation failed silently
@@ -1330,7 +1696,15 @@ function SourceControlInner(): React.JSX.Element {
}
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.unstage({ worktreePath, filePath, connectionId })
+ await unstageRuntimeGitPath(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ filePath
+ )
await refreshActiveGitStatusAfterMutation()
} catch {
// git operation failed silently
@@ -1357,7 +1731,15 @@ function SourceControlInner(): React.JSX.Element {
relativePath: filePath
})
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.git.discard({ worktreePath, filePath, connectionId })
+ await discardRuntimeGitPath(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ filePath
+ )
notifyEditorExternalFileChange({
worktreeId: activeWorktreeId,
worktreePath,
@@ -1438,7 +1820,15 @@ function SourceControlInner(): React.JSX.Element {
const errors: unknown[] = []
const result = await runDiscardAllForArea(area, paths, {
bulkUnstage: (filePaths) =>
- window.api.git.bulkUnstage({ worktreePath, filePaths, connectionId }),
+ bulkUnstageRuntimeGitPaths(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ filePaths
+ ),
discardMany,
discardOne: discardSingle,
onError: (error) => {
@@ -1548,6 +1938,17 @@ function SourceControlInner(): React.JSX.Element {
return (
<>
+
{(['all', 'uncommitted'] as const).map((value) => (
@@ -1648,7 +2049,7 @@ function SourceControlInner(): React.JSX.Element {
groupId={activeGroupId ?? activeWorktreeId}
onFocusTerminal={focusTerminalTabSurface}
prompt={diffCommentsPrompt}
- launchSource="diff_notes_send"
+ launchSource="notes_send"
/>
@@ -1680,7 +2081,7 @@ function SourceControlInner(): React.JSX.Element {
void deleteDiffComment(activeWorktreeId, id)}
- onOpen={(filePath, commentId) => handleOpenComment(filePath, commentId)}
+ onOpen={(comment) => handleOpenComment(comment)}
/>
)}
@@ -1780,6 +2181,20 @@ function SourceControlInner(): React.JSX.Element {
commitMessage={commitMessage}
commitError={commitError}
isCommitting={isCommitting}
+ aiEnabled={commitMessageAi?.enabled === true}
+ aiAgentConfigured={
+ commitMessageAi?.enabled === true &&
+ commitMessageAi.agentId !== null &&
+ // Why: 'custom' is configured only once the user types a command.
+ // Without this guard, Generate would spawn an empty command and
+ // fail with a confusing error.
+ (commitMessageAi.agentId !== 'custom' ||
+ (commitMessageAi.customAgentCommand ?? '').trim().length > 0)
+ }
+ isGenerating={isGenerating}
+ generateError={generateError}
+ stagedCount={grouped.staged.length}
+ hasUnresolvedConflicts={unresolvedConflicts.length > 0}
isRemoteOperationActive={isRemoteOperationActive}
inFlightRemoteOpKind={inFlightRemoteOpKind}
primaryAction={primaryAction}
@@ -1792,6 +2207,10 @@ function SourceControlInner(): React.JSX.Element {
writeCommitDraftForWorktree(prev, activeWorktreeId, value)
)
}}
+ onGenerate={() => {
+ void handleGenerate()
+ }}
+ onCancelGenerate={handleCancelGenerate}
onPrimaryAction={handlePrimaryClick}
onDropdownAction={handleActionInvoke}
/>
@@ -2091,11 +2510,19 @@ type CommitAreaProps = {
commitMessage: string
commitError: string | null
isCommitting: boolean
+ aiEnabled: boolean
+ aiAgentConfigured: boolean
+ isGenerating: boolean
+ generateError: string | null
+ stagedCount: number
+ hasUnresolvedConflicts: boolean
isRemoteOperationActive: boolean
inFlightRemoteOpKind: RemoteOpKind | null
primaryAction: PrimaryAction
dropdownItems: DropdownEntry[]
onCommitMessageChange: (message: string) => void
+ onGenerate: () => void
+ onCancelGenerate: () => void
onPrimaryAction: () => void
onDropdownAction: (kind: DropdownActionKind) => void
}
@@ -2104,11 +2531,19 @@ export function CommitArea({
commitMessage,
commitError,
isCommitting,
+ aiEnabled,
+ aiAgentConfigured,
+ isGenerating,
+ generateError,
+ stagedCount,
+ hasUnresolvedConflicts,
isRemoteOperationActive,
inFlightRemoteOpKind,
primaryAction,
dropdownItems,
onCommitMessageChange,
+ onGenerate,
+ onCancelGenerate,
onPrimaryAction,
onDropdownAction
}: CommitAreaProps): React.JSX.Element {
@@ -2146,23 +2581,90 @@ export function CommitArea({
// title attribute carry the meaning for assistive tech.
const PrimaryIcon = PRIMARY_ICONS[primaryAction.kind]
+ const hasMessage = commitMessage.trim().length > 0
+
+ // Why: only render the Generate button when the user has opted into the
+ // feature. Mounting a perma-disabled button would leak space and add noise
+ // for users who never plan to use AI commit messages.
+ const showGenerate = aiEnabled
+ let generateDisabledReason: string | undefined
+ if (isGenerating) {
+ generateDisabledReason = 'Generating commit message…'
+ } else if (isCommitting) {
+ generateDisabledReason = 'Commit in progress…'
+ } else if (!aiAgentConfigured) {
+ generateDisabledReason = 'Pick an agent in Settings → AI Commit Messages.'
+ } else if (stagedCount === 0) {
+ generateDisabledReason = 'Stage at least one file to generate a message.'
+ } else if (hasMessage) {
+ generateDisabledReason = 'Clear the message to regenerate.'
+ }
+ const isGenerateDisabled =
+ !aiAgentConfigured ||
+ isGenerating ||
+ isCommitting ||
+ stagedCount === 0 ||
+ hasMessage ||
+ hasUnresolvedConflicts
+
return (
)
@@ -309,14 +304,16 @@ function RightSidebarInner(): React.JSX.Element {
{activityBarPosition === 'top' ? (
/* ── Top activity bar: horizontal icon row ── */
-
-
-
- {activityBarIcons}
- {closeButton}
-
-
-
+
+
+
+
+ {activityBarIcons}
+
+
+ {closeButton}
+
+
+
{visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''}
@@ -339,6 +336,13 @@ function RightSidebarInner(): React.JSX.Element {
)}
+ {showGitHubBudgetInPanel ? (
+
+ ) : null}
+
{panelContent}
{/* Resize handle on LEFT side */}
@@ -423,7 +427,7 @@ function ActivityBarButton({
{
'commit_sync',
'separator',
'push',
+ 'create_pr',
+ 'push_create_pr',
'pull',
'sync',
'fetch',
@@ -168,6 +170,39 @@ describe('resolveDropdownItems', () => {
expect(byKind.publish.disabled).toBe(false)
})
+ it('does not mention Publish Branch when the linked PR is already merged', () => {
+ const items = resolveDropdownItems(
+ inputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ prState: 'merged'
+ })
+ )
+ const byKind = Object.fromEntries(
+ items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
+ )
+ expect(byKind.push.title).toBe('PR is already merged')
+ expect(byKind.pull.title).toBe('PR is already merged')
+ expect(byKind.sync.title).toBe('PR is already merged')
+ expect(byKind.publish.label).toBe('PR Status')
+ expect(byKind.publish.title).toBe('PR is already merged')
+ expect(byKind.publish.disabled).toBe(true)
+ })
+
+ it('waits for linked PR state before showing a publish prompt', () => {
+ const items = resolveDropdownItems(
+ inputs({
+ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
+ isPRStateLoading: true
+ })
+ )
+ const byKind = Object.fromEntries(
+ items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
+ )
+ expect(byKind.publish.label).toBe('PR Status')
+ expect(byKind.publish.title).toBe('Checking PR status…')
+ expect(byKind.publish.disabled).toBe(true)
+ })
+
it('omits counts from compound commit labels even when ahead/behind are nonzero', () => {
// Why: the commit itself changes ahead/behind, so pre-commit counts would
// be stale the moment the action fires. Plain Push/Pull/Sync continue to
@@ -188,4 +223,25 @@ describe('resolveDropdownItems', () => {
expect(byKind.push.label).toBe('Push (2)')
expect(byKind.sync.label).toBe('Sync (↓3 ↑2)')
})
+
+ it('enables Push & Create PR when review creation is only blocked by unpushed commits', () => {
+ const items = resolveDropdownItems(
+ inputs({
+ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
+ hostedReviewCreation: {
+ provider: 'github',
+ review: null,
+ canCreate: false,
+ blockedReason: 'needs_push',
+ nextAction: 'push'
+ }
+ })
+ )
+ const byKind = Object.fromEntries(
+ items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
+ )
+ expect(byKind.create_pr.disabled).toBe(true)
+ expect(byKind.create_pr.hint).toBe('Push first')
+ expect(byKind.push_create_pr.disabled).toBe(false)
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
index 78a9eac4d14..ed7fca8d87c 100644
--- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
@@ -6,6 +6,8 @@ export type DropdownActionKind =
| 'commit'
| 'commit_push'
| 'commit_sync'
+ | 'create_pr'
+ | 'push_create_pr'
| 'push'
| 'pull'
| 'sync'
@@ -17,6 +19,7 @@ export type DropdownItem = {
label: string
title: string
disabled: boolean
+ hint?: string
}
export type DropdownSeparator = { kind: 'separator' }
@@ -58,7 +61,10 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
hasUnresolvedConflicts,
isCommitting,
isRemoteOperationActive,
- upstreamStatus
+ upstreamStatus,
+ prState,
+ isPRStateLoading,
+ hostedReviewCreation
} = inputs
const hasStaged = stagedCount > 0
@@ -71,6 +77,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
// button's stable-frame guarantee extends to the dropdown.
const upstreamLoading = upstreamStatus === undefined
const hasUpstream = upstreamStatus?.hasUpstream ?? false
+ const publishBlockedByMergedPR = !hasUpstream && prState === 'merged'
+ const publishBlockedByPRLoading = !hasUpstream && !!isPRStateLoading
const ahead = upstreamStatus?.ahead ?? 0
const behind = upstreamStatus?.behind ?? 0
@@ -108,20 +116,36 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
// so the "publish first" instruction is consistent across the menu.
const commitPushTitle = upstreamLoading
? 'Checking branch status…'
- : !hasUpstream
- ? 'Publish the branch first to push commits'
- : (commitDisabledReason ?? 'Commit staged changes and push')
+ : publishBlockedByPRLoading
+ ? 'Checking PR status…'
+ : publishBlockedByMergedPR
+ ? 'PR is already merged'
+ : !hasUpstream
+ ? 'Publish the branch first to push commits'
+ : (commitDisabledReason ?? 'Commit staged changes and push')
const commitPushItem: DropdownItem = {
kind: 'commit_push',
label: 'Commit & Push',
title: commitPushTitle,
- disabled: globalBusy || upstreamLoading || !hasUpstream || commitDisabledReason !== null
+ disabled:
+ globalBusy ||
+ upstreamLoading ||
+ !hasUpstream ||
+ publishBlockedByPRLoading ||
+ publishBlockedByMergedPR ||
+ commitDisabledReason !== null
}
const commitSyncTitle = (() => {
if (upstreamLoading) {
return 'Checking branch status…'
}
+ if (publishBlockedByPRLoading) {
+ return 'Checking PR status…'
+ }
+ if (publishBlockedByMergedPR) {
+ return 'PR is already merged'
+ }
if (!hasUpstream) {
// Why: mirror pushItem/syncItem — direct the user to Publish Branch
// (the primary action on an unpublished branch) rather than naming a
@@ -146,11 +170,15 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
label: formatCountLabel('Push', ahead),
title: upstreamLoading
? 'Checking branch status…'
- : !hasUpstream
- ? 'Publish the branch first to push commits'
- : ahead === 0
- ? 'Nothing to push'
- : describePushCount(ahead),
+ : publishBlockedByPRLoading
+ ? 'Checking PR status…'
+ : publishBlockedByMergedPR
+ ? 'PR is already merged'
+ : !hasUpstream
+ ? 'Publish the branch first to push commits'
+ : ahead === 0
+ ? 'Nothing to push'
+ : describePushCount(ahead),
disabled: globalBusy || upstreamLoading || !hasUpstream || ahead === 0
}
@@ -159,11 +187,15 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
label: formatCountLabel('Pull', behind),
title: upstreamLoading
? 'Checking branch status…'
- : !hasUpstream
- ? 'Publish the branch first to pull commits'
- : behind === 0
- ? 'Nothing to pull'
- : describePullCount(behind),
+ : publishBlockedByPRLoading
+ ? 'Checking PR status…'
+ : publishBlockedByMergedPR
+ ? 'PR is already merged'
+ : !hasUpstream
+ ? 'Publish the branch first to pull commits'
+ : behind === 0
+ ? 'Nothing to pull'
+ : describePullCount(behind),
disabled: globalBusy || upstreamLoading || !hasUpstream || behind === 0
}
@@ -172,11 +204,15 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
label: formatSyncLabel('Sync', ahead, behind),
title: upstreamLoading
? 'Checking branch status…'
- : !hasUpstream
- ? 'Publish the branch first to sync commits'
- : ahead === 0 && behind === 0
- ? 'Branch is up to date'
- : describeSyncCounts(ahead, behind),
+ : publishBlockedByPRLoading
+ ? 'Checking PR status…'
+ : publishBlockedByMergedPR
+ ? 'PR is already merged'
+ : !hasUpstream
+ ? 'Publish the branch first to sync commits'
+ : ahead === 0 && behind === 0
+ ? 'Branch is up to date'
+ : describeSyncCounts(ahead, behind),
disabled: globalBusy || upstreamLoading || !hasUpstream || (ahead === 0 && behind === 0)
}
@@ -189,13 +225,72 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
const publishItem: DropdownItem = {
kind: 'publish',
- label: 'Publish Branch',
+ label: publishBlockedByMergedPR || publishBlockedByPRLoading ? 'PR Status' : 'Publish Branch',
title: upstreamLoading
? 'Checking branch status…'
- : hasUpstream
- ? 'Branch is already published'
- : 'Publish this branch to origin',
- disabled: globalBusy || upstreamLoading || hasUpstream
+ : publishBlockedByPRLoading
+ ? 'Checking PR status…'
+ : publishBlockedByMergedPR
+ ? 'PR is already merged'
+ : hasUpstream
+ ? 'Branch is already published'
+ : 'Publish this branch to origin',
+ disabled:
+ globalBusy ||
+ upstreamLoading ||
+ hasUpstream ||
+ publishBlockedByPRLoading ||
+ publishBlockedByMergedPR
+ }
+
+ const createBlockedHint = (() => {
+ switch (hostedReviewCreation?.blockedReason) {
+ case 'dirty':
+ return 'Commit changes first'
+ case 'detached_head':
+ return 'Check out a branch first'
+ case 'default_branch':
+ return 'Switch to a feature branch'
+ case 'no_upstream':
+ return 'Publish Branch'
+ case 'needs_push':
+ return 'Push first'
+ case 'needs_sync':
+ return 'Sync first'
+ case 'auth_required':
+ return 'Run gh auth login in this environment'
+ case 'unsupported_provider':
+ return 'Unsupported provider'
+ case 'existing_review':
+ return 'A pull request already exists'
+ case 'fork_head_unsupported':
+ return 'Fork head unsupported'
+ default:
+ return upstreamLoading ? 'Checking branch status…' : 'Branch is not ready'
+ }
+ })()
+
+ const createPRItem: DropdownItem = {
+ kind: 'create_pr',
+ label: 'Create PR',
+ title: hostedReviewCreation?.canCreate
+ ? 'Create a pull request for this branch'
+ : createBlockedHint,
+ hint: hostedReviewCreation?.canCreate ? undefined : createBlockedHint,
+ disabled: globalBusy || upstreamLoading || !hostedReviewCreation?.canCreate
+ }
+
+ const canPushAndCreate =
+ !globalBusy &&
+ !upstreamLoading &&
+ hostedReviewCreation?.provider === 'github' &&
+ hostedReviewCreation.blockedReason === 'needs_push'
+ const pushCreatePRItem: DropdownItem = {
+ kind: 'push_create_pr',
+ label: 'Push & Create PR',
+ title: canPushAndCreate ? 'Push local commits, then create a pull request' : createBlockedHint,
+ hint: canPushAndCreate ? undefined : createBlockedHint,
+ disabled: !canPushAndCreate
}
return [
@@ -204,6 +299,8 @@ export function resolveDropdownItems(inputs: PrimaryActionInputs): DropdownEntry
commitSyncItem,
{ kind: 'separator' },
pushItem,
+ createPRItem,
+ pushCreatePRItem,
pullItem,
syncItem,
fetchItem,
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts
index c0655ca03a9..8cfb44aff3c 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts
@@ -1,3 +1,4 @@
+/* eslint-disable max-lines -- Why: this state-machine table intentionally keeps every primary-action priority case together so merge regressions are visible in one file. */
import { describe, expect, it } from 'vitest'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
@@ -182,6 +183,16 @@ describe('resolvePrimaryAction', () => {
})
})
+ it.each([
+ [{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'],
+ [{ isPRStateLoading: true }, 'Checking PR status…']
+ ])('does not offer Publish Branch when linked PR state blocks it', (overrides, title) => {
+ const result = resolvePrimaryAction(
+ inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, ...overrides })
+ )
+ expect(result).toEqual({ kind: 'commit', label: 'Commit', title, disabled: true })
+ })
+
it('returns Sync when clean + tracked + diverged both ways', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
@@ -325,4 +336,25 @@ describe('resolvePrimaryAction', () => {
disabled: true
})
})
+
+ it('returns Create PR when a clean tracked branch is eligible for review creation', () => {
+ const result = resolvePrimaryAction(
+ inputs({
+ upstreamStatus: upstreamInSync,
+ hostedReviewCreation: {
+ provider: 'github',
+ review: null,
+ canCreate: true,
+ blockedReason: null,
+ nextAction: null
+ }
+ })
+ )
+ expect(result).toEqual({
+ kind: 'create_pr',
+ label: 'Create PR',
+ title: 'Create a pull request for this branch',
+ disabled: false
+ })
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
index 51a0f808ce5..022c0d38327 100644
--- a/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.ts
@@ -1,6 +1,7 @@
// Why: split from the combined primary+dropdown module because the primary and dropdown are independent derivations with different priority ladders; together they exceed the max-lines budget and tangle unrelated concerns.
-import type { GitUpstreamStatus } from '../../../../shared/types'
+import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
+import type { GitUpstreamStatus, PRState } from '../../../../shared/types'
// Why: this module owns the pure state-machine logic for the Source Control
// primary action (split button). Keeping the logic outside the React component
@@ -14,7 +15,14 @@ import type { GitUpstreamStatus } from '../../../../shared/types'
// `handlePrimaryClick` switch exhaustively over only the kinds the
// primary can actually emit, and it kills the compound-commit branch in
// the isRemoteOperationActive tooltip below at compile time.
-export type PrimaryActionKind = 'commit' | 'stage' | 'push' | 'pull' | 'sync' | 'publish'
+export type PrimaryActionKind =
+ | 'commit'
+ | 'stage'
+ | 'push'
+ | 'pull'
+ | 'sync'
+ | 'publish'
+ | 'create_pr'
// Why: the in-flight remote op tracker stores which action the user actually
// triggered, so the primary button can mirror that label/spinner instead of
@@ -39,11 +47,14 @@ export type PrimaryActionInputs = {
isCommitting: boolean
isRemoteOperationActive: boolean
upstreamStatus: GitUpstreamStatus | undefined
+ prState?: PRState | null
+ isPRStateLoading?: boolean
// Why: which remote op is currently running, when one is. null when no
// remote op is in flight. Used by the in-flight branch below to mirror
// the user-triggered action on the primary button instead of leaving a
// stale label that no longer matches what the slice is doing.
inFlightRemoteOpKind?: RemoteOpKind | null
+ hostedReviewCreation?: HostedReviewCreationEligibility | null
}
const PRIMARY_LABEL_BY_KIND: Record, string> = {
@@ -51,7 +62,8 @@ const PRIMARY_LABEL_BY_KIND: Record, string
push: 'Push',
pull: 'Pull',
sync: 'Sync',
- publish: 'Publish Branch'
+ publish: 'Publish Branch',
+ create_pr: 'Create PR'
}
function describePushCount(ahead: number): string {
@@ -93,7 +105,10 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
isCommitting,
isRemoteOperationActive,
upstreamStatus,
- inFlightRemoteOpKind
+ prState,
+ isPRStateLoading,
+ inFlightRemoteOpKind,
+ hostedReviewCreation
} = inputs
// 1. Commit in flight — lock the primary no matter what else is true.
@@ -214,6 +229,24 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
if (!upstreamStatus.hasUpstream) {
+ if (isPRStateLoading) {
+ return {
+ kind: 'commit',
+ label: 'Commit',
+ title: 'Checking PR status…',
+ disabled: true
+ }
+ }
+
+ if (prState === 'merged') {
+ return {
+ kind: 'commit',
+ label: 'Commit',
+ title: 'Nothing to commit. PR is already merged.',
+ disabled: true
+ }
+ }
+
return {
kind: 'publish',
label: 'Publish Branch',
@@ -247,6 +280,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
}
+ if (hostedReviewCreation?.canCreate) {
+ return {
+ kind: 'create_pr',
+ label: 'Create PR',
+ title: 'Create a pull request for this branch',
+ disabled: false
+ }
+ }
+
// Clean + tracked + in sync — distinguish truly clean from work that still
// needs staging before commit can proceed.
return {
diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts
index 44996e80790..6aeb95dd24b 100644
--- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts
+++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts
@@ -4,6 +4,7 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { dirname } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
+import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { isPathEqualOrDescendant } from './file-explorer-paths'
import type { TreeNode } from './file-explorer-types'
import {
@@ -11,6 +12,12 @@ import {
requestEditorSaveQuiesce
} from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
+import {
+ deleteRuntimePath,
+ isRemoteRuntimeFileOperation,
+ readRuntimeFileContent,
+ writeRuntimeFile
+} from '@/runtime/runtime-file-client'
type UseFileDeletionParams = {
activeWorktreeId: string | null
@@ -54,7 +61,18 @@ export function useFileDeletion({
inFlightRef.current.add(node.path)
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- const isRemote = connectionId !== undefined
+ const state = useAppStore.getState()
+ const worktree = activeWorktreeId
+ ? findWorktreeById(state.worktreesByRepo, activeWorktreeId)
+ : null
+ const fileContext = {
+ settings: state.settings,
+ worktreeId: activeWorktreeId,
+ worktreePath: worktree?.path ?? null,
+ connectionId
+ }
+ const isRemote =
+ connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path)
// Why: remote deletes go through `rm` on the relay — there is no OS-level
// Trash/Recycle Bin, so the operation is permanent. Require an explicit
@@ -93,7 +111,13 @@ export function useFileDeletion({
let undoContent: string | undefined
if (!node.isDirectory) {
try {
- const rf = await window.api.fs.readFile({ filePath: node.path, connectionId })
+ const rf = await readRuntimeFileContent({
+ settings: fileContext.settings,
+ filePath: node.path,
+ relativePath: node.relativePath,
+ worktreeId: activeWorktreeId ?? undefined,
+ connectionId
+ })
if (!rf.isBinary) {
undoContent = rf.content
}
@@ -103,28 +127,16 @@ export function useFileDeletion({
}
}
- await window.api.fs.deletePath({
- targetPath: node.path,
- connectionId,
- recursive: node.isDirectory
- })
+ await deleteRuntimePath(fileContext, node.path, node.isDirectory)
if (undoContent !== undefined) {
commitFileExplorerOp({
undo: async () => {
- await window.api.fs.writeFile({
- filePath: node.path,
- content: undoContent,
- connectionId
- })
+ await writeRuntimeFile(fileContext, node.path, undoContent)
await refreshDir(parentDir)
},
redo: async () => {
- await window.api.fs.deletePath({
- targetPath: node.path,
- connectionId,
- recursive: node.isDirectory
- })
+ await deleteRuntimePath(fileContext, node.path, node.isDirectory)
await refreshDir(parentDir)
}
})
diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts
index 255b380ca5a..82ef96b1353 100644
--- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts
+++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts
@@ -2,6 +2,9 @@ import { useCallback } from 'react'
import { toast } from 'sonner'
import { basename, dirname, joinPath } from '@/lib/path'
import type { TreeNode } from './file-explorer-types'
+import { useAppStore } from '@/store'
+import { copyRuntimePath, runtimePathExists } from '@/runtime/runtime-file-client'
+import { getConnectionId } from '@/lib/connection-context'
/**
* Electron's ipcRenderer.invoke wraps errors as:
@@ -17,11 +20,13 @@ function extractIpcErrorMessage(err: unknown, fallback: string): string {
}
type UseFileDuplicateParams = {
+ activeWorktreeId: string | null
worktreePath: string | null
refreshDir: (dirPath: string) => Promise
}
export function useFileDuplicate({
+ activeWorktreeId,
worktreePath,
refreshDir
}: UseFileDuplicateParams): (node: TreeNode) => void {
@@ -37,12 +42,19 @@ export function useFileDuplicate({
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
const run = async (): Promise => {
+ const settings = useAppStore.getState().settings
+ const context = {
+ settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId: getConnectionId(activeWorktreeId) ?? undefined
+ }
// Why: generate a unique "stem copy.ext", "stem copy 2.ext", … name
// so we never collide with an existing file. pathExists checks are
// sequential to avoid TOCTOU races with COPYFILE_EXCL on the backend.
let candidate = joinPath(dir, `${stem} copy${ext}`)
let n = 2
- while (await window.api.shell.pathExists(candidate)) {
+ while (await runtimePathExists(context, candidate)) {
candidate = joinPath(dir, `${stem} copy ${n}${ext}`)
n += 1
}
@@ -58,7 +70,7 @@ export function useFileDuplicate({
// eslint-disable-next-line no-constant-condition
while (true) {
try {
- await window.api.shell.copyFile({ srcPath: node.path, destPath: candidate })
+ await copyRuntimePath(context, node.path, candidate)
break
} catch (err) {
const isEexist =
@@ -87,6 +99,6 @@ export function useFileDuplicate({
}
void run()
},
- [worktreePath, refreshDir]
+ [activeWorktreeId, worktreePath, refreshDir]
)
}
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
index 30c8f964881..32716165c12 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
@@ -10,6 +10,7 @@ import { detectLanguage } from '@/lib/language-detect'
import { getConnectionId } from '@/lib/connection-context'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
+import { renameRuntimePath } from '@/runtime/runtime-file-client'
function extractIpcErrorMessage(err: unknown, fallback: string): string {
if (!(err instanceof Error)) {
@@ -281,16 +282,22 @@ export function useFileExplorerDragDrop({
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId })
+ const fileContext = {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ }
+ await renameRuntimePath(fileContext, sourcePath, newPath)
commitFileExplorerOp({
undo: async () => {
- await window.api.fs.rename({ oldPath: newPath, newPath: sourcePath, connectionId })
+ await renameRuntimePath(fileContext, newPath, sourcePath)
await Promise.all([refreshDir(destDir), refreshDir(sourceDir)])
remapOpenTabsForMovedPath(newPath, sourcePath)
},
redo: async () => {
- await window.api.fs.rename({ oldPath: sourcePath, newPath, connectionId })
+ await renameRuntimePath(fileContext, sourcePath, newPath)
await Promise.all([refreshDir(sourceDir), refreshDir(destDir)])
remapOpenTabsForMovedPath(sourcePath, newPath)
}
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts
index b08ddb30532..9a253a476ab 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts
@@ -3,6 +3,8 @@ import type { Dispatch, SetStateAction } from 'react'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
+import { useAppStore } from '@/store'
+import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
type UseFileExplorerImportParams = {
worktreePath: string | null
@@ -61,21 +63,26 @@ export function useFileExplorerImport({
void (async () => {
try {
- const { results } = await window.api.fs.importExternalPaths({
- sourcePaths: paths,
- destDir: destinationDir,
- connectionId
- })
+ const settings = useAppStore.getState().settings
+ const { results } = await importExternalPathsToRuntime(
+ {
+ settings,
+ worktreeId: wtId,
+ worktreePath: worktreePathRef.current,
+ connectionId
+ },
+ paths,
+ destinationDir
+ )
// Refresh the destination directory once per gesture
await refreshDirRef.current(destinationDir)
// Why: only select (highlight) the first imported file — don't trigger
- // the full reveal machinery (scrollToIndex + flash) because the user
- // already knows where they dropped the file. The reveal's aggressive
- // scroll-to-center races with FS watcher refreshes and can snap the
- // viewport back to the top of the tree.
+ // the full reveal machinery because watcher refreshes can otherwise
+ // snap the tree viewport away from the user's drop target.
const imported = results.filter((r) => r.status === 'imported')
+ const skipped = results.filter((r) => r.status === 'skipped')
const failed = results.filter((r) => r.status === 'failed')
if (imported.length > 0) {
@@ -85,6 +92,9 @@ export function useFileExplorerImport({
if (failed.length > 0) {
const noun = failed.length === 1 ? 'file' : 'files'
toast.error(`Failed to import ${failed.length} ${noun}.`)
+ } else if (skipped.length > 0 && imported.length === 0) {
+ const noun = skipped.length === 1 ? 'file' : 'files'
+ toast.error(`Skipped ${skipped.length} ${noun}.`)
}
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to import files.'))
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts
index 65d495437be..a4064115397 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts
@@ -9,6 +9,7 @@ import { extractIpcErrorMessage, renameFileOnDisk } from '@/lib/rename-file'
import type { InlineInput } from './FileExplorerRow'
import type { TreeNode } from './file-explorer-types'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
+import { createRuntimePath, deleteRuntimePath } from '@/runtime/runtime-file-client'
type UseFileExplorerInlineInputParams = {
activeWorktreeId: string | null
@@ -110,6 +111,12 @@ export function useFileExplorerInlineInput({
}
const run = async (): Promise => {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
+ const fileContext = {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ }
if (inlineInput.type === 'rename' && inlineInput.existingPath) {
await renameFileOnDisk({
oldPath: inlineInput.existingPath,
@@ -121,29 +128,31 @@ export function useFileExplorerInlineInput({
} else {
const fullPath = joinPath(inlineInput.parentPath, name)
try {
- await (inlineInput.type === 'folder'
- ? window.api.fs.createDir({ dirPath: fullPath, connectionId })
- : window.api.fs.createFile({ filePath: fullPath, connectionId }))
+ await createRuntimePath(
+ fileContext,
+ fullPath,
+ inlineInput.type === 'folder' ? 'directory' : 'file'
+ )
const parentForRefresh = inlineInput.parentPath
if (inlineInput.type === 'folder') {
commitFileExplorerOp({
undo: async () => {
- await window.api.fs.deletePath({ targetPath: fullPath, connectionId })
+ await deleteRuntimePath(fileContext, fullPath, true)
await refreshDir(parentForRefresh)
},
redo: async () => {
- await window.api.fs.createDir({ dirPath: fullPath, connectionId })
+ await createRuntimePath(fileContext, fullPath, 'directory')
await refreshDir(parentForRefresh)
}
})
} else {
commitFileExplorerOp({
undo: async () => {
- await window.api.fs.deletePath({ targetPath: fullPath, connectionId })
+ await deleteRuntimePath(fileContext, fullPath)
await refreshDir(parentForRefresh)
},
redo: async () => {
- await window.api.fs.createFile({ filePath: fullPath, connectionId })
+ await createRuntimePath(fileContext, fullPath, 'file')
await refreshDir(parentForRefresh)
}
})
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts
index a09c31b3333..7c52f0a756d 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts
@@ -5,6 +5,8 @@ import { getConnectionId } from '@/lib/connection-context'
import type { DirCache, TreeNode } from './file-explorer-types'
import { splitPathSegments } from './path-tree'
import { shouldIncludeFileExplorerEntry } from './file-explorer-entries'
+import { readRuntimeDirectory } from '@/runtime/runtime-file-client'
+import { useAppStore } from '@/store'
type UseFileExplorerTreeResult = {
dirCache: Record
@@ -48,7 +50,15 @@ export function useFileExplorerTree(
}))
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
- const entries = await window.api.fs.readDir({ dirPath, connectionId })
+ const entries = await readRuntimeDirectory(
+ {
+ settings: useAppStore.getState().settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ dirPath
+ )
if (depth === -1) {
setRootError(null)
}
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts
index 2d0c9290074..6aae3550b35 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { FsChangedPayload } from '../../../../shared/types'
import {
+ canonicalizeFileExplorerWatchPath,
getExternalFileChangeRelativePath,
payloadRequiresDeferredTreeRefresh
} from './useFileExplorerWatch'
@@ -32,6 +33,22 @@ describe('getExternalFileChangeRelativePath', () => {
).toBe('config/settings.json')
})
+ it('matches Windows paths case-insensitively before deriving the relative path', () => {
+ expect(
+ getExternalFileChangeRelativePath('C:\\Repo', 'c:\\repo\\config\\settings.json', false)
+ ).toBe('config/settings.json')
+ })
+
+ it('preserves UNC roots when deriving the relative path', () => {
+ expect(
+ getExternalFileChangeRelativePath(
+ '//Server/Share/Repo',
+ '//server/share/repo/config/settings.json',
+ false
+ )
+ ).toBe('config/settings.json')
+ })
+
it('ignores paths outside the active worktree', () => {
expect(
getExternalFileChangeRelativePath('/repo', '/other/config/settings.json', false)
@@ -62,6 +79,29 @@ describe('getExternalFileChangeRelativePath', () => {
})
})
+describe('canonicalizeFileExplorerWatchPath', () => {
+ it('returns event paths with the watched worktree casing for UNC cache lookups', () => {
+ expect(
+ canonicalizeFileExplorerWatchPath('//Server/Share/Repo', '//server/share/repo/src/index.ts')
+ ).toBe('//Server/Share/Repo/src/index.ts')
+ })
+
+ it('preserves the watched worktree separator style for Windows cache lookups', () => {
+ expect(canonicalizeFileExplorerWatchPath('C:\\Repo', 'c:\\repo\\src\\index.ts')).toBe(
+ 'C:\\Repo\\src\\index.ts'
+ )
+ })
+
+ it('rejects sibling UNC shares whose path merely shares a prefix', () => {
+ expect(
+ canonicalizeFileExplorerWatchPath(
+ '//Server/Share/Repo',
+ '//server/share/repository/src/index.ts'
+ )
+ ).toBeNull()
+ })
+})
+
describe('payloadRequiresDeferredTreeRefresh', () => {
function payload(events: FsChangedPayload['events'], worktreePath = '/repo'): FsChangedPayload {
return { worktreePath, events }
diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts
index 22c9dbd0205..5f926604e81 100644
--- a/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts
+++ b/src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts
@@ -3,14 +3,19 @@ import type { Dispatch, SetStateAction } from 'react'
import type { FsChangedPayload } from '../../../../shared/types'
import type { DirCache } from './file-explorer-types'
import type { InlineInput } from './FileExplorerRow'
-import { normalizeRelativePath } from '@/lib/path'
-import { normalizeAbsolutePath } from './file-explorer-paths'
-import { dirname } from '@/lib/path'
+import { joinPath, normalizeRelativePath, dirname } from '@/lib/path'
+import {
+ isPathInsideOrEqual,
+ normalizeRuntimePathForComparison,
+ relativePathInsideRoot
+} from '../../../../shared/cross-platform-path'
import {
purgeDirCacheSubtree,
purgeExpandedDirsSubtree,
clearStalePendingReveal
} from './file-explorer-watcher-reconcile'
+import { useAppStore } from '@/store'
+import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
type UseFileExplorerWatchParams = {
worktreePath: string | null
@@ -35,17 +40,8 @@ export function getExternalFileChangeRelativePath(
return null
}
- const normalizedWorktreePath = normalizeAbsolutePath(worktreePath)
- const normalizedAbsolutePath = normalizeAbsolutePath(absolutePath)
- // Why: `normalizeAbsolutePath` preserves the trailing slash for filesystem
- // roots (`/` on POSIX, `C:/` on Windows drive roots) but strips it from all
- // other paths. Blindly appending `/` would produce `//` or `C://`, so
- // external edits under a root worktree would fail the prefix check and be
- // silently dropped. Treat those roots as already-terminated prefixes.
- const isFsRoot = normalizedWorktreePath === '/' || /^[A-Za-z]:\/$/.test(normalizedWorktreePath)
- const worktreePrefix = isFsRoot ? normalizedWorktreePath : `${normalizedWorktreePath}/`
-
- if (!normalizedAbsolutePath.startsWith(worktreePrefix)) {
+ const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
+ if (relativePath === null || relativePath === '') {
return null
}
@@ -54,14 +50,37 @@ export function getExternalFileChangeRelativePath(
// filesystem watcher reports absolute paths, so normalize them here before
// the explorer refresh path returns; otherwise terminal edits refresh the
// tree but leave the editor's cached file contents stale.
- return normalizeRelativePath(normalizedAbsolutePath.slice(worktreePrefix.length))
+ return normalizeRelativePath(relativePath)
+}
+
+export function canonicalizeFileExplorerWatchPath(
+ worktreePath: string,
+ absolutePath: string
+): string | null {
+ const relativePath = relativePathInsideRoot(worktreePath, absolutePath)
+ if (relativePath === null) {
+ return null
+ }
+
+ const rootPath = normalizeExplorerAbsolutePath(worktreePath)
+ return relativePath === '' ? rootPath : joinPath(rootPath, relativePath)
+}
+
+function normalizeExplorerAbsolutePath(path: string): string {
+ if (path === '/' || /^[A-Za-z]:[\\/]$/.test(path)) {
+ return path
+ }
+ return path.replace(/[\\/]+$/, '')
}
export function payloadRequiresDeferredTreeRefresh(
payload: FsChangedPayload,
currentWorktreePath: string
): boolean {
- if (normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath)) {
+ if (
+ normalizeRuntimePathForComparison(payload.worktreePath) !==
+ normalizeRuntimePathForComparison(currentWorktreePath)
+ ) {
return false
}
@@ -91,6 +110,8 @@ export function useFileExplorerWatch({
dragSourcePath,
isNativeDragOver
}: UseFileExplorerWatchParams): void {
+ const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
+
// Keep refs for values accessed inside the event handler to avoid
// re-subscribing the IPC listener on every render.
const dirCacheRef = useRef(dirCache)
@@ -151,7 +172,8 @@ export function useFileExplorerWatch({
// the old worktree can arrive after the switch. Processing them against
// the new worktree's tree state would corrupt dirCache (design §3).
if (
- normalizeAbsolutePath(payload.worktreePath) !== normalizeAbsolutePath(currentWorktreePath)
+ normalizeRuntimePathForComparison(payload.worktreePath) !==
+ normalizeRuntimePathForComparison(currentWorktreePath)
) {
return
}
@@ -169,13 +191,19 @@ export function useFileExplorerWatch({
let needsFullRefresh = false
for (const evt of payload.events) {
- const normalizedPath = normalizeAbsolutePath(evt.absolutePath)
-
if (evt.kind === 'overflow') {
needsFullRefresh = true
break
}
+ const normalizedPath = canonicalizeFileExplorerWatchPath(
+ currentWorktreePath,
+ evt.absolutePath
+ )
+ if (!normalizedPath) {
+ continue
+ }
+
if (evt.kind === 'delete') {
// Why: for delete events, isDirectory is undefined from the watcher
// (the path no longer exists). Infer from dirCache: if the deleted
@@ -193,27 +221,27 @@ export function useFileExplorerWatch({
// Clear selectedPath if it points into the deleted subtree
setSelectedPath((prev) => {
- if (prev && normalizeAbsolutePath(prev) === normalizedPath) {
- return null
- }
if (
prev &&
- wasDirectory &&
- normalizeAbsolutePath(prev).startsWith(`${normalizedPath}/`)
+ normalizeRuntimePathForComparison(prev) ===
+ normalizeRuntimePathForComparison(normalizedPath)
) {
return null
}
+ if (prev && wasDirectory && isPathInsideOrEqual(normalizedPath, prev)) {
+ return null
+ }
return prev
})
// Invalidate the parent directory
- const parent = normalizeAbsolutePath(dirname(normalizedPath))
+ const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath))
if (parent in cache) {
dirsToRefresh.add(parent)
}
} else if (evt.kind === 'create') {
// Invalidate the parent directory
- const parent = normalizeAbsolutePath(dirname(normalizedPath))
+ const parent = normalizeExplorerAbsolutePath(dirname(normalizedPath))
if (parent in cache) {
dirsToRefresh.add(parent)
}
@@ -239,7 +267,7 @@ export function useFileExplorerWatch({
for (const dirPath of dirsToRefresh) {
// Check the dir is the root or an expanded directory or already in cache
if (
- dirPath === normalizeAbsolutePath(currentWorktreePath) ||
+ dirPath === normalizeExplorerAbsolutePath(currentWorktreePath) ||
exp.has(dirPath) ||
dirPath in dirCacheRef.current
) {
@@ -271,14 +299,52 @@ export function useFileExplorerWatch({
processPayload(payload)
}
- const unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged)
+ let disposed = false
+ let unsubscribeListener: (() => void) | null = null
+ if (activeRuntimeEnvironmentId?.trim() && activeWorktreeId) {
+ // Why: remote runtime watch events do not enter the local Electron
+ // fs:changed bus, so the Explorer subscribes directly while it is mounted.
+ void subscribeRuntimeFileChanges(
+ {
+ settings: { activeRuntimeEnvironmentId },
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId: undefined
+ },
+ handleFsChanged,
+ (err) => {
+ console.warn('[filesystem-watch] failed to subscribe to runtime file changes', {
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ error: err.message
+ })
+ }
+ )
+ .then((unsubscribe) => {
+ if (disposed) {
+ unsubscribe()
+ return
+ }
+ unsubscribeListener = unsubscribe
+ })
+ .catch((err) => {
+ console.warn('[filesystem-watch] failed to subscribe to runtime file changes', {
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ error: err instanceof Error ? err.message : String(err)
+ })
+ })
+ } else {
+ unsubscribeListener = window.api.fs.onFsChanged(handleFsChanged)
+ }
return () => {
- unsubscribeListener()
+ disposed = true
+ unsubscribeListener?.()
deferredRef.current = []
processPayloadRef.current = null
}
- }, [worktreePath, activeWorktreeId, setDirCache, setSelectedPath])
+ }, [worktreePath, activeWorktreeId, activeRuntimeEnvironmentId, setDirCache, setSelectedPath])
// ── Flush deferred events when interaction ends ────────────────────
useEffect(() => {
diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts
index 89121ff88bc..f7b0f80eeb2 100644
--- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts
+++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts
@@ -3,7 +3,7 @@ import type * as React from 'react'
import type { GitStatusResult } from '../../../../shared/types'
const worktree = { id: 'repo-1::/repo', repoId: 'repo-1', path: '/repo' }
-const repo = { id: 'repo-1', path: '/repo', kind: 'git' }
+const repo = { id: 'repo-1', path: '/repo', kind: 'git', connectionId: null as string | null }
type PollState = {
activeWorktreeId: string
@@ -13,6 +13,7 @@ type PollState = {
setUpstreamStatus: ReturnType
setConflictOperation: ReturnType
gitConflictOperationByWorktree: Record
+ sshConnectionStates: Map
}
type GitStatusPollingHook = () => void
@@ -22,7 +23,14 @@ function GitStatusPollingHarness({ runPolling }: { runPolling: GitStatusPollingH
return null
}
-async function usePollingOnce(status: GitStatusResult): Promise {
+async function usePollingOnce(
+ status: GitStatusResult,
+ options: {
+ connectionId?: string | null
+ sshStatus?: string
+ expectStatusCall?: boolean
+ } = {}
+): Promise<{ state: PollState; gitStatus: ReturnType }> {
vi.resetModules()
const state: PollState = {
@@ -32,8 +40,15 @@ async function usePollingOnce(status: GitStatusResult): Promise {
fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined),
setUpstreamStatus: vi.fn(),
setConflictOperation: vi.fn(),
- gitConflictOperationByWorktree: {}
+ gitConflictOperationByWorktree: {},
+ sshConnectionStates: new Map(
+ options.connectionId && options.sshStatus
+ ? [[options.connectionId, { status: options.sshStatus }]]
+ : []
+ )
}
+ const mockedRepo = { ...repo, connectionId: options.connectionId ?? null }
+ const gitStatus = vi.fn().mockResolvedValue(status)
vi.doMock('react', async () => {
const actual = await vi.importActual('react')
@@ -48,24 +63,26 @@ async function usePollingOnce(status: GitStatusResult): Promise {
})
vi.doMock('@/store', () => ({
- useAppStore: (selector: (s: PollState) => unknown) => selector(state)
+ useAppStore: Object.assign((selector: (s: PollState) => unknown) => selector(state), {
+ getState: () => ({ settings: null })
+ })
}))
vi.doMock('@/store/selectors', () => ({
useActiveWorktree: () => worktree,
useAllWorktrees: () => [worktree],
- useRepoById: () => repo,
- useRepoMap: () => new Map([[repo.id, repo]])
+ useRepoById: () => mockedRepo,
+ useRepoMap: () => new Map([[mockedRepo.id, mockedRepo]])
}))
vi.doMock('@/lib/connection-context', () => ({
- getConnectionId: () => undefined
+ getConnectionId: () => options.connectionId ?? undefined
}))
vi.stubGlobal('window', {
api: {
git: {
- status: vi.fn().mockResolvedValue(status)
+ status: gitStatus
}
},
addEventListener: vi.fn(),
@@ -78,9 +95,13 @@ async function usePollingOnce(status: GitStatusResult): Promise {
const { useGitStatusPolling: runPolling } = await import('./useGitStatusPolling')
GitStatusPollingHarness({ runPolling })
- await Promise.resolve()
+ await (options.expectStatusCall !== false
+ ? vi.waitFor(() => {
+ expect(state.setGitStatus).toHaveBeenCalled()
+ })
+ : Promise.resolve())
- return state
+ return { state, gitStatus }
}
describe('useGitStatusPolling', () => {
@@ -90,7 +111,7 @@ describe('useGitStatusPolling', () => {
})
it('uses upstream data from git status instead of spawning a separate upstream refresh', async () => {
- const state = await usePollingOnce({
+ const { state } = await usePollingOnce({
entries: [],
conflictOperation: 'unknown',
head: 'abc123',
@@ -113,7 +134,7 @@ describe('useGitStatusPolling', () => {
})
it('falls back to the upstream IPC for legacy status payloads', async () => {
- const state = await usePollingOnce({
+ const { state } = await usePollingOnce({
entries: [],
conflictOperation: 'unknown',
head: 'abc123',
@@ -123,4 +144,19 @@ describe('useGitStatusPolling', () => {
expect(state.setUpstreamStatus).not.toHaveBeenCalled()
expect(state.fetchUpstreamStatus).toHaveBeenCalledWith(worktree.id, '/repo', undefined)
})
+
+ it('skips remote git status polling while the SSH target is disconnected', async () => {
+ const { state, gitStatus } = await usePollingOnce(
+ {
+ entries: [],
+ conflictOperation: 'unknown',
+ head: 'abc123',
+ branch: 'refs/heads/main'
+ },
+ { connectionId: 'ssh-target-1', sshStatus: 'disconnected', expectStatusCall: false }
+ )
+
+ expect(gitStatus).not.toHaveBeenCalled()
+ expect(state.setGitStatus).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts
index 4ef3c3770e9..e989b921bdf 100644
--- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts
+++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts
@@ -4,6 +4,7 @@ import { useActiveWorktree, useAllWorktrees, useRepoById, useRepoMap } from '@/s
import type { GitConflictOperation } from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getConnectionId } from '@/lib/connection-context'
+import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client'
import { refreshGitStatusForWorktree } from './git-status-refresh'
const POLL_INTERVAL_MS = 3000
@@ -18,12 +19,19 @@ export function useGitStatusPolling(): void {
const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus)
const setConflictOperation = useAppStore((s) => s.setConflictOperation)
const conflictOperationByWorktree = useAppStore((s) => s.gitConflictOperationByWorktree)
+ const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const repoMap = useRepoMap()
const worktreePath = activeWorktree?.path ?? null
const activeRepoId = activeWorktree?.repoId ?? null
const activeRepo = useRepoById(activeRepoId)
const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false
+ const activeConnectionId = activeRepo?.connectionId ?? null
+ const isConnectionReady = useCallback(
+ (connectionId: string | null | undefined): boolean =>
+ !connectionId || sshConnectionStates.get(connectionId)?.status === 'connected',
+ [sshConnectionStates]
+ )
// Why: build a list of non-active worktrees that still have a known conflict
// operation (merge/rebase/cherry-pick). These need lightweight polling so
@@ -51,9 +59,13 @@ export function useGitStatusPolling(): void {
if (!activeWorktreeId || !worktreePath || !activeRepoSupportsGit) {
return
}
+ if (!isConnectionReady(activeConnectionId)) {
+ return
+ }
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
await refreshGitStatusForWorktree({
+ settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId,
@@ -69,8 +81,10 @@ export function useGitStatusPolling(): void {
}
}, [
activeRepoSupportsGit,
+ activeConnectionId,
activeWorktreeId,
fetchUpstreamStatus,
+ isConnectionReady,
worktreePath,
setGitStatus,
setUpstreamStatus,
@@ -109,9 +123,17 @@ export function useGitStatusPolling(): void {
const pollStale = async (): Promise => {
for (const { id, path } of staleConflictWorktrees) {
try {
- const op = (await window.api.git.conflictOperation({
+ const connectionId = getConnectionId(id) ?? undefined
+ // Why: after explicit SSH disconnect the provider is intentionally
+ // gone; keep remote polling quiet until the target reconnects.
+ if (!isConnectionReady(connectionId)) {
+ continue
+ }
+ const op = (await getRuntimeGitConflictOperation({
+ settings: useAppStore.getState().settings,
+ worktreeId: id,
worktreePath: path,
- connectionId: getConnectionId(id) ?? undefined
+ connectionId
})) as GitConflictOperation
setConflictOperation(id, op)
} catch {
@@ -132,5 +154,5 @@ export function useGitStatusPolling(): void {
clearInterval(intervalId)
window.removeEventListener('focus', onFocus)
}
- }, [staleConflictWorktrees, setConflictOperation])
+ }, [staleConflictWorktrees, setConflictOperation, isConnectionReady])
}
diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx
new file mode 100644
index 00000000000..375f849f5c9
--- /dev/null
+++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx
@@ -0,0 +1,51 @@
+import type { GlobalSettings } from '../../../../shared/types'
+import { Label } from '../ui/label'
+import { SearchableSetting } from './SearchableSetting'
+
+type AgentAwakeSettingProps = {
+ settings: GlobalSettings
+ updateSettings: (updates: Partial) => void
+}
+
+export function AgentAwakeSetting({
+ settings,
+ updateSettings
+}: AgentAwakeSettingProps): React.JSX.Element {
+ return (
+
+
+
+
Keep computer awake when Orca sees agents running
+
+ Prevents this computer from sleeping while Orca sees an agent working. The display can
+ still turn off.
+
+
+
+ updateSettings({
+ keepComputerAwakeWhileAgentsRun: !settings.keepComputerAwakeWhileAgentsRun
+ })
+ }
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
+ settings.keepComputerAwakeWhileAgentsRun ? 'bg-foreground' : 'bg-muted-foreground/30'
+ }`}
+ >
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx
new file mode 100644
index 00000000000..00a7059e101
--- /dev/null
+++ b/src/renderer/src/components/settings/AgentsPane.test.tsx
@@ -0,0 +1,101 @@
+import React from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getDefaultSettings } from '../../../../shared/constants'
+import type { GlobalSettings } from '../../../../shared/types'
+import { useAppStore } from '../../store'
+import { AgentAwakeSetting } from './AgentAwakeSetting'
+import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane'
+import { matchesSettingsSearch } from './settings-search'
+
+type ReactElementLike = {
+ type: unknown
+ props: Record
+}
+
+function renderPane(settings: GlobalSettings): string {
+ return renderToStaticMarkup(
+ React.createElement(AgentsPane, {
+ settings,
+ updateSettings: vi.fn()
+ })
+ )
+}
+
+function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
+ if (node == null || typeof node === 'string' || typeof node === 'number') {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach((entry) => visit(entry, cb))
+ return
+ }
+ const element = node as ReactElementLike
+ cb(element)
+ if (element.props?.children) {
+ visit(element.props.children, cb)
+ }
+}
+
+function findSwitch(node: unknown): ReactElementLike {
+ let found: ReactElementLike | null = null
+ visit(node, (entry) => {
+ if (entry.props.role === 'switch') {
+ found = entry
+ }
+ })
+ if (!found) {
+ throw new Error('switch not found')
+ }
+ return found
+}
+
+describe('AgentsPane', () => {
+ beforeEach(() => {
+ useAppStore.setState({
+ settingsSearchQuery: '',
+ detectedAgentIds: ['claude'],
+ isDetectingAgents: false,
+ isRefreshingAgents: false
+ })
+ })
+
+ it('renders the keep-awake toggle from settings', () => {
+ const markup = renderPane(getDefaultSettings('/tmp'))
+
+ expect(markup).toContain('Keep computer awake when Orca sees agents running')
+ expect(markup).toContain(
+ 'Prevents this computer from sleeping while Orca sees an agent working. The display can still turn off.'
+ )
+ expect(markup).toContain('aria-checked="false"')
+ })
+
+ it('toggles the keep-awake setting with the next value', () => {
+ const updateSettings = vi.fn()
+ const element = AgentAwakeSetting({
+ settings: {
+ ...getDefaultSettings('/tmp'),
+ keepComputerAwakeWhileAgentsRun: false
+ },
+ updateSettings
+ })
+
+ const keepAwakeSwitch = findSwitch(element)
+ expect(keepAwakeSwitch.props['aria-label']).toBe(
+ 'Keep computer awake when Orca sees agents running'
+ )
+ expect(keepAwakeSwitch.props['aria-checked']).toBe(false)
+
+ const onClick = keepAwakeSwitch.props.onClick as () => void
+ onClick()
+
+ expect(updateSettings).toHaveBeenCalledWith({
+ keepComputerAwakeWhileAgentsRun: true
+ })
+ })
+
+ it('includes awake and sleep search metadata for the setting', () => {
+ expect(matchesSettingsSearch('awake', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
+ expect(matchesSettingsSearch('sleep', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx
index 5b55b96a549..01e1c4bcd70 100644
--- a/src/renderer/src/components/settings/AgentsPane.tsx
+++ b/src/renderer/src/components/settings/AgentsPane.tsx
@@ -6,6 +6,7 @@ import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
+import { AgentAwakeSetting } from './AgentAwakeSetting'
export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search'
@@ -336,6 +337,8 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
+
+
{/* Detected agents */}
{detectedAgents.length > 0 && (
diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx
index ad34aa40038..ee1d6567d75 100644
--- a/src/renderer/src/components/settings/AppearancePane.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.tsx
@@ -61,23 +61,11 @@ const STATUS_BAR_TOGGLES: readonly {
},
{
id: 'resource-usage',
- title: 'Resource Usage',
- description: 'Show CPU, memory, and terminal session indicators in the status bar.',
- keywords: [
- 'status bar',
- 'resource',
- 'usage',
- 'memory',
- 'ram',
- 'cpu',
- 'terminal',
- 'sessions',
- 'pty',
- 'monitoring',
- 'performance'
- ],
+ title: 'Resource Manager',
+ description: 'Show CPU, memory, terminal sessions, and workspace disk usage in the status bar.',
+ keywords: ['status bar', 'resource', 'manager', 'memory', 'cpu', 'terminal', 'disk', 'space'],
toggleDescription:
- 'Show CPU, memory, and terminal session counts. Click it for a per-workspace breakdown and daemon controls.'
+ 'Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.'
}
]
diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx
index b5e586f317e..e3c8ddd67d9 100644
--- a/src/renderer/src/components/settings/BaseRefPicker.tsx
+++ b/src/renderer/src/components/settings/BaseRefPicker.tsx
@@ -2,6 +2,11 @@ import { useEffect, useState } from 'react'
import { ScrollArea } from '../ui/scroll-area'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
+import { useAppStore } from '@/store'
+import {
+ getRuntimeRepoBaseRefDefault,
+ searchRuntimeRepoBaseRefs
+} from '@/runtime/runtime-repo-client'
type BaseRefPickerProps = {
repoId: string
@@ -16,6 +21,9 @@ export function BaseRefPicker({
onSelect,
onUsePrimary
}: BaseRefPickerProps): React.JSX.Element {
+ const activeRuntimeEnvironmentId = useAppStore(
+ (state) => state.settings?.activeRuntimeEnvironmentId ?? null
+ )
// Why: null until the IPC resolves (or when the repo has no default base ref
// available). We avoid seeding with 'origin/main' because that would display
// a fabricated default in repos that don't actually have origin/main.
@@ -34,7 +42,7 @@ export function BaseRefPicker({
const loadDefaultBaseRef = async (): Promise => {
try {
- const result = await window.api.repos.getBaseRefDefault({ repoId })
+ const result = await getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId)
if (!stale) {
setDefaultBaseRef(result.defaultBaseRef)
setRemoteCount(result.remoteCount)
@@ -60,7 +68,7 @@ export function BaseRefPicker({
return () => {
stale = true
}
- }, [repoId])
+ }, [activeRuntimeEnvironmentId, repoId])
useEffect(() => {
const trimmedQuery = baseRefQuery.trim()
@@ -74,12 +82,7 @@ export function BaseRefPicker({
setIsSearchingBaseRefs(true)
const timer = window.setTimeout(() => {
- void window.api.repos
- .searchBaseRefs({
- repoId,
- query: trimmedQuery,
- limit: 20
- })
+ void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 20)
.then((results) => {
if (!stale) {
setBaseRefResults(results)
@@ -102,7 +105,7 @@ export function BaseRefPicker({
stale = true
window.clearTimeout(timer)
}
- }, [baseRefQuery, repoId])
+ }, [activeRuntimeEnvironmentId, baseRefQuery, repoId])
const effectiveBaseRef = currentBaseRef ?? defaultBaseRef
diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx
index 2d7d9ef7bd7..a2257f6b307 100644
--- a/src/renderer/src/components/settings/BrowserPane.tsx
+++ b/src/renderer/src/components/settings/BrowserPane.tsx
@@ -30,9 +30,14 @@ export const BROWSER_PANE_SEARCH_ENTRIES = [
type BrowserPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial) => void
+ onOpenComputerUse?: () => void
}
-export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): React.JSX.Element {
+export function BrowserPane({
+ settings,
+ updateSettings,
+ onOpenComputerUse
+}: BrowserPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
const setBrowserDefaultUrl = useAppStore((s) => s.setBrowserDefaultUrl)
@@ -87,7 +92,12 @@ export function BrowserPane({ settings, updateSettings }: BrowserPaneProps): Rea
return (
- {showBrowserUse ? : null}
+ {showBrowserUse ? (
+
+ ) : null}
{showHomePage ? (
void
+ onOpenComputerUse?: () => void
}
export function BrowserUseSetup({
- onConfigureMoreBrowsers
+ onConfigureMoreBrowsers,
+ onOpenComputerUse
}: BrowserUseSetupProps = {}): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const browserSessionProfiles = useAppStore((s) => s.browserSessionProfiles)
@@ -222,6 +224,31 @@ export function BrowserUseSetup({
+ {onOpenComputerUse ? (
+
+
+
+
Use an existing browser session
+
+ If cookie import is not the right fit, Computer Use can control local apps and may
+ use existing logged-in browser sessions where applicable. Install the Computer Use
+ skill; macOS also requires privacy permissions.
+
+
+
+
+ Open Computer Use
+
+
+
+ ) : null}
+
{showStep1 ? (
- setDialogOpen(true)}
- className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
- isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
- } ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
- >
-
-
+ {!isBrowserManaged ? (
+ setDialogOpen(true)}
+ className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors ${
+ isEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
+ } ${loading || !isSupported || busyAction !== null ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
+ >
+
+
+ ) : null}
@@ -182,7 +185,7 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
) : null}
- {!loading && !isSupported && status?.detail ? (
+ {!loading && !isSupported && !isBrowserManaged && status?.detail ? (
{status.detail}
) : null}
@@ -201,44 +204,46 @@ export function CliSection({ currentPlatform }: CliSectionProps): React.JSX.Elem
) : null}
-
-
-
Agent skills
-
- Install skills so agents know how to use Orca and report status.
-
-
+ {!isBrowserManaged ? (
+
+
+
Agent skills
+
+ Install skills so agents know how to use Orca and report status.
+
+
-
-
-
CLI skill
-
-
- {ORCA_CLI_SKILL_INSTALL_COMMAND}
-
-
-
-
-
- void handleCopySkillInstallCommand(ORCA_CLI_SKILL_INSTALL_COMMAND)
- }
- aria-label="Copy CLI skill install command"
- >
-
-
-
-
- Copy
-
-
-
+
+
+
CLI skill
+
+
+ {ORCA_CLI_SKILL_INSTALL_COMMAND}
+
+
+
+
+
+ void handleCopySkillInstallCommand(ORCA_CLI_SKILL_INSTALL_COMMAND)
+ }
+ aria-label="Copy CLI skill install command"
+ >
+
+
+
+
+ Copy
+
+
+
+
-
+ ) : null}
diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx
new file mode 100644
index 00000000000..f9478fd1998
--- /dev/null
+++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { GlobalSettings } from '../../../../shared/types'
+import { useAppStore } from '../../store'
+import { CommitMessageAiPane } from './CommitMessageAiPane'
+import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
+
+function renderPane(settings: GlobalSettings): string {
+ return renderToStaticMarkup(
+ React.createElement(CommitMessageAiPane, {
+ settings,
+ updateSettings: () => {}
+ })
+ )
+}
+
+function buildSettings(overrides: Partial = {}): GlobalSettings {
+ return {
+ commitMessageAi: {
+ enabled: false,
+ agentId: null,
+ selectedModelByAgent: {},
+ selectedThinkingByModel: {},
+ customPrompt: '',
+ customAgentCommand: ''
+ },
+ ...overrides
+ } as GlobalSettings
+}
+
+describe('CommitMessageAiPane', () => {
+ beforeEach(() => {
+ useAppStore.setState({ settingsSearchQuery: '' })
+ })
+
+ it('renders only the opt-in control before the feature is enabled', () => {
+ const markup = renderPane(buildSettings())
+
+ expect(markup).toContain('AI Commit Messages')
+ expect(markup).toContain('Enable AI commit messages')
+ expect(markup).toContain('aria-checked="false"')
+ expect(markup).not.toContain('Which agent drafts your commit messages')
+ expect(markup).not.toContain('Thinking effort')
+ })
+
+ it('renders model, thinking, and prompt controls for enabled preset agents', () => {
+ const markup = renderPane(
+ buildSettings({
+ commitMessageAi: {
+ enabled: true,
+ agentId: 'codex',
+ selectedModelByAgent: { codex: 'gpt-5.5' },
+ selectedThinkingByModel: { 'gpt-5.5': 'medium' },
+ customPrompt: 'Use Conventional Commits.',
+ customAgentCommand: ''
+ }
+ })
+ )
+
+ expect(markup).toContain('aria-checked="true"')
+ expect(markup).toContain('Which agent drafts your commit messages')
+ expect(markup).toContain('Model')
+ expect(markup).toContain('Thinking effort')
+ expect(markup).toContain('Higher effort produces more careful messages')
+ expect(markup).toContain('Use Conventional Commits.')
+ })
+
+ it('renders custom command settings for custom agents', () => {
+ const markup = renderPane(
+ buildSettings({
+ commitMessageAi: {
+ enabled: true,
+ agentId: 'custom',
+ selectedModelByAgent: {},
+ selectedThinkingByModel: {},
+ customPrompt: '',
+ customAgentCommand: 'ollama run llama3.1 {prompt}'
+ }
+ })
+ )
+
+ expect(markup).toContain('AI Commit Messages')
+ expect(markup).toContain('Custom command')
+ expect(markup).toContain('ollama run llama3.1 {prompt}')
+ })
+
+ it('keeps custom command discoverable in settings search metadata', () => {
+ const customCommandEntry = COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES.find(
+ (entry) => entry.title === 'Custom command'
+ )
+
+ expect(customCommandEntry?.keywords).toEqual(
+ expect.arrayContaining(['custom', 'command', 'ollama'])
+ )
+ })
+})
diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx
new file mode 100644
index 00000000000..ff9000b4162
--- /dev/null
+++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx
@@ -0,0 +1,480 @@
+/* eslint-disable max-lines -- Why: each agent setting (toggle, agent dropdown,
+ model dropdown, thinking effort dropdown, custom command, custom prompt) is
+ a SearchableSetting block, and splitting the pane across files would scatter
+ the ~6 conditional render branches without making any of them clearer. */
+import { useMemo } from 'react'
+import { Terminal } from 'lucide-react'
+import type { CommitMessageAiSettings, GlobalSettings, TuiAgent } from '../../../../shared/types'
+import {
+ CUSTOM_AGENT_ID,
+ DEFAULT_COMMIT_MESSAGE_AGENT_ID,
+ getCommitMessageAgentCapability,
+ isCustomAgentId,
+ listCommitMessageAgentCapabilities,
+ type CommitMessageAgentChoice,
+ type CommitMessageAgentCapability,
+ type CommitMessageModelCapability
+} from '../../../../shared/commit-message-agent-spec'
+import { CUSTOM_PROMPT_PLACEHOLDER } from '../../../../shared/commit-message-prompt'
+import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
+import { Label } from '../ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { useAppStore } from '../../store'
+import { SearchableSetting } from './SearchableSetting'
+import { matchesSettingsSearch } from './settings-search'
+
+type CommitMessageAiPaneProps = {
+ settings: GlobalSettings
+ updateSettings: (updates: Partial) => void
+}
+
+const EMPTY_SETTINGS: CommitMessageAiSettings = {
+ enabled: false,
+ agentId: null,
+ selectedModelByAgent: {},
+ selectedThinkingByModel: {},
+ customPrompt: '',
+ customAgentCommand: ''
+}
+
+function readSettings(settings: GlobalSettings): CommitMessageAiSettings {
+ return settings.commitMessageAi ?? EMPTY_SETTINGS
+}
+
+function agentLabel(agentId: TuiAgent, capability: CommitMessageAgentCapability): string {
+ return AGENT_CATALOG.find((a) => a.id === agentId)?.label ?? capability.label
+}
+
+function resolveSelectedModel(
+ config: CommitMessageAiSettings,
+ capability: CommitMessageAgentCapability
+): CommitMessageModelCapability {
+ const persisted = config.selectedModelByAgent[capability.id]
+ if (persisted) {
+ const found = capability.models.find((m) => m.id === persisted)
+ if (found) {
+ return found
+ }
+ }
+ // Why: defaultModelId is guaranteed to exist in provider capabilities by construction.
+ return capability.models.find((m) => m.id === capability.defaultModelId) ?? capability.models[0]
+}
+
+function resolveSelectedThinking(
+ config: CommitMessageAiSettings,
+ model: CommitMessageModelCapability
+): string | undefined {
+ if (!model.thinkingLevels) {
+ return undefined
+ }
+ const persisted = config.selectedThinkingByModel[model.id]
+ if (persisted && model.thinkingLevels.some((l) => l.id === persisted)) {
+ return persisted
+ }
+ return model.defaultThinkingLevel
+}
+
+export function CommitMessageAiPane({
+ settings,
+ updateSettings
+}: CommitMessageAiPaneProps): React.JSX.Element {
+ const searchQuery = useAppStore((s) => s.settingsSearchQuery)
+ const config = readSettings(settings)
+
+ const agentCapabilities = useMemo(listCommitMessageAgentCapabilities, [])
+ const activeAgentId: CommitMessageAgentChoice = config.agentId ?? DEFAULT_COMMIT_MESSAGE_AGENT_ID
+ const isCustom = isCustomAgentId(activeAgentId)
+ const activeCapability = isCustom ? undefined : getCommitMessageAgentCapability(activeAgentId)
+ const activeModel = activeCapability ? resolveSelectedModel(config, activeCapability) : null
+ const activeThinking = activeModel ? resolveSelectedThinking(config, activeModel) : undefined
+
+ const writeConfig = (patch: Partial): void => {
+ updateSettings({ commitMessageAi: { ...config, ...patch } })
+ }
+
+ const onToggleEnabled = (): void => {
+ const next = !config.enabled
+ if (!next) {
+ writeConfig({ enabled: false })
+ return
+ }
+ // Why: when the user enables the feature for the first time, hydrate the
+ // agent / model / thinking choices from provider capabilities so the
+ // Generate button works immediately without forcing them to pick first.
+ // If the user previously persisted 'custom', we keep that and let them
+ // re-edit the command — no implicit reset to a preset.
+ const seedAgentId: TuiAgent | 'custom' = config.agentId ?? DEFAULT_COMMIT_MESSAGE_AGENT_ID
+ const seedCapability = isCustomAgentId(seedAgentId)
+ ? undefined
+ : getCommitMessageAgentCapability(seedAgentId)
+ const seedModel = seedCapability ? resolveSelectedModel(config, seedCapability) : null
+ const seedThinking = seedModel ? resolveSelectedThinking(config, seedModel) : undefined
+
+ const nextSelectedModelByAgent = { ...config.selectedModelByAgent }
+ if (seedCapability && !nextSelectedModelByAgent[seedCapability.id]) {
+ nextSelectedModelByAgent[seedCapability.id] = seedCapability.defaultModelId
+ }
+ const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
+ if (seedModel && seedThinking && !nextSelectedThinkingByModel[seedModel.id]) {
+ nextSelectedThinkingByModel[seedModel.id] = seedThinking
+ }
+ writeConfig({
+ enabled: true,
+ agentId: seedAgentId,
+ selectedModelByAgent: nextSelectedModelByAgent,
+ selectedThinkingByModel: nextSelectedThinkingByModel
+ })
+ }
+
+ const onAgentChange = (newAgentId: string): void => {
+ if (isCustomAgentId(newAgentId)) {
+ writeConfig({ agentId: CUSTOM_AGENT_ID })
+ return
+ }
+ const capability = getCommitMessageAgentCapability(newAgentId as TuiAgent)
+ if (!capability) {
+ return
+ }
+ const nextSelectedModelByAgent = { ...config.selectedModelByAgent }
+ if (!nextSelectedModelByAgent[capability.id]) {
+ nextSelectedModelByAgent[capability.id] = capability.defaultModelId
+ }
+ const newModel = resolveSelectedModel({ ...config, agentId: capability.id }, capability)
+ const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
+ if (
+ newModel.thinkingLevels &&
+ newModel.defaultThinkingLevel &&
+ !nextSelectedThinkingByModel[newModel.id]
+ ) {
+ nextSelectedThinkingByModel[newModel.id] = newModel.defaultThinkingLevel
+ }
+ writeConfig({
+ agentId: capability.id,
+ selectedModelByAgent: nextSelectedModelByAgent,
+ selectedThinkingByModel: nextSelectedThinkingByModel
+ })
+ }
+
+ const onCustomCommandChange = (value: string): void => {
+ writeConfig({ customAgentCommand: value })
+ }
+
+ const onModelChange = (newModelId: string): void => {
+ if (!activeCapability) {
+ return
+ }
+ const model = activeCapability.models.find((m) => m.id === newModelId)
+ if (!model) {
+ return
+ }
+ const nextSelectedModelByAgent = {
+ ...config.selectedModelByAgent,
+ [activeCapability.id]: model.id
+ }
+ const nextSelectedThinkingByModel = { ...config.selectedThinkingByModel }
+ if (
+ model.thinkingLevels &&
+ model.defaultThinkingLevel &&
+ !nextSelectedThinkingByModel[model.id]
+ ) {
+ nextSelectedThinkingByModel[model.id] = model.defaultThinkingLevel
+ }
+ writeConfig({
+ selectedModelByAgent: nextSelectedModelByAgent,
+ selectedThinkingByModel: nextSelectedThinkingByModel
+ })
+ }
+
+ const onThinkingChange = (newLevelId: string): void => {
+ if (!activeModel) {
+ return
+ }
+ writeConfig({
+ selectedThinkingByModel: {
+ ...config.selectedThinkingByModel,
+ [activeModel.id]: newLevelId
+ }
+ })
+ }
+
+ const onCustomPromptChange = (value: string): void => {
+ writeConfig({ customPrompt: value })
+ }
+
+ const sections: React.ReactNode[] = []
+
+ if (
+ matchesSettingsSearch(searchQuery, {
+ title: 'Enable AI commit messages',
+ description: 'Adds a Generate button to the Source Control panel.',
+ keywords: ['ai', 'commit', 'message', 'generate', 'agent', 'enabled']
+ })
+ ) {
+ sections.push(
+
+
+
Enable AI commit messages
+
+ Adds a Generate button to the Source Control panel that drafts a commit message from
+ your staged changes. Runs the agent CLI locally (or on the SSH host when working
+ remotely) and waits for the response.
+
+
+
+
+
+
+ )
+ }
+
+ if (
+ config.enabled &&
+ matchesSettingsSearch(searchQuery, {
+ title: 'Agent',
+ description: 'Which agent to invoke when generating a commit message.',
+ keywords: ['agent', 'claude', 'codex']
+ })
+ ) {
+ sections.push(
+
+
+
Agent
+
+ Which agent drafts your commit messages. Orca invokes its CLI in the background, so the
+ agent must be installed on the machine that hosts the worktree - your computer for local
+ worktrees, or the SSH host for remote ones.
+
+
+
+
+
+
+
+ {agentCapabilities.map((capability) => {
+ const id = capability.id
+ return (
+
+
+
+ {agentLabel(id, capability)}
+
+
+ )
+ })}
+
+
+
+ Custom
+
+
+
+
+
+ )
+ }
+
+ if (
+ config.enabled &&
+ isCustom &&
+ matchesSettingsSearch(searchQuery, {
+ title: 'Custom command',
+ description: 'Command line Orca runs to generate the commit message.',
+ keywords: ['custom', 'command', 'cli', 'binary', 'prompt', 'placeholder']
+ })
+ ) {
+ sections.push(
+
+
+
Custom command
+
+ Use{' '}
+
+ {CUSTOM_PROMPT_PLACEHOLDER}
+ {' '}
+ where the prompt should be substituted (passed as a single argument). Omit it and the
+ prompt is piped via stdin instead - useful for CLIs like{' '}
+ claude -p. Quoting
+ is for grouping arguments only; we never invoke a shell, so{' '}
+ $VAR and backticks
+ are not expanded.
+
+
+ onCustomCommandChange(e.target.value)}
+ placeholder={`e.g. ollama run llama3.1 ${CUSTOM_PROMPT_PLACEHOLDER}`}
+ className="w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
+ />
+
+ )
+ }
+
+ if (
+ config.enabled &&
+ activeCapability &&
+ activeModel &&
+ matchesSettingsSearch(searchQuery, {
+ title: 'Model',
+ description: 'Which model the selected agent uses to generate the message.',
+ keywords: ['model', 'haiku', 'sonnet', 'opus', 'gpt']
+ })
+ ) {
+ sections.push(
+
+
+
Model
+
+ Smaller models default to lower latency and cost. Pick a larger one if the diffs you
+ review tend to need more reasoning.
+
+
+
+
+
+
+
+ {activeCapability.models.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+
+ )
+ }
+
+ if (
+ config.enabled &&
+ activeModel?.thinkingLevels &&
+ activeThinking &&
+ matchesSettingsSearch(searchQuery, {
+ title: 'Thinking effort',
+ description: 'Reasoning effort level for the selected model. Higher levels are slower.',
+ keywords: ['thinking', 'effort', 'reasoning']
+ })
+ ) {
+ sections.push(
+
+
+
Thinking effort
+
+ Higher effort produces more careful messages but takes longer and costs more tokens.
+
+
+
+
+
+
+
+ {activeModel.thinkingLevels.map((level) => (
+
+ {level.label}
+
+ ))}
+
+
+
+ )
+ }
+
+ if (
+ config.enabled &&
+ matchesSettingsSearch(searchQuery, {
+ title: 'Custom prompt',
+ description:
+ 'Optional instructions appended to the base prompt (e.g. Conventional Commits style).',
+ keywords: ['prompt', 'conventional commits', 'gitmoji', 'style']
+ })
+ ) {
+ sections.push(
+
+
+
Custom prompt
+
+ Appended verbatim to the base prompt. Use it to enforce Conventional Commits, gitmoji,
+ ticket prefixes, or any other style your team prefers.
+
+
+ onCustomPromptChange(e.target.value)}
+ placeholder="Use Conventional Commits format (feat:, fix:, ...). Reference the ticket key when present."
+ className="w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
+ />
+
+ )
+ }
+
+ if (sections.length === 0) {
+ return
+ }
+ // Why: this pane lives nested inside the Git section, so we draw an explicit
+ // sub-heading + top border to keep its toggles visually distinct from the
+ // Branch Prefix / Refresh Local Base Ref / Orca Attribution rows above.
+ return (
+
+
+
AI Commit Messages
+
+ Generate commit messages from staged changes using a local agent CLI.
+
+
+ {sections}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx
index 79bd613b37e..bf31a526a4b 100644
--- a/src/renderer/src/components/settings/ComputerUsePane.tsx
+++ b/src/renderer/src/components/settings/ComputerUsePane.tsx
@@ -67,20 +67,12 @@ function statusClass(status: ComputerUsePermissionStatus | undefined): string {
return 'border-border bg-muted text-muted-foreground'
}
-function isExpectedDevMissingHelperError(error: unknown): boolean {
- return (
- import.meta.env.DEV &&
- error instanceof Error &&
- error.message.includes('Orca Computer Use.app was not found')
- )
-}
-
export function ComputerUsePane(): React.JSX.Element {
const [platform, setPlatform] = useState(null)
const [states, setStates] = useState([])
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState(null)
- const [helperUnavailableInDev, setHelperUnavailableInDev] = useState(false)
+ const [helperUnavailableReason, setHelperUnavailableReason] = useState(null)
const stateById = useMemo(
() => new Map(states.map((state) => [state.id, state.status] as const)),
@@ -93,14 +85,8 @@ export function ComputerUsePane(): React.JSX.Element {
const result = await window.api.computerUsePermissions.getStatus()
setPlatform(result.platform)
setStates(result.permissions)
- setHelperUnavailableInDev(false)
+ setHelperUnavailableReason(result.helperUnavailableReason)
} catch (error) {
- if (isExpectedDevMissingHelperError(error)) {
- setPlatform('darwin')
- setStates(PERMISSIONS.map((permission) => ({ id: permission.id, status: 'not-granted' })))
- setHelperUnavailableInDev(true)
- return
- }
toast.error(
error instanceof Error ? error.message : 'Could not load Computer Use permissions'
)
@@ -166,10 +152,9 @@ export function ComputerUsePane(): React.JSX.Element {
Computer Use needs macOS privacy permissions before agents can inspect and operate
app windows.
- {helperUnavailableInDev ? (
+ {helperUnavailableReason ? (
- Computer Use permissions are unavailable in this dev build because the helper app
- has not been built.
+ Computer Use permissions are unavailable because {helperUnavailableReason}.
) : null}
@@ -208,7 +193,9 @@ export function ComputerUsePane(): React.JSX.Element {
void openPermission(permission.id)}
className="shrink-0 gap-1.5"
>
diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx
index c5a9ae65964..28ff5016db2 100644
--- a/src/renderer/src/components/settings/GeneralPane.tsx
+++ b/src/renderer/src/components/settings/GeneralPane.tsx
@@ -434,6 +434,38 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
/>
+
+
+
+
Markdown Review Notes
+
+ Show local markdown note controls, the review panel, and agent handoff actions.
+
+
+
+ updateSettings({
+ markdownReviewToolsEnabled: !settings.markdownReviewToolsEnabled
+ })
+ }
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
+ settings.markdownReviewToolsEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
+ }`}
+ >
+
+
+
) : null,
matchesSettingsSearch(searchQuery, GENERAL_CLI_SEARCH_ENTRIES) ? (
diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx
index 6810ef47c07..e7c4d924b76 100644
--- a/src/renderer/src/components/settings/GitPane.tsx
+++ b/src/renderer/src/components/settings/GitPane.tsx
@@ -5,6 +5,7 @@ import { useAppStore } from '../../store'
import { GIT_PANE_SEARCH_ENTRIES } from './git-search'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
+import { GitHubRateLimitPanel } from '../github/github-rate-limit-display'
export { GIT_PANE_SEARCH_ENTRIES }
@@ -110,6 +111,21 @@ export function GitPane({
) : null,
+ matchesSettingsSearch(searchQuery, {
+ title: 'GitHub API Budget',
+ description: 'Current GitHub CLI REST, Search, and GraphQL rate limits.',
+ keywords: ['github', 'gh', 'graphql', 'rate limit', 'api budget']
+ }) ? (
+
+
+
+ ) : null,
matchesSettingsSearch(searchQuery, {
title: 'Orca Attribution',
description: 'Add Orca attribution to commits, PRs, and issues.',
diff --git a/src/renderer/src/components/settings/IntegrationsPane.tsx b/src/renderer/src/components/settings/IntegrationsPane.tsx
index a238bc78618..9847c21163a 100644
--- a/src/renderer/src/components/settings/IntegrationsPane.tsx
+++ b/src/renderer/src/components/settings/IntegrationsPane.tsx
@@ -1,5 +1,5 @@
-/* eslint-disable max-lines -- Why: this pane co-locates GitHub, GitLab,
- and Linear integration cards so the preflight-check + status-badge +
+/* eslint-disable max-lines -- Why: this pane co-locates source-host and
+ Linear integration cards so the preflight-check + status-badge +
install/auth-prompt scaffolding lives in one place rather than fanning
out across per-integration files that would each repeat the same
pattern. Splitting buys nothing while the surface stays this narrow. */
@@ -53,6 +53,11 @@ export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description: 'Bitbucket Cloud authentication via API token environment variables.',
keywords: ['bitbucket', 'integration', 'pull request', 'api token']
},
+ {
+ title: 'Gitea Integration',
+ description: 'Gitea authentication via API token environment variables.',
+ keywords: ['gitea', 'self-hosted', 'integration', 'pull request', 'api token']
+ },
{
title: 'Linear Integration',
description: 'Connect Linear to browse and link issues.',
@@ -65,28 +70,52 @@ type GhStatus = 'checking' | 'connected' | 'not-installed' | 'not-authenticated'
// modes (probe in-flight / installed-but-unauth / missing entirely).
type GlabStatus = GhStatus
type BitbucketStatus = 'checking' | 'connected' | 'not-configured' | 'not-authenticated'
+type GiteaStatus = 'checking' | 'configured' | 'not-configured' | 'not-authenticated'
+
+type GiteaPreflightStatus = {
+ configured: boolean
+ authenticated: boolean
+ account: string | null
+ baseUrl: string | null
+ tokenConfigured: boolean
+}
+
+function giteaStatusFromPreflight(status: GiteaPreflightStatus | undefined): GiteaStatus {
+ if (!status?.configured) {
+ return 'not-configured'
+ }
+ if (status.tokenConfigured && !status.authenticated) {
+ return 'not-authenticated'
+ }
+ return 'configured'
+}
export function IntegrationsPane(): React.JSX.Element {
const linearStatus = useAppStore((s) => s.linearStatus)
const connectLinear = useAppStore((s) => s.connectLinear)
const disconnectLinear = useAppStore((s) => s.disconnectLinear)
+ const disconnectLinearWorkspace = useAppStore((s) => s.disconnectLinearWorkspace)
const checkLinearConnection = useAppStore((s) => s.checkLinearConnection)
const testLinearConnection = useAppStore((s) => s.testLinearConnection)
+ const linearWorkspaces = linearStatus.workspaces ?? []
const [ghStatus, setGhStatus] = useState
('checking')
const [glabStatus, setGlabStatus] = useState('checking')
const [bitbucketStatus, setBitbucketStatus] = useState('checking')
const [bitbucketAccount, setBitbucketAccount] = useState(null)
+ const [giteaStatus, setGiteaStatus] = useState('checking')
+ const [giteaAccount, setGiteaAccount] = useState(null)
+ const [giteaBaseUrl, setGiteaBaseUrl] = useState(null)
const [linearDialogOpen, setLinearDialogOpen] = useState(false)
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
'idle'
)
const [linearConnectError, setLinearConnectError] = useState(null)
- const [linearTestState, setLinearTestState] = useState<'idle' | 'testing' | 'ok' | 'error'>(
- 'idle'
- )
- const [linearTestError, setLinearTestError] = useState(null)
+ const [linearTestingWorkspaceId, setLinearTestingWorkspaceId] = useState(null)
+ const [linearTestResultByWorkspace, setLinearTestResultByWorkspace] = useState<
+ Record
+ >({})
useEffect(() => {
void checkLinearConnection()
@@ -118,6 +147,10 @@ export function IntegrationsPane(): React.JSX.Element {
} else {
setBitbucketStatus('connected')
}
+ const gitea = status.gitea
+ setGiteaAccount(gitea?.account ?? null)
+ setGiteaBaseUrl(gitea?.baseUrl ?? null)
+ setGiteaStatus(giteaStatusFromPreflight(gitea))
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount check
}, [])
@@ -134,6 +167,7 @@ export function IntegrationsPane(): React.JSX.Element {
setLinearApiKeyDraft('')
setLinearConnectState('idle')
setLinearDialogOpen(false)
+ setLinearTestResultByWorkspace({})
} else {
setLinearConnectState('error')
setLinearConnectError(result.error)
@@ -144,28 +178,37 @@ export function IntegrationsPane(): React.JSX.Element {
}
}
- const handleLinearDisconnect = async (): Promise => {
- await disconnectLinear()
+ const handleLinearDisconnect = async (workspaceId?: string): Promise => {
+ await (workspaceId ? disconnectLinearWorkspace(workspaceId) : disconnectLinear())
setLinearConnectState('idle')
setLinearConnectError(null)
- setLinearTestState('idle')
- setLinearTestError(null)
+ setLinearTestResultByWorkspace({})
}
// Why: explicit user-triggered verification. This is the *only* path in
// settings that decrypts the stored API key, so the macOS Keychain prompt
// (if the app signature has changed since the item was stored) only
// appears when the user clicks Test — not just for opening Settings.
- const handleLinearTest = async (): Promise => {
- setLinearTestState('testing')
- setLinearTestError(null)
- const result = await testLinearConnection()
+ const handleLinearTest = async (workspaceId: string): Promise => {
+ setLinearTestingWorkspaceId(workspaceId)
+ setLinearTestResultByWorkspace((prev) => {
+ const next = { ...prev }
+ delete next[workspaceId]
+ return next
+ })
+ const result = await testLinearConnection(workspaceId)
if (result.ok) {
- setLinearTestState('ok')
+ setLinearTestResultByWorkspace((prev) => ({
+ ...prev,
+ [workspaceId]: { state: 'ok' }
+ }))
} else {
- setLinearTestState('error')
- setLinearTestError(result.error)
+ setLinearTestResultByWorkspace((prev) => ({
+ ...prev,
+ [workspaceId]: { state: 'error', error: result.error }
+ }))
}
+ setLinearTestingWorkspaceId(null)
}
const handleRefreshGlab = (): void => {
@@ -210,6 +253,16 @@ export function IntegrationsPane(): React.JSX.Element {
})
}
+ const handleRefreshGitea = (): void => {
+ setGiteaStatus('checking')
+ void window.api.preflight.check({ force: true }).then((status) => {
+ const gitea = status.gitea
+ setGiteaAccount(gitea?.account ?? null)
+ setGiteaBaseUrl(gitea?.baseUrl ?? null)
+ setGiteaStatus(giteaStatusFromPreflight(gitea))
+ })
+ }
+
return (
{/* GitHub */}
@@ -449,6 +502,90 @@ export function IntegrationsPane(): React.JSX.Element {
)}
+ {/* Gitea */}
+
+
+
+
+
Gitea
+
+ {giteaStatus === 'configured'
+ ? giteaAccount
+ ? `${giteaAccount} · Pull requests and commit statuses`
+ : giteaBaseUrl
+ ? `${giteaBaseUrl} · Pull requests and commit statuses`
+ : 'Pull requests and commit statuses for detected repositories'
+ : 'Pull requests and commit statuses via the Gitea REST API.'}
+
+
+ {giteaStatus === 'checking' ? (
+
+ ) : giteaStatus === 'configured' ? (
+
+ {giteaAccount ? 'Connected' : 'Configured'}
+
+ ) : (
+
+ {giteaStatus === 'not-configured' ? 'Optional setup' : 'Auth failed'}
+
+ )}
+
+
+ {giteaStatus !== 'checking' && giteaStatus !== 'configured' && (
+
+ {giteaStatus === 'not-configured' ? (
+ <>
+
+ Public repositories are detected from their git remote. Set{' '}
+ ORCA_GITEA_TOKEN for private
+ repositories, and set{' '}
+ ORCA_GITEA_API_BASE_URL only when
+ Orca cannot derive the API URL from the remote.
+
+
+
+ window.api.shell.openUrl('https://docs.gitea.com/next/development/api-usage')
+ }
+ >
+
+ Learn more
+
+
+ Re-check
+
+
+ >
+ ) : (
+ <>
+
+ Gitea credentials are configured but could not authenticate. Check the token, API
+ base URL, and repository permissions, then restart Orca if environment variables
+ changed.
+
+
+
+ window.api.shell.openUrl('https://docs.gitea.com/next/development/api-usage')
+ }
+ >
+
+ Learn more
+
+
+ Re-check
+
+
+ >
+ )}
+
+ )}
+
+
{/* Linear */}
@@ -457,21 +594,15 @@ export function IntegrationsPane(): React.JSX.Element {
Linear
{linearStatus.connected
- ? linearStatus.viewer
- ? `${linearStatus.viewer.organizationName} · ${linearStatus.viewer.displayName}${linearStatus.viewer.email ? ` · ${linearStatus.viewer.email}` : ''}`
- : 'API key saved. Test to verify.'
+ ? `${linearWorkspaces.length} workspace${linearWorkspaces.length === 1 ? '' : 's'} connected`
: 'Browse and link issues to workspaces.'}
{linearStatus.connected ? (
-
-
-
+ setLinearDialogOpen(true)}>
+ Add workspace
+
Connected
@@ -487,39 +618,64 @@ export function IntegrationsPane(): React.JSX.Element {
{linearStatus.connected && (
-
-
void handleLinearTest()}
- disabled={linearTestState === 'testing'}
- >
- {linearTestState === 'testing' ? (
- <>
-
- Testing…
- >
- ) : (
- 'Test connection'
- )}
-
- {linearTestState === 'ok' && (
-
-
- Verified
-
- )}
- {linearTestState === 'error' && linearTestError && (
-
-
- {linearTestError}
-
- )}
- {linearTestState === 'idle' && (
-
- Verifies your API key against Linear.
-
- )}
+
+ {linearWorkspaces.map((workspace) => {
+ const testResult = linearTestResultByWorkspace[workspace.id]
+ const testing = linearTestingWorkspaceId === workspace.id
+ return (
+
+
+
+ {workspace.organizationName}
+
+
+ {workspace.displayName}
+ {workspace.email ? ` · ${workspace.email}` : ''}
+
+
+ {testResult?.state === 'ok' ? (
+
+
+ Verified
+
+ ) : null}
+ {testResult?.state === 'error' ? (
+
+
+ {testResult.error}
+
+ ) : null}
+
void handleLinearTest(workspace.id)}
+ disabled={testing}
+ >
+ {testing ? (
+ <>
+
+ Testing…
+ >
+ ) : (
+ 'Test'
+ )}
+
+
void handleLinearDisconnect(workspace.id)}
+ aria-label={`Disconnect ${workspace.organizationName}`}
+ className="rounded-md p-1 text-muted-foreground/50 transition-colors hover:text-destructive"
+ >
+
+
+
+ )
+ })}
+
+ Each workspace uses its own locally stored API key.
+
)}
@@ -547,10 +703,10 @@ export function IntegrationsPane(): React.JSX.Element {
}}
>
- Connect Linear
+ Connect Linear workspace
Paste a Personal API key to
- browse your assigned issues.
+ add a workspace to Orca.
diff --git a/src/renderer/src/components/settings/ManageSessionsSection.tsx b/src/renderer/src/components/settings/ManageSessionsSection.tsx
index 92148821647..8efeafa903d 100644
--- a/src/renderer/src/components/settings/ManageSessionsSection.tsx
+++ b/src/renderer/src/components/settings/ManageSessionsSection.tsx
@@ -96,6 +96,9 @@ function getConfirmCopy(confirm: PendingConfirm): {
}
export function ManageSessionsSection(): React.JSX.Element {
+ const activeRuntimeEnvironmentId = useAppStore(
+ (s) => s.settings?.activeRuntimeEnvironmentId ?? null
+ )
const [sessions, setSessions] = useState
([])
const [isRefreshing, setIsRefreshing] = useState(true)
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
@@ -176,6 +179,14 @@ export function ManageSessionsSection(): React.JSX.Element {
}, [])
const refresh = useCallback(async (): Promise => {
+ if (activeRuntimeEnvironmentId?.trim()) {
+ if (isMounted.current) {
+ setSessions([])
+ setIsRefreshing(false)
+ setHasLoadedOnce(true)
+ }
+ return []
+ }
setIsRefreshing(true)
try {
const result = await window.api.pty.management.listSessions()
@@ -198,7 +209,7 @@ export function ManageSessionsSection(): React.JSX.Element {
setHasLoadedOnce(true)
}
}
- }, [])
+ }, [activeRuntimeEnvironmentId])
useEffect(() => {
void refresh()
@@ -275,6 +286,29 @@ export function ManageSessionsSection(): React.JSX.Element {
const copy = useMemo(() => getConfirmCopy(confirm), [confirm])
const isBusy = busyKind !== null || daemonActions.isBusy
+ if (activeRuntimeEnvironmentId?.trim()) {
+ return (
+
+
+
Manage Sessions
+
+ Session management is unavailable while a remote runtime server is active.
+
+
+
+
+ Switch back to the local runtime to restart or kill local daemon sessions.
+
+
+
+ )
+ }
+
return (
diff --git a/src/renderer/src/components/settings/McpConfigFileRow.tsx b/src/renderer/src/components/settings/McpConfigFileRow.tsx
new file mode 100644
index 00000000000..7f640800eb1
--- /dev/null
+++ b/src/renderer/src/components/settings/McpConfigFileRow.tsx
@@ -0,0 +1,117 @@
+import { AlertCircle, CheckCircle2 } from 'lucide-react'
+import type { McpConfigInspection } from '../../../../shared/mcp-config'
+import { Button } from '../ui/button'
+
+export type LoadedMcpConfigInspection = McpConfigInspection & {
+ absolutePath: string
+ readError?: string
+}
+
+type McpConfigFileRowProps = {
+ config: LoadedMcpConfigInspection
+ onOpen: (config: LoadedMcpConfigInspection) => void
+}
+
+function statusLabel(config: LoadedMcpConfigInspection): string {
+ if (config.readError) {
+ return 'Unreadable'
+ }
+ if (config.status === 'missing') {
+ return 'Not found'
+ }
+ if (config.status === 'invalid') {
+ return 'Invalid JSON'
+ }
+ if (config.servers.length === 0) {
+ return 'No servers'
+ }
+ return `${config.servers.length} server${config.servers.length === 1 ? '' : 's'}`
+}
+
+function statusClassName(config: LoadedMcpConfigInspection): string {
+ if (config.readError || config.status === 'invalid') {
+ return 'border-destructive/30 bg-destructive/10 text-destructive'
+ }
+ if (config.status === 'valid' && config.servers.length > 0) {
+ return 'border-border/60 bg-background text-foreground'
+ }
+ return 'border-border/60 bg-muted/60 text-muted-foreground'
+}
+
+function serverDetailLabel(server: LoadedMcpConfigInspection['servers'][number]): string {
+ if (server.transport === 'http') {
+ return server.url ?? 'HTTP server'
+ }
+ if (server.transport === 'stdio') {
+ return server.command ?? 'stdio server'
+ }
+ return server.issue ?? 'Invalid server'
+}
+
+export function McpConfigFileRow({ config, onOpen }: McpConfigFileRowProps): React.JSX.Element {
+ return (
+
+
+ {config.status === 'valid' && !config.readError ? (
+
+ ) : (
+
+ )}
+
+
+
{config.candidate.label}
+
+ {config.candidate.relativePath}
+
+
+
+
+ {statusLabel(config)}
+
+ {config.exists ? (
+
onOpen(config)}>
+ Open
+
+ ) : null}
+
+
+ {config.error || config.readError ? (
+
{config.readError ?? config.error}
+ ) : null}
+
+ {config.servers.length > 0 ? (
+
+ {config.servers.map((server) => (
+
+
+
+ {server.name}
+
+ {server.transport}
+
+
+
+ {serverDetailLabel(server)}
+
+ {server.env && Object.keys(server.env).length > 0 ? (
+
+ env:{' '}
+ {Object.entries(server.env)
+ .map(([key, value]) => `${key}=${value}`)
+ .join(', ')}
+
+ ) : null}
+
+
{server.status}
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx
new file mode 100644
index 00000000000..6506e332dff
--- /dev/null
+++ b/src/renderer/src/components/settings/McpConfigSection.tsx
@@ -0,0 +1,364 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { AlertCircle, FileCode2, LoaderCircle, Plus, RefreshCw } from 'lucide-react'
+import { toast } from 'sonner'
+import type { Repo, Worktree } from '../../../../shared/types'
+import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
+import {
+ canInspectLocalMcpConfigRoot,
+ getMcpConfigCandidateParentDir,
+ getMcpConfigParentDirs,
+ inspectMcpConfigContent,
+ MCP_CONFIG_CANDIDATES,
+ MCP_STARTER_CONFIG,
+ selectExistingMcpConfigCandidates,
+ type McpConfigDirectoryEntry
+} from '../../../../shared/mcp-config'
+import { useAppStore } from '../../store'
+import { joinPath } from '../../lib/path'
+import { extractIpcErrorMessage } from '../../lib/ipc-error'
+import { Button } from '../ui/button'
+import { isWindowsUserAgent } from '../terminal-pane/pane-helpers'
+import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFileRow'
+
+type McpConfigSectionProps = {
+ repo: Repo
+}
+
+const EMPTY_WORKTREES: Worktree[] = []
+
+function isMissingFileError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error)
+ return /ENOENT|no such file|not found/i.test(message)
+}
+
+function countServers(configs: LoadedMcpConfigInspection[]): number {
+ return configs.reduce((sum, config) => sum + config.servers.length, 0)
+}
+
+export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Element {
+ const openFile = useAppStore((state) => state.openFile)
+ const setActiveView = useAppStore((state) => state.setActiveView)
+ const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
+ const ensureWorktreeRootGroup = useAppStore((state) => state.ensureWorktreeRootGroup)
+ const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
+ const worktreesForRepo = useAppStore((state) => state.worktreesByRepo[repo.id] ?? EMPTY_WORKTREES)
+ const sshConnectionStatus = useAppStore((state) =>
+ repo.connectionId ? state.sshConnectionStates.get(repo.connectionId)?.status : null
+ )
+ const [configs, setConfigs] = useState
([])
+ const [loading, setLoading] = useState(true)
+ const [createConfirm, setCreateConfirm] = useState(false)
+ const [inspectionUnavailableMessage, setInspectionUnavailableMessage] = useState(
+ null
+ )
+
+ const connectionId = repo.connectionId ?? undefined
+ const isWindows = isWindowsUserAgent()
+ const targetWorktree = useMemo(() => {
+ if (activeWorktreeId && getRepoIdFromWorktreeId(activeWorktreeId) === repo.id) {
+ return (
+ worktreesForRepo.find((worktree) => worktree.id === activeWorktreeId) ?? {
+ id: activeWorktreeId,
+ path: repo.path
+ }
+ )
+ }
+ return (
+ worktreesForRepo.find((worktree) => worktree.isMainWorktree) ??
+ worktreesForRepo.find((worktree) => worktree.path === repo.path) ??
+ worktreesForRepo[0] ?? { id: `${repo.id}::${repo.path}`, path: repo.path }
+ )
+ }, [activeWorktreeId, repo.id, repo.path, worktreesForRepo])
+ const targetWorktreeId = targetWorktree.id
+ const targetRootPath = targetWorktree.path
+ const detectedCount = useMemo(() => configs.filter((config) => config.exists).length, [configs])
+ const inspectionUnavailable = inspectionUnavailableMessage !== null
+ const visibleConfigs = useMemo(
+ () =>
+ inspectionUnavailable
+ ? []
+ : configs.filter(
+ (config) => config.exists || config.status === 'invalid' || config.readError
+ ),
+ [configs, inspectionUnavailable]
+ )
+ const missingConfigs = useMemo(
+ () =>
+ configs.filter(
+ (config) => !config.exists && config.status === 'missing' && !config.readError
+ ),
+ [configs]
+ )
+ const missingInspections = useMemo(
+ () =>
+ MCP_CONFIG_CANDIDATES.map(
+ (candidate): LoadedMcpConfigInspection => ({
+ ...inspectMcpConfigContent(candidate, null),
+ absolutePath: joinPath(targetRootPath, candidate.relativePath)
+ })
+ ),
+ [targetRootPath]
+ )
+ const serverCount = useMemo(() => countServers(configs), [configs])
+ const canCreateStarter = detectedCount === 0 && !inspectionUnavailable
+
+ const loadConfigs = useCallback(async (): Promise => {
+ setLoading(true)
+ setInspectionUnavailableMessage(null)
+
+ try {
+ if (connectionId && sshConnectionStatus !== 'connected') {
+ setConfigs(missingInspections)
+ setInspectionUnavailableMessage('Connect this SSH repo to inspect or add MCP configs.')
+ return
+ }
+
+ if (!connectionId && !canInspectLocalMcpConfigRoot(targetRootPath, isWindows)) {
+ setConfigs(missingInspections)
+ setInspectionUnavailableMessage('This workspace path is not available from this host.')
+ return
+ }
+
+ if (!connectionId && !(await window.api.shell.pathExists(targetRootPath))) {
+ setConfigs(missingInspections)
+ setInspectionUnavailableMessage('This workspace path is not available on disk.')
+ return
+ }
+
+ const entriesByRelativeDir = new Map()
+ const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId })
+ entriesByRelativeDir.set('', rootEntries)
+
+ const rootDirectoryNames = new Set(
+ rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name)
+ )
+ const unreadableParentDirMessages = new Map()
+ await Promise.all(
+ getMcpConfigParentDirs().map(async (relativeDir) => {
+ if (!rootDirectoryNames.has(relativeDir)) {
+ return
+ }
+ try {
+ const entries = await window.api.fs.readDir({
+ dirPath: joinPath(targetRootPath, relativeDir),
+ connectionId
+ })
+ entriesByRelativeDir.set(relativeDir, entries)
+ } catch (error) {
+ unreadableParentDirMessages.set(
+ relativeDir,
+ extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`)
+ )
+ }
+ })
+ )
+
+ const existingRelativePaths = new Set(
+ selectExistingMcpConfigCandidates(entriesByRelativeDir).map(
+ (candidate) => candidate.relativePath
+ )
+ )
+
+ const next = await Promise.all(
+ MCP_CONFIG_CANDIDATES.map(async (candidate): Promise => {
+ const absolutePath = joinPath(targetRootPath, candidate.relativePath)
+ const parentDirReadError = unreadableParentDirMessages.get(
+ getMcpConfigCandidateParentDir(candidate)
+ )
+ if (parentDirReadError) {
+ return {
+ ...inspectMcpConfigContent(candidate, null),
+ exists: false,
+ status: 'invalid',
+ absolutePath,
+ readError: parentDirReadError
+ }
+ }
+
+ if (!existingRelativePaths.has(candidate.relativePath)) {
+ return { ...inspectMcpConfigContent(candidate, null), absolutePath }
+ }
+
+ try {
+ const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId })
+ const inspection = inspectMcpConfigContent(
+ candidate,
+ result.isBinary ? '' : result.content
+ )
+ return { ...inspection, absolutePath }
+ } catch (error) {
+ if (isMissingFileError(error)) {
+ return { ...inspectMcpConfigContent(candidate, null), absolutePath }
+ }
+ return {
+ ...inspectMcpConfigContent(candidate, null),
+ exists: false,
+ status: 'invalid',
+ absolutePath,
+ readError: extractIpcErrorMessage(error, 'Unable to read config file.')
+ }
+ }
+ })
+ )
+ setConfigs(next)
+ } catch (error) {
+ setConfigs(missingInspections)
+ setInspectionUnavailableMessage(
+ extractIpcErrorMessage(error, 'Unable to inspect MCP configs.')
+ )
+ } finally {
+ setLoading(false)
+ }
+ }, [connectionId, isWindows, missingInspections, sshConnectionStatus, targetRootPath])
+
+ useEffect(() => {
+ void loadConfigs()
+ }, [loadConfigs])
+
+ const handleOpen = (config: LoadedMcpConfigInspection): void => {
+ setActiveWorktree(targetWorktreeId)
+ const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId)
+ openFile(
+ {
+ filePath: config.absolutePath,
+ relativePath: config.candidate.relativePath,
+ worktreeId: targetWorktreeId,
+ language: 'json',
+ mode: 'edit'
+ },
+ { targetGroupId }
+ )
+ setActiveView('terminal')
+ }
+
+ const handleCreateStarter = async (): Promise => {
+ if (!createConfirm) {
+ setCreateConfirm(true)
+ window.setTimeout(() => setCreateConfirm(false), 3000)
+ return
+ }
+
+ const target = joinPath(targetRootPath, '.mcp.json')
+ try {
+ // Why: v1 only creates the root workspace config so we do not need to
+ // guess per-agent directory layouts or mutate agent-specific files.
+ await window.api.fs.writeFile({ filePath: target, content: MCP_STARTER_CONFIG, connectionId })
+ setCreateConfirm(false)
+ await loadConfigs()
+ setActiveWorktree(targetWorktreeId)
+ const targetGroupId = ensureWorktreeRootGroup(targetWorktreeId)
+ openFile(
+ {
+ filePath: target,
+ relativePath: '.mcp.json',
+ worktreeId: targetWorktreeId,
+ language: 'json',
+ mode: 'edit'
+ },
+ { targetGroupId }
+ )
+ setActiveView('terminal')
+ toast.success('MCP config created', { description: '.mcp.json' })
+ } catch (error) {
+ toast.error(extractIpcErrorMessage(error, 'Failed to create MCP config.'))
+ }
+ }
+
+ return (
+
+
+
+
MCP Configs
+
+ Inspect MCP server definitions that agents can use while working in this repo.
+
+ {repo.connectionId ? (
+
+ SSH repos are read through the remote filesystem. Starter creation is limited to the
+ workspace root config.
+
+ ) : null}
+
+
+
void loadConfigs()}
+ aria-label="Refresh MCP configs"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ {canCreateStarter ? (
+
void handleCreateStarter()}
+ >
+
+ {createConfirm ? 'Create empty config' : 'Add MCP config'}
+
+ ) : null}
+
+
+
+
+
+
+ {detectedCount} detected · {serverCount} server{serverCount === 1 ? '' : 's'}
+
+ {loading ? : null}
+
+
+ {visibleConfigs.length === 0 ? (
+
+ {inspectionUnavailable ? (
+
+ ) : (
+
+ )}
+ {inspectionUnavailable ? (
+
{inspectionUnavailableMessage}
+ ) : (
+
+ No MCP config found. Add an empty workspace config when you want this repo to
+ define its own MCP servers.
+
+ )}
+
+ ) : (
+
+ {visibleConfigs.map((config) => (
+
+ ))}
+
+ )}
+
+ {missingConfigs.length > 0 && !inspectionUnavailable ? (
+
+
Checked
+
+ {missingConfigs.map((config) => (
+
+ {config.candidate.relativePath}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx
index fc5b9505638..6e6cbf23fc9 100644
--- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx
+++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx
@@ -5,6 +5,8 @@ import { AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '../ui/button'
import { SearchableSetting } from './SearchableSetting'
+import { useAppStore } from '@/store'
+import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
type RepositoryHooksSectionProps = {
repo: Repo
@@ -154,6 +156,7 @@ export function RepositoryHooksSection({
onClearLegacyHooks,
onUpdateSetupRunPolicy
}: RepositoryHooksSectionProps): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
// Why: distinguish "file has unrecognised top-level keys" from "file is
// genuinely malformed" so users see a helpful update prompt instead of a
// confusing parse-error when a newer Orca version adds keys to `orca.yaml`.
@@ -192,8 +195,7 @@ export function RepositoryHooksSection({
// Why: settings only edit the local override, but we still need to know
// whether `orca.yaml` defines a shared default so the helper copy can
// explain what happens when the override is blank.
- void window.api.hooks
- .readIssueCommand({ repoId })
+ void readRuntimeIssueCommand(settings, repoId)
.then((result) => {
if (cancelled) {
return
@@ -215,18 +217,18 @@ export function RepositoryHooksSection({
cancelled = true
const draft = issueCommandDraftRef.current.trim()
if (draft !== lastCommittedIssueCommandRef.current) {
- void window.api.hooks.writeIssueCommand({ repoId, content: draft }).catch((err) => {
+ void writeRuntimeIssueCommand(settings, repoId, draft).catch((err) => {
console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err)
})
}
}
- }, [repo.id])
+ }, [repo.id, settings])
const commitIssueCommand = useCallback(async (): Promise => {
const trimmed = issueCommandDraft.trim()
setIssueCommandDraft(trimmed)
try {
- await window.api.hooks.writeIssueCommand({ repoId: repo.id, content: trimmed })
+ await writeRuntimeIssueCommand(settings, repo.id, trimmed)
lastCommittedIssueCommandRef.current = trimmed
setIssueCommandSaveError(null)
} catch (err) {
@@ -235,7 +237,7 @@ export function RepositoryHooksSection({
setIssueCommandSaveError(message)
toast.error(message)
}
- }, [issueCommandDraft, repo.id])
+ }, [issueCommandDraft, repo.id, settings])
return (
diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx
index 8f335364bb0..297963ced99 100644
--- a/src/renderer/src/components/settings/RepositoryPane.tsx
+++ b/src/renderer/src/components/settings/RepositoryPane.tsx
@@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import { BaseRefPicker } from './BaseRefPicker'
import { RepositoryHooksSection } from './RepositoryHooksSection'
+import { McpConfigSection } from './McpConfigSection'
import { WorktreeSymlinksSection } from './WorktreeSymlinksSection'
import { SparsePresetSettingsSection } from './SparsePresetSettingsSection'
import { SearchableSetting } from './SearchableSetting'
@@ -83,6 +84,19 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
'node_modules'
]
},
+ {
+ title: 'MCP Configs',
+ description: 'Inspect repo-level MCP server config files.',
+ keywords: [
+ repo.displayName,
+ 'mcp',
+ 'model context protocol',
+ '.mcp.json',
+ '.cursor/mcp.json',
+ '.claude.json',
+ '.claude/mcp.json'
+ ]
+ },
{
title: 'orca.yaml hooks',
description: 'Shared setup and archive hook commands for this repository.',
@@ -206,6 +220,7 @@ export function RepositoryPane({
'Custom GitHub Issue Command'
].includes(entry.title)
)
+ const mcpEntries = allEntries.filter((entry) => entry.title === 'MCP Configs')
const symlinkEntries = allEntries.filter((entry) => entry.title === 'Worktree Symlinks')
const visibleSections = [
@@ -313,6 +328,9 @@ export function RepositoryPane({
!isFolder && matchesSettingsSearch(searchQuery, sparsePresetEntries) ? (
) : null,
+ !isFolder && matchesSettingsSearch(searchQuery, mcpEntries) ? (
+
+ ) : null,
!isFolder && matchesSettingsSearch(searchQuery, hooksEntries) ? (
Promise
+ canGeneratePairingUrl?: boolean
+ allowLocalRuntime?: boolean
+}
+
+export function RuntimeEnvironmentsPane({
+ settings,
+ switchRuntimeEnvironment,
+ canGeneratePairingUrl = true,
+ allowLocalRuntime = true
+}: RuntimeEnvironmentsPaneProps): React.JSX.Element {
+ const [environments, setEnvironments] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [switchingValue, setSwitchingValue] = useState(null)
+ const [removingId, setRemovingId] = useState(null)
+ const [pendingSwitchValue, setPendingSwitchValue] = useState(null)
+ const [pendingRemove, setPendingRemove] = useState(null)
+ const [addServerFormOpen, setAddServerFormOpen] = useState(false)
+ const [shareServerFormOpen, setShareServerFormOpen] = useState(false)
+ const [switchError, setSwitchError] = useState(null)
+ const [removeError, setRemoveError] = useState(null)
+ const [name, setName] = useState('')
+ const [pairingCode, setPairingCode] = useState('')
+ const activeValue =
+ settings.activeRuntimeEnvironmentId ??
+ (allowLocalRuntime ? LOCAL_RUNTIME_VALUE : NO_RUNTIME_VALUE)
+ const isBusy = isSaving || switchingValue !== null || removingId !== null
+ const removingActiveServer = pendingRemove?.id === settings.activeRuntimeEnvironmentId
+ const searchEntry = canGeneratePairingUrl
+ ? RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
+ : WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
+
+ const loadEnvironments = async (): Promise => {
+ setIsLoading(true)
+ try {
+ setEnvironments(await window.api.runtimeEnvironments.list())
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to load runtime environments.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ void loadEnvironments()
+ }, [])
+
+ const closeAddServerForm = (): void => {
+ if (isSaving) {
+ return
+ }
+ setAddServerFormOpen(false)
+ setName('')
+ setPairingCode('')
+ }
+
+ const addEnvironment = async (): Promise => {
+ const trimmedName = name.trim()
+ const trimmedPairingCode = pairingCode.trim()
+ if (!trimmedName || !trimmedPairingCode) {
+ toast.error('Name and pairing code are required.')
+ return
+ }
+ const duplicate = environments.find(
+ (environment) => environment.name.trim().toLowerCase() === trimmedName.toLowerCase()
+ )
+ if (duplicate) {
+ toast.error(`A server named "${duplicate.name}" already exists.`)
+ return
+ }
+ setIsSaving(true)
+ try {
+ if (!allowLocalRuntime && settings.activeRuntimeEnvironmentId) {
+ const disconnected = await switchRuntimeEnvironment(null)
+ if (!disconnected) {
+ return
+ }
+ }
+ const result = await window.api.runtimeEnvironments.addFromPairingCode({
+ name: trimmedName,
+ pairingCode: trimmedPairingCode
+ })
+ setName('')
+ setPairingCode('')
+ await loadEnvironments()
+ if (!allowLocalRuntime) {
+ const switched = await switchRuntimeEnvironment(result.environment.id)
+ if (!switched) {
+ await window.api.runtimeEnvironments.remove({ selector: result.environment.id })
+ await loadEnvironments()
+ return
+ }
+ toast.success(`Connected to ${result.environment.name}.`)
+ } else {
+ toast.success(`Saved ${result.environment.name}. Use Active Server to switch when ready.`)
+ }
+ setAddServerFormOpen(false)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to save runtime environment.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const removeEnvironment = async (
+ environment: PublicKnownRuntimeEnvironment
+ ): Promise => {
+ setRemovingId(environment.id)
+ setRemoveError(null)
+ try {
+ if (settings.activeRuntimeEnvironmentId === environment.id) {
+ const switched = await switchRuntimeEnvironment(null)
+ if (!switched) {
+ setRemoveError(
+ allowLocalRuntime
+ ? 'Could not switch to Local desktop. Fix the issue and try again.'
+ : 'Could not disconnect from this server. Fix the issue and try again.'
+ )
+ return false
+ }
+ if (!allowLocalRuntime) {
+ await loadEnvironments()
+ toast.success(`Removed ${environment.name}.`)
+ return true
+ }
+ }
+ await window.api.runtimeEnvironments.remove({ selector: environment.id })
+ await loadEnvironments()
+ toast.success(`Removed ${environment.name}.`)
+ return true
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : 'Failed to remove runtime environment.'
+ setRemoveError(message)
+ toast.error(message)
+ return false
+ } finally {
+ setRemovingId(null)
+ }
+ }
+
+ const switchToValue = async (value: string): Promise => {
+ if (value === NO_RUNTIME_VALUE) {
+ return false
+ }
+ setSwitchingValue(value)
+ setSwitchError(null)
+ try {
+ const switched = await switchRuntimeEnvironment(
+ allowLocalRuntime && value === LOCAL_RUNTIME_VALUE ? null : value
+ )
+ if (switched) {
+ toast.success(`Switched to ${getEnvironmentLabel(value)}.`)
+ return true
+ }
+ setSwitchError('Could not switch servers. Fix the issue and try again.')
+ return false
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to switch servers.'
+ setSwitchError(message)
+ toast.error(message)
+ return false
+ } finally {
+ setSwitchingValue(null)
+ }
+ }
+
+ const getEnvironmentLabel = (value: string): string => {
+ if (value === LOCAL_RUNTIME_VALUE) {
+ return 'Local desktop'
+ }
+ if (value === NO_RUNTIME_VALUE) {
+ return 'No server connected'
+ }
+ return environments.find((environment) => environment.id === value)?.name ?? 'remote server'
+ }
+
+ return (
+
+
+
+
Active Server
+
+ {allowLocalRuntime
+ ? "Local keeps today's desktop behavior. Saved servers route supported client calls through the remote runtime."
+ : 'Saved servers route this browser through a paired Orca runtime.'}
+
+
+
+ {
+ if (value !== activeValue) {
+ setSwitchError(null)
+ setPendingSwitchValue(value)
+ }
+ }}
+ disabled={isBusy}
+ >
+
+
+
+
+ {allowLocalRuntime ? (
+ Local desktop
+ ) : environments.length === 0 ? (
+
+ No server connected
+
+ ) : null}
+ {environments.map((environment) => (
+
+ {environment.name}
+
+ ))}
+
+
+ void loadEnvironments()}
+ disabled={isLoading || isBusy}
+ >
+ {isLoading ? : }
+
+
+
+
+
+
+
Saved Servers
+ {addServerFormOpen ? null : (
+
setAddServerFormOpen(true)}
+ disabled={isBusy}
+ >
+
+ Add Server
+
+ )}
+
+
+ {addServerFormOpen ? (
+
{
+ event.preventDefault()
+ void addEnvironment()
+ }}
+ >
+
+
+ Server name
+ setName(event.target.value)}
+ placeholder="Dev box"
+ className="h-8 text-xs"
+ autoFocus
+ />
+
+
+
Pairing code
+
setPairingCode(event.target.value)}
+ placeholder="orca://pair#..."
+ className="h-8 min-w-0 font-mono text-xs"
+ />
+
+ Run orca serve --pairing-address <host> {' '}
+ on the server and paste the printed pairing URL.
+
+
+
+
+
+ Cancel
+
+
+ {isSaving ? : }
+ Add Server
+
+
+
+ ) : null}
+
+
+ {environments.length === 0 ? (
+
No saved servers.
+ ) : (
+
+ {environments.map((environment) => (
+
+
+
{environment.name}
+
+ {environment.endpoints[0]?.endpoint ?? 'No endpoint'}
+
+
+
{
+ setRemoveError(null)
+ setPendingRemove(environment)
+ }}
+ disabled={isBusy}
+ aria-label={`Remove ${environment.name}`}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+ {canGeneratePairingUrl ? (
+
+
+
+
Share this desktop
+
+ Generate a pairing URL for the web client or another Orca client.
+
+
+
setShareServerFormOpen((open) => !open)}
+ >
+
+ {shareServerFormOpen ? 'Hide' : 'Generate Link'}
+
+
+ {shareServerFormOpen ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {
+ if (!open && switchingValue === null) {
+ setSwitchError(null)
+ setPendingSwitchValue(null)
+ }
+ }}
+ >
+
+
+ Switch Server
+
+ Orca will close remote terminals and browser tabs from the current server before
+ loading projects from the next server.
+
+
+ {pendingSwitchValue ? (
+
+
Switch to
+
+ {getEnvironmentLabel(pendingSwitchValue)}
+
+
+ ) : null}
+ {switchError ? {switchError}
: null}
+
+ {
+ setSwitchError(null)
+ setPendingSwitchValue(null)
+ }}
+ disabled={switchingValue !== null}
+ >
+ Cancel
+
+ {
+ const value = pendingSwitchValue
+ if (!value) {
+ return
+ }
+ void switchToValue(value).then((switched) => {
+ if (switched) {
+ setPendingSwitchValue(null)
+ }
+ })
+ }}
+ disabled={switchingValue !== null}
+ >
+ {switchingValue !== null ? : null}
+ Switch
+
+
+
+
+
+ {
+ if (!open && removingId === null) {
+ setRemoveError(null)
+ setPendingRemove(null)
+ }
+ }}
+ >
+
+
+ Remove Server
+
+ {removingActiveServer
+ ? allowLocalRuntime
+ ? 'Removing the active server first switches Orca back to Local desktop and closes remote terminals and browser tabs for that server.'
+ : 'Removing the active server disconnects this browser and closes remote terminals and browser tabs for that server.'
+ : 'This removes the saved server from Orca. It does not change the active server.'}
+
+
+ {pendingRemove ? (
+
+
{pendingRemove.name}
+
+ {pendingRemove.endpoints[0]?.endpoint ?? 'No endpoint'}
+
+
+ ) : null}
+ {removeError ? {removeError}
: null}
+
+ {
+ setRemoveError(null)
+ setPendingRemove(null)
+ }}
+ disabled={removingId !== null}
+ >
+ Cancel
+
+ {
+ const environment = pendingRemove
+ if (!environment) {
+ return
+ }
+ void removeEnvironment(environment).then((removed) => {
+ if (removed) {
+ setPendingRemove(null)
+ }
+ })
+ }}
+ disabled={removingId !== null}
+ >
+ {removingId !== null ? : }
+ Remove
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx
new file mode 100644
index 00000000000..5b96398a938
--- /dev/null
+++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx
@@ -0,0 +1,239 @@
+import { Check, Copy, Loader2, RefreshCw } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Label } from '../ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+
+const LOOPBACK_ADDRESS = '127.0.0.1'
+
+// Why: runtime pairing tokens stay valid in the main-process registry; keep the
+// last displayed URL across settings collapse/navigation without less-protected storage.
+const runtimePairingUrlCache: {
+ selectedAddress: string
+ customAddress: string
+ runtimePairingUrl: string | null
+ webClientUrl: string | null
+} = {
+ selectedAddress: LOOPBACK_ADDRESS,
+ customAddress: '',
+ runtimePairingUrl: null,
+ webClientUrl: null
+}
+
+function GeneratedUrlRow({
+ label,
+ value,
+ copied,
+ onCopy
+}: {
+ label: string
+ value: string
+ copied: boolean
+ onCopy: () => void
+}): React.JSX.Element {
+ return (
+
+
{label}
+
+
+ {value}
+
+
+ {copied ? : }
+
+
+
+ )
+}
+
+type RuntimePairingUrlGeneratorProps = {
+ framed?: boolean
+ showHeader?: boolean
+}
+
+export function RuntimePairingUrlGenerator({
+ framed = true,
+ showHeader = true
+}: RuntimePairingUrlGeneratorProps): React.JSX.Element {
+ const [networkInterfaces, setNetworkInterfaces] = useState<{ name: string; address: string }[]>(
+ []
+ )
+ const [selectedAddress, setSelectedAddress] = useState(runtimePairingUrlCache.selectedAddress)
+ const [customAddress, setCustomAddress] = useState(runtimePairingUrlCache.customAddress)
+ const [runtimePairingUrl, setRuntimePairingUrl] = useState(
+ runtimePairingUrlCache.runtimePairingUrl
+ )
+ const [webClientUrl, setWebClientUrl] = useState(
+ runtimePairingUrlCache.webClientUrl
+ )
+ const [copiedTarget, setCopiedTarget] = useState<'web' | 'pairing' | null>(null)
+ const [isGeneratingPairing, setIsGeneratingPairing] = useState(false)
+
+ useEffect(() => {
+ let stale = false
+
+ const loadNetworkInterfaces = async (): Promise => {
+ try {
+ const result = await window.api.mobile.listNetworkInterfaces()
+ if (!stale) {
+ setNetworkInterfaces(result.interfaces)
+ }
+ } catch {
+ // Keep the loopback option available even if interface enumeration fails.
+ }
+ }
+
+ void loadNetworkInterfaces()
+
+ return () => {
+ stale = true
+ }
+ }, [])
+
+ const generateRuntimePairingUrl = async (): Promise => {
+ setIsGeneratingPairing(true)
+ try {
+ const advertiseAddress = customAddress.trim() || selectedAddress
+ const result = await window.api.mobile.getRuntimePairingUrl({
+ address: advertiseAddress,
+ rotate: true
+ })
+ if (!result.available) {
+ runtimePairingUrlCache.runtimePairingUrl = null
+ runtimePairingUrlCache.webClientUrl = null
+ setRuntimePairingUrl(null)
+ setWebClientUrl(null)
+ toast.error('Runtime pairing is unavailable.')
+ return
+ }
+ runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl
+ runtimePairingUrlCache.webClientUrl = result.webClientUrl
+ setRuntimePairingUrl(result.pairingUrl)
+ setWebClientUrl(result.webClientUrl)
+ toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.')
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.')
+ } finally {
+ setIsGeneratingPairing(false)
+ }
+ }
+
+ const copyGeneratedUrl = async (target: 'web' | 'pairing', value: string): Promise => {
+ try {
+ await window.api.ui.writeClipboardText(value)
+ setCopiedTarget(target)
+ toast.success(target === 'web' ? 'Copied web client URL.' : 'Copied pairing URL.')
+ window.setTimeout(() => {
+ setCopiedTarget((current) => (current === target ? null : current))
+ }, 1400)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to copy URL.')
+ }
+ }
+
+ const containerClassName = framed
+ ? 'space-y-3 rounded-lg border border-border/50 bg-muted/25 p-3'
+ : 'space-y-4'
+
+ const updateSelectedAddress = (address: string): void => {
+ runtimePairingUrlCache.selectedAddress = address
+ setSelectedAddress(address)
+ }
+
+ const updateCustomAddress = (address: string): void => {
+ runtimePairingUrlCache.customAddress = address
+ setCustomAddress(address)
+ }
+
+ return (
+
+ {showHeader ? (
+
+
Share this desktop
+
+ Generate a runtime pairing URL for the web client or another Orca client.
+
+
+ ) : null}
+
+
+
+
+ Advertise address
+
+
+
+
+
+
+ This computer ({LOOPBACK_ADDRESS})
+ {networkInterfaces.map((networkInterface, index) => (
+
+ {networkInterface.name} ({networkInterface.address})
+
+ ))}
+
+
+
+
+ Custom address
+ updateCustomAddress(event.target.value)}
+ placeholder="host, host:port, or wss://host/path"
+ className="h-8 font-mono text-xs"
+ />
+
+
+
+ void generateRuntimePairingUrl()}
+ disabled={isGeneratingPairing}
+ >
+ {isGeneratingPairing ? : }
+ Generate
+
+
+
+
+ {webClientUrl ? (
+
void copyGeneratedUrl('web', webClientUrl)}
+ />
+ ) : null}
+
+ {runtimePairingUrl ? (
+ void copyGeneratedUrl('pairing', runtimePairingUrl)}
+ />
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx
index 4932313f9bf..2938353c754 100644
--- a/src/renderer/src/components/settings/Settings.tsx
+++ b/src/renderer/src/components/settings/Settings.tsx
@@ -4,11 +4,13 @@ import {
BarChart3,
Bell,
Bot,
+ Cable,
FlaskConical,
GitBranch,
Globe,
Info,
Keyboard,
+ ListChecks,
Lock,
MousePointerClick,
Network,
@@ -42,6 +44,8 @@ import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane'
import { getTerminalPaneSearchEntries } from './terminal-search'
import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane'
+import { CommitMessageAiPane } from './CommitMessageAiPane'
+import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
import { NotificationsPane, NOTIFICATIONS_PANE_SEARCH_ENTRIES } from './NotificationsPane'
import { VoicePane } from './VoicePane'
import { VOICE_PANE_SEARCH_ENTRIES } from './voice-pane-search'
@@ -53,17 +57,25 @@ import { ORCHESTRATION_PANE_SEARCH_ENTRIES } from './orchestration-search'
import { AccountsPane, ACCOUNTS_PANE_SEARCH_ENTRIES } from './AccountsPane'
import { StatsPane, STATS_PANE_SEARCH_ENTRIES } from '../stats/StatsPane'
import { IntegrationsPane, INTEGRATIONS_PANE_SEARCH_ENTRIES } from './IntegrationsPane'
+import { TasksPane } from './TasksPane'
+import { TASKS_PANE_SEARCH_ENTRIES } from './tasks-search'
import {
DeveloperPermissionsPane,
DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES
} from './DeveloperPermissionsPane'
import { ComputerUsePane, COMPUTER_USE_PANE_SEARCH_ENTRIES } from './ComputerUsePane'
import { MobileSettingsPane, MOBILE_SETTINGS_PANE_SEARCH_ENTRIES } from './MobileSettingsPane'
+import { RuntimeEnvironmentsPane } from './RuntimeEnvironmentsPane'
+import {
+ RUNTIME_ENVIRONMENTS_SEARCH_ENTRY,
+ WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
+} from './runtime-environments-search'
import { PrivacyPane } from './PrivacyPane'
import { PRIVACY_PANE_SEARCH_ENTRIES } from './privacy-search'
import { SettingsSidebar } from './SettingsSidebar'
import { SettingsSection } from './SettingsSection'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
+import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
type SettingsNavTarget =
| 'general'
@@ -71,6 +83,7 @@ type SettingsNavTarget =
| 'accounts'
| 'browser'
| 'git'
+ | 'tasks'
| 'appearance'
| 'terminal'
| 'notifications'
@@ -84,6 +97,7 @@ type SettingsNavTarget =
| 'experimental'
| 'agents'
| 'orchestration'
+ | 'servers'
| 'mobile'
| 'repo'
@@ -170,9 +184,17 @@ function isEditableTarget(target: EventTarget | null): boolean {
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
}
+function isWebClientLocation(): boolean {
+ return (
+ Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
+ window.location.pathname.endsWith('/web-index.html')
+ )
+}
+
function Settings(): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
+ const switchRuntimeEnvironment = useAppStore((s) => s.switchRuntimeEnvironment)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const closeSettingsPage = useAppStore((s) => s.closeSettingsPage)
const repos = useAppStore((s) => s.repos)
@@ -189,6 +211,8 @@ function Settings(): React.JSX.Element {
const systemPrefersDark = useSystemPrefersDark()
const isWindows = isWindowsUserAgent()
const isMac = isMacUserAgent()
+ const isWebClient = isWebClientLocation()
+ const showDesktopOnlySettings = !isWebClient
const showComputerUsePreviewTooltip = !isMac
const computerUsePlatform = computerUsePlatformLabel({ isWindows, isMac })
// Why: the Terminal settings section shares one search index with the
@@ -220,6 +244,7 @@ function Settings(): React.JSX.Element {
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
)
const [activeSectionId, setActiveSectionId] = useState('general')
+ const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0)
// Why: the hidden-experimental group is an unlock — Shift-clicking the
// Experimental sidebar entry reveals it for the remainder of the session.
// Not persisted on purpose: it's a power-user affordance we don't want to
@@ -355,7 +380,7 @@ function Settings(): React.JSX.Element {
return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const
}
try {
- const result = await window.api.hooks.check({ repoId: repo.id })
+ const result = await checkRuntimeHooks(settings, repo.id)
return [repo.id, result] as const
} catch {
return [repo.id, { hasHooks: false, hooks: null, mayNeedUpdate: false }] as const
@@ -382,13 +407,16 @@ function Settings(): React.JSX.Element {
return () => {
stale = true
}
- }, [repos])
+ }, [repos, settings])
const applyTheme = useCallback((theme: 'system' | 'dark' | 'light') => {
applyDocumentTheme(theme)
}, [])
const displayedGitUsername = repos[0]?.gitUsername ?? ''
+ const runtimeEnvironmentsSearchEntry = isWebClient
+ ? WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
+ : RUNTIME_ENVIRONMENTS_SEARCH_ENTRY
const navSections = useMemo(
() => [
@@ -416,9 +444,19 @@ function Settings(): React.JSX.Element {
{
id: 'git',
title: 'Git',
- description: 'Branch naming and local ref behavior.',
+ description: 'Branch naming, local ref behavior, and AI commit messages.',
icon: GitBranch,
- searchEntries: GIT_PANE_SEARCH_ENTRIES
+ // Why: the AI commit messages pane is rendered inside the Git section,
+ // so its search entries belong to Git too — that way a query like
+ // "claude" or "thinking" still surfaces the section.
+ searchEntries: [...GIT_PANE_SEARCH_ENTRIES, ...COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES]
+ },
+ {
+ id: 'tasks',
+ title: 'Tasks',
+ description: 'Choose which task providers appear in the Tasks page and sidebar.',
+ icon: ListChecks,
+ searchEntries: TASKS_PANE_SEARCH_ENTRIES
},
{
id: 'appearance',
@@ -434,20 +472,24 @@ function Settings(): React.JSX.Element {
icon: SquareTerminal,
searchEntries: terminalPaneSearchEntries
},
- {
- id: 'browser',
- title: 'Browser',
- description: 'Home page, link routing, and session cookies.',
- icon: Globe,
- searchEntries: BROWSER_PANE_SEARCH_ENTRIES
- },
- {
- id: 'notifications',
- title: 'Notifications',
- description: 'Native desktop notifications for agent and terminal events.',
- icon: Bell,
- searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES
- },
+ ...(showDesktopOnlySettings
+ ? [
+ {
+ id: 'browser' as const,
+ title: 'Browser',
+ description: 'Home page, link routing, and session cookies.',
+ icon: Globe,
+ searchEntries: BROWSER_PANE_SEARCH_ENTRIES
+ },
+ {
+ id: 'notifications' as const,
+ title: 'Notifications',
+ description: 'Native desktop notifications for agent and terminal events.',
+ icon: Bell,
+ searchEntries: NOTIFICATIONS_PANE_SEARCH_ENTRIES
+ }
+ ]
+ : []),
{
id: 'orchestration',
title: 'Orchestration',
@@ -456,30 +498,44 @@ function Settings(): React.JSX.Element {
searchEntries: ORCHESTRATION_PANE_SEARCH_ENTRIES
},
{
- id: 'mobile',
- title: 'Mobile',
- description: 'Control terminals and agents from your phone.',
- icon: Smartphone,
- searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES,
+ id: 'servers',
+ title: 'Servers',
+ description: isWebClient
+ ? 'Connect this browser to a saved Orca server.'
+ : 'Run this client locally or through a remote Orca server.',
+ icon: Server,
+ searchEntries: [runtimeEnvironmentsSearchEntry],
badge: 'Beta'
},
- {
- id: 'computer-use',
- title: 'Computer Use',
- description: 'Enable agents to control any app on your computer.',
- icon: MousePointerClick,
- searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES,
- badge: 'Beta'
- },
- {
- id: 'voice',
- title: 'Voice',
- description: 'Local speech-to-text dictation with on-device models.',
- icon: Mic,
- searchEntries: VOICE_PANE_SEARCH_ENTRIES,
- badge: 'Beta'
- },
- ...(isMac
+ ...(showDesktopOnlySettings
+ ? [
+ {
+ id: 'mobile' as const,
+ title: 'Mobile',
+ description: 'Control terminals and agents from your phone.',
+ icon: Smartphone,
+ searchEntries: MOBILE_SETTINGS_PANE_SEARCH_ENTRIES,
+ badge: 'Beta'
+ },
+ {
+ id: 'computer-use' as const,
+ title: 'Computer Use',
+ description: 'Enable agents to control any app on your computer.',
+ icon: MousePointerClick,
+ searchEntries: COMPUTER_USE_PANE_SEARCH_ENTRIES,
+ badge: 'Beta'
+ },
+ {
+ id: 'voice' as const,
+ title: 'Voice',
+ description: 'Local speech-to-text dictation with on-device models.',
+ icon: Mic,
+ searchEntries: VOICE_PANE_SEARCH_ENTRIES,
+ badge: 'Beta'
+ }
+ ]
+ : []),
+ ...(showDesktopOnlySettings && isMac
? [
{
id: 'developer-permissions' as const,
@@ -514,17 +570,21 @@ function Settings(): React.JSX.Element {
{
id: 'stats',
title: 'Stats & Usage',
- description: 'Orca stats and Claude usage analytics.',
+ description: 'Orca stats plus Claude and Codex usage analytics.',
icon: BarChart3,
searchEntries: STATS_PANE_SEARCH_ENTRIES
},
- {
- id: 'ssh',
- title: 'SSH',
- description: 'Remote SSH connections.',
- icon: Server,
- searchEntries: SSH_PANE_SEARCH_ENTRIES
- },
+ ...(showDesktopOnlySettings
+ ? [
+ {
+ id: 'ssh' as const,
+ title: 'SSH',
+ description: 'Remote SSH connections.',
+ icon: Cable,
+ searchEntries: SSH_PANE_SEARCH_ENTRIES
+ }
+ ]
+ : []),
{
id: 'experimental',
title: 'Experimental',
@@ -540,7 +600,14 @@ function Settings(): React.JSX.Element {
searchEntries: getRepositoryPaneSearchEntries(repo)
}))
],
- [isMac, repos, terminalPaneSearchEntries]
+ [
+ isMac,
+ isWebClient,
+ repos,
+ runtimeEnvironmentsSearchEntry,
+ showDesktopOnlySettings,
+ terminalPaneSearchEntries
+ ]
)
const visibleNavSections = useMemo(
@@ -573,7 +640,13 @@ function Settings(): React.JSX.Element {
if (!visibleIds.has(activeSectionId) && visibleNavSections.length > 0) {
setActiveSectionId(getFallbackVisibleSection(visibleNavSections)?.id ?? activeSectionId)
}
- }, [activeSectionId, setSettingsSearchQuery, settingsSearchQuery, visibleNavSections])
+ }, [
+ activeSectionId,
+ pendingNavRequestTick,
+ setSettingsSearchQuery,
+ settingsSearchQuery,
+ visibleNavSections
+ ])
useEffect(() => {
const container = contentScrollRef.current
@@ -665,6 +738,18 @@ function Settings(): React.JSX.Element {
[]
)
+ const openComputerUseFromBrowser = useCallback(() => {
+ pendingNavSectionRef.current = 'computer-use'
+ pendingScrollTargetRef.current = 'computer-use'
+ if (settingsSearchQuery !== '') {
+ setSettingsSearchQuery('')
+ return
+ }
+ // Why: the pending section refs do not schedule a render by themselves.
+ // When search is already clear, this reruns the centralized jump effect.
+ setPendingNavRequestTick((tick) => tick + 1)
+ }, [setSettingsSearchQuery, settingsSearchQuery])
+
if (!settings) {
return (
@@ -743,14 +828,27 @@ function Settings(): React.JSX.Element {
+
+
+
+
+
-
-
-
+ {showDesktopOnlySettings ? (
+ <>
+
+
+
-
-
-
+
+
+
+ >
+ ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
- {computerUsePlatform} Computer Use is an early preview. Some apps and
- desktop environments may behave inconsistently.
-
-
-
-
- ) : null
+ description={
+ isWebClient
+ ? 'Connect this browser to a saved Orca server.'
+ : 'Run this desktop client locally or through a remote Orca server.'
}
- description="Enable agents to control any app on your computer."
- searchEntries={COMPUTER_USE_PANE_SEARCH_ENTRIES}
+ searchEntries={[runtimeEnvironmentsSearchEntry]}
>
-
+
-
-
-
+ {showDesktopOnlySettings ? (
+ <>
+
+
+
- {isMac ? (
+
+
+
+
+
+
+
+
+
+ {computerUsePlatform} Computer Use is an early preview. Some apps
+ and desktop environments may behave inconsistently.
+
+
+
+
+ ) : null
+ }
+ description="Enable agents to control any app on your computer."
+ searchEntries={COMPUTER_USE_PANE_SEARCH_ENTRIES}
+ >
+
+
+
+
+
+
+ >
+ ) : null}
+
+ {showDesktopOnlySettings && isMac ? (
-
-
-
+ {showDesktopOnlySettings ? (
+
+
+
+ ) : null}
s.setSshTargetLabels)
+ const setSshTargetsMetadata = useAppStore((s) => s.setSshTargetsMetadata)
const loadTargets = useCallback(
async (opts?: { signal?: AbortSignal }) => {
@@ -67,18 +67,14 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
return
}
setTargets(result)
- const labels = new Map()
- for (const t of result) {
- labels.set(t.id, t.label)
- }
- setSshTargetLabels(labels)
+ setSshTargetsMetadata(result)
} catch {
if (!opts?.signal?.aborted) {
toast.error('Failed to load SSH targets')
}
}
},
- [setSshTargetLabels]
+ [setSshTargetsMetadata]
)
useEffect(() => {
@@ -104,6 +100,14 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
toast.error('Relay grace period must be between 60 and 3600 seconds')
return
}
+ const remoteGraceSeconds = parseInt(form.remoteWorkspaceSyncGracePeriodSeconds, 10)
+ if (
+ form.remoteWorkspaceSyncEnabled &&
+ (isNaN(remoteGraceSeconds) || remoteGraceSeconds < 0 || remoteGraceSeconds > 3600)
+ ) {
+ toast.error('Synced relay grace period must be between 0 and 3600 seconds')
+ return
+ }
const target = {
label: form.label.trim() || `${form.username}@${form.host}`,
@@ -112,6 +116,10 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
port,
username: form.username.trim(),
relayGracePeriodSeconds: graceSeconds,
+ remoteWorkspaceSyncEnabled: form.remoteWorkspaceSyncEnabled,
+ remoteWorkspaceSyncGracePeriodSeconds: form.remoteWorkspaceSyncEnabled
+ ? remoteGraceSeconds
+ : 300,
...(form.identityFile.trim() ? { identityFile: form.identityFile.trim() } : {}),
...(form.proxyCommand.trim() ? { proxyCommand: form.proxyCommand.trim() } : {}),
...(form.jumpHost.trim() ? { jumpHost: form.jumpHost.trim() } : {})
@@ -173,7 +181,11 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
identityFile: target.identityFile ?? '',
proxyCommand: target.proxyCommand ?? '',
jumpHost: target.jumpHost ?? '',
- relayGracePeriodSeconds: String(target.relayGracePeriodSeconds ?? 300)
+ relayGracePeriodSeconds: String(target.relayGracePeriodSeconds ?? 300),
+ remoteWorkspaceSyncEnabled: target.remoteWorkspaceSyncEnabled === true,
+ remoteWorkspaceSyncGracePeriodSeconds: String(
+ target.remoteWorkspaceSyncGracePeriodSeconds ?? 300
+ )
})
setShowForm(true)
}
diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx
index 49e97af57cc..ba372b13452 100644
--- a/src/renderer/src/components/settings/SshTargetForm.tsx
+++ b/src/renderer/src/components/settings/SshTargetForm.tsx
@@ -13,6 +13,8 @@ export type EditingTarget = {
proxyCommand: string
jumpHost: string
relayGracePeriodSeconds: string
+ remoteWorkspaceSyncEnabled: boolean
+ remoteWorkspaceSyncGracePeriodSeconds: string
}
export const EMPTY_FORM: EditingTarget = {
@@ -24,7 +26,9 @@ export const EMPTY_FORM: EditingTarget = {
identityFile: '',
proxyCommand: '',
jumpHost: '',
- relayGracePeriodSeconds: '300'
+ relayGracePeriodSeconds: '300',
+ remoteWorkspaceSyncEnabled: false,
+ remoteWorkspaceSyncGracePeriodSeconds: '300'
}
type SshTargetFormProps = {
@@ -140,6 +144,47 @@ export function SshTargetForm({
How long the relay keeps terminals alive after disconnect. Default: 300 (5 minutes).
+
+
+
+ onFormChange((f) => ({ ...f, remoteWorkspaceSyncEnabled: e.target.checked }))
+ }
+ />
+
+ Sync remote workspace
+
+ Store terminal tabs and split layouts on the SSH host so another Orca client can
+ restore the same remote workspace.
+
+
+
+ {form.remoteWorkspaceSyncEnabled && (
+
+
Synced Relay Grace Period (seconds)
+
+ onFormChange((f) => ({
+ ...f,
+ remoteWorkspaceSyncGracePeriodSeconds: e.target.value
+ }))
+ }
+ placeholder="0"
+ min={0}
+ max={3600}
+ />
+
+ How long synced remote workspace terminals stay alive after all clients disconnect.
+ 0 keeps them alive until explicitly terminated.
+
+
+ )}
+
diff --git a/src/renderer/src/components/settings/TasksPane.tsx b/src/renderer/src/components/settings/TasksPane.tsx
new file mode 100644
index 00000000000..7bf4e50f005
--- /dev/null
+++ b/src/renderer/src/components/settings/TasksPane.tsx
@@ -0,0 +1,142 @@
+import { Check, Github, Gitlab } from 'lucide-react'
+import type { GlobalSettings, TaskProvider } from '../../../../shared/types'
+import {
+ TASK_PROVIDERS,
+ normalizeVisibleTaskProviders,
+ resolveVisibleTaskProvider
+} from '../../../../shared/task-providers'
+import { cn } from '@/lib/utils'
+import { LinearIcon } from '@/components/icons/LinearIcon'
+import { Label } from '../ui/label'
+import { SearchableSetting } from './SearchableSetting'
+
+type TasksPaneProps = {
+ settings: GlobalSettings
+ updateSettings: (updates: Partial
) => void
+}
+
+const TASK_PROVIDER_OPTIONS: readonly {
+ id: TaskProvider
+ label: string
+ description: string
+ Icon: (props: { className?: string }) => React.JSX.Element
+}[] = [
+ {
+ id: 'github',
+ label: 'GitHub',
+ description: 'Show GitHub in the Tasks source picker and sidebar shortcuts.',
+ Icon: ({ className }) =>
+ },
+ {
+ id: 'gitlab',
+ label: 'GitLab',
+ description: 'Show GitLab in the Tasks source picker and sidebar shortcuts.',
+ Icon: ({ className }) =>
+ },
+ {
+ id: 'linear',
+ label: 'Linear',
+ description: 'Show Linear in the Tasks source picker and sidebar shortcuts.',
+ Icon: ({ className }) =>
+ }
+]
+
+export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.JSX.Element {
+ const visibleProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders)
+
+ const toggleProvider = (provider: TaskProvider): void => {
+ const isVisible = visibleProviders.includes(provider)
+ if (isVisible && visibleProviders.length === 1) {
+ return
+ }
+
+ const nextProviders = isVisible
+ ? visibleProviders.filter((entry) => entry !== provider)
+ : TASK_PROVIDERS.filter((entry) => entry === provider || visibleProviders.includes(entry))
+
+ updateSettings({
+ visibleTaskProviders: nextProviders,
+ defaultTaskSource: resolveVisibleTaskProvider(settings.defaultTaskSource, nextProviders)
+ })
+ }
+
+ return (
+
+
+
+
Task Sources
+
+ Choose which task providers appear in the Tasks page source picker and sidebar
+ shortcuts. At least one provider must stay visible.
+
+
+
+
+ {TASK_PROVIDER_OPTIONS.map((option) => {
+ const enabled = visibleProviders.includes(option.id)
+ const isLastEnabled = enabled && visibleProviders.length === 1
+ const Icon = option.Icon
+
+ return (
+ toggleProvider(option.id)}
+ className={cn(
+ 'flex w-full items-center gap-3 rounded-md border border-border/60 px-3 py-2.5 text-left transition-colors',
+ enabled
+ ? 'bg-accent/70 text-accent-foreground'
+ : 'bg-transparent hover:bg-muted/50',
+ isLastEnabled && 'cursor-not-allowed'
+ )}
+ >
+
+
+
+
+ {option.label}
+ {option.description}
+
+
+ {enabled ? : null}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx b/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx
index 8136e90a274..a8e90a5c316 100644
--- a/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx
+++ b/src/renderer/src/components/settings/TerminalQuickCommandsSection.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import type { TerminalQuickCommand } from '../../../../shared/types'
+import { createBrowserUuid } from '@/lib/browser-uuid'
import { Button } from '../ui/button'
import {
Dialog,
@@ -30,12 +31,8 @@ type EditorState =
| null
function createQuickCommand(): TerminalQuickCommand {
- const randomId =
- typeof crypto !== 'undefined' && 'randomUUID' in crypto
- ? crypto.randomUUID()
- : `${Date.now()}-${Math.random().toString(16).slice(2)}`
return {
- id: `quick-command-${randomId}`,
+ id: `quick-command-${createBrowserUuid()}`,
label: '',
command: '',
appendEnter: true
diff --git a/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx b/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx
index 59a7fcc620e..60dd9e42c79 100644
--- a/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx
+++ b/src/renderer/src/components/settings/WorktreeSymlinksSection.tsx
@@ -6,6 +6,7 @@ import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '.
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
import { cn } from '@/lib/utils'
import { SearchableSetting } from './SearchableSetting'
+import { useAppStore } from '@/store'
type WorktreeSymlinksSectionProps = {
repo: Repo
@@ -23,11 +24,16 @@ export function WorktreeSymlinksSection({
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [entries, setEntries] = useState([])
+ const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
const paths = repo.symlinkPaths ?? []
const queryTrimmed = query.trim().replace(/^\/+/, '')
useEffect(() => {
+ if (activeRuntimeEnvironmentId?.trim()) {
+ setEntries([])
+ return
+ }
let cancelled = false
void window.api.fs
.readDir({ dirPath: repo.path, connectionId: repo.connectionId ?? undefined })
@@ -44,7 +50,7 @@ export function WorktreeSymlinksSection({
return () => {
cancelled = true
}
- }, [repo.path, repo.connectionId])
+ }, [activeRuntimeEnvironmentId, repo.path, repo.connectionId])
const filtered = useMemo(() => {
const q = queryTrimmed.toLowerCase()
diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts
index 5be39975023..319f780f8a8 100644
--- a/src/renderer/src/components/settings/agents-search.ts
+++ b/src/renderer/src/components/settings/agents-search.ts
@@ -29,7 +29,9 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'qwen',
'rovo',
'hermes',
+ 'openclaw',
'copilot',
+ 'grok',
'github',
'github copilot',
'command',
@@ -37,5 +39,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'install',
'detected'
]
+ },
+ {
+ title: 'Keep computer awake when Orca sees agents running',
+ description:
+ 'Prevents this computer from sleeping while Orca sees an agent working. The display can still turn off.',
+ keywords: ['awake', 'sleep', 'power', 'agent', 'running']
}
]
diff --git a/src/renderer/src/components/settings/browser-use-search.ts b/src/renderer/src/components/settings/browser-use-search.ts
index 1574ae34178..08bd0d5ada6 100644
--- a/src/renderer/src/components/settings/browser-use-search.ts
+++ b/src/renderer/src/components/settings/browser-use-search.ts
@@ -33,7 +33,14 @@ export const BROWSER_USE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'auth',
'chrome',
'edge',
- 'arc'
+ 'arc',
+ 'computer use',
+ 'system browser',
+ 'existing session',
+ 'authenticated browser',
+ 'chrome profile',
+ 'edge profile',
+ 'arc profile'
]
}
]
diff --git a/src/renderer/src/components/settings/commit-message-ai-search.ts b/src/renderer/src/components/settings/commit-message-ai-search.ts
new file mode 100644
index 00000000000..2dadc067471
--- /dev/null
+++ b/src/renderer/src/components/settings/commit-message-ai-search.ts
@@ -0,0 +1,45 @@
+import type { SettingsSearchEntry } from './settings-search'
+
+export const COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
+ {
+ title: 'Enable AI commit messages',
+ description: 'Adds a Generate button to the Source Control panel.',
+ keywords: [
+ 'ai',
+ 'commit',
+ 'message',
+ 'generate',
+ 'agent',
+ 'claude',
+ 'codex',
+ 'source control',
+ 'enabled'
+ ]
+ },
+ {
+ title: 'Agent',
+ description: 'Which agent to invoke when generating a commit message.',
+ keywords: ['agent', 'claude', 'codex']
+ },
+ {
+ title: 'Model',
+ description: 'Which model the selected agent uses to generate the message.',
+ keywords: ['model', 'haiku', 'sonnet', 'opus', 'gpt']
+ },
+ {
+ title: 'Thinking effort',
+ description: 'Reasoning effort level for the selected model. Higher levels are slower.',
+ keywords: ['thinking', 'effort', 'reasoning']
+ },
+ {
+ title: 'Custom prompt',
+ description:
+ 'Optional instructions appended to the base prompt (e.g. Conventional Commits style).',
+ keywords: ['prompt', 'conventional commits', 'gitmoji', 'style']
+ },
+ {
+ title: 'Custom command',
+ description: 'Command line Orca runs to generate the commit message.',
+ keywords: ['custom', 'command', 'cli', 'binary', 'prompt', 'placeholder', 'ollama']
+ }
+]
diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts
index cdb6dc733a2..254b63336bf 100644
--- a/src/renderer/src/components/settings/general-search.ts
+++ b/src/renderer/src/components/settings/general-search.ts
@@ -43,6 +43,11 @@ export const GENERAL_EDITOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
title: 'Minimap',
description: 'Show the minimap overview when editing a file.',
keywords: ['minimap', 'overview', 'code', 'scroll']
+ },
+ {
+ title: 'Markdown Review Notes',
+ description: 'Show local markdown review note controls and the review panel.',
+ keywords: ['markdown', 'review', 'notes', 'annotations', 'agents']
}
]
@@ -88,7 +93,8 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'pi',
'gemini',
'aider',
- 'copilot'
+ 'copilot',
+ 'grok'
]
}
]
diff --git a/src/renderer/src/components/settings/git-search.ts b/src/renderer/src/components/settings/git-search.ts
index cb66e34ee00..e26ee40bb41 100644
--- a/src/renderer/src/components/settings/git-search.ts
+++ b/src/renderer/src/components/settings/git-search.ts
@@ -11,6 +11,11 @@ export const GIT_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description: 'Optionally fast-forward local main or master when creating worktrees.',
keywords: ['main', 'master', 'origin/main', 'git diff', 'base ref', 'worktree']
},
+ {
+ title: 'GitHub API Budget',
+ description: 'Current GitHub CLI REST, Search, and GraphQL rate limits.',
+ keywords: ['github', 'gh', 'graphql', 'rate limit', 'api budget']
+ },
{
title: 'Orca Attribution',
description: 'Add Orca attribution to commits, PRs, and issues.',
diff --git a/src/renderer/src/components/settings/runtime-environments-search.ts b/src/renderer/src/components/settings/runtime-environments-search.ts
new file mode 100644
index 00000000000..5da0eea6214
--- /dev/null
+++ b/src/renderer/src/components/settings/runtime-environments-search.ts
@@ -0,0 +1,25 @@
+import type { SettingsSearchEntry } from './settings-search'
+
+export const RUNTIME_ENVIRONMENTS_SEARCH_ENTRY: SettingsSearchEntry = {
+ title: 'Active Server',
+ description: 'Choose local desktop, add a saved remote Orca server, or generate a pairing URL.',
+ keywords: [
+ 'runtime',
+ 'environment',
+ 'server',
+ 'client',
+ 'remote',
+ 'pairing',
+ 'pairing url',
+ 'web client',
+ 'cloud',
+ 'vm',
+ 'dev box'
+ ]
+}
+
+export const WEB_RUNTIME_ENVIRONMENTS_SEARCH_ENTRY: SettingsSearchEntry = {
+ title: 'Active Server',
+ description: 'Connect this browser to a saved Orca server.',
+ keywords: ['runtime', 'environment', 'server', 'client', 'remote', 'pairing code', 'cloud', 'vm']
+}
diff --git a/src/renderer/src/components/settings/tasks-search.ts b/src/renderer/src/components/settings/tasks-search.ts
new file mode 100644
index 00000000000..a309c42412d
--- /dev/null
+++ b/src/renderer/src/components/settings/tasks-search.ts
@@ -0,0 +1,9 @@
+import type { SettingsSearchEntry } from './settings-search'
+
+export const TASKS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
+ {
+ title: 'Task Providers',
+ description: 'Choose which task providers appear in the Tasks page and sidebar shortcuts.',
+ keywords: ['tasks', 'provider', 'source', 'github', 'gitlab', 'linear', 'display', 'hide']
+ }
+]
diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx
index 5678c9300a6..d24aeda7e0f 100644
--- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx
@@ -13,6 +13,7 @@ import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/di
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/types'
@@ -46,6 +47,12 @@ export function useCreateRepo(
}, [])
const handlePickParent = useCallback(async () => {
+ if (useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim()) {
+ // Why: the native folder picker returns a client-local path. Runtime
+ // project creation needs an explicit server parent path.
+ toast.error('Enter a server parent path.')
+ return
+ }
const dir = await window.api.repos.pickDirectory()
if (dir) {
setCreateParent(dir)
@@ -63,11 +70,25 @@ export function useCreateRepo(
setIsCreating(true)
setCreateError(null)
try {
- const result = await window.api.repos.create({
- parentPath,
- name,
- kind: createKind
- })
+ const settings = useAppStore.getState().settings
+ const target = getActiveRuntimeTarget(settings)
+ const result =
+ target.kind === 'environment'
+ ? await callRuntimeRpc<{ repo: Repo } | { error: string }>(
+ target,
+ 'repo.create',
+ {
+ parentPath,
+ name,
+ kind: createKind
+ },
+ { timeoutMs: 60_000 }
+ )
+ : await window.api.repos.create({
+ parentPath,
+ name,
+ kind: createKind
+ })
// Why: if the user closed the dialog or clicked Back mid-create,
// createGenRef was bumped by resetCreateState. Ignore stale results.
if (gen !== createGenRef.current) {
@@ -147,6 +168,7 @@ export function useCreateRepo(
createError,
isCreating,
setCreateName,
+ setCreateParent,
setCreateKind,
setCreateError,
resetCreateState,
@@ -235,7 +257,9 @@ type CreateStepProps = {
createKind: RepoKind
createError: string | null
isCreating: boolean
+ manualParentEntry?: boolean
onNameChange: (value: string) => void
+ onParentChange: (value: string) => void
onKindChange: (kind: RepoKind) => void
onPickParent: () => void
onCreate: () => void
@@ -247,7 +271,9 @@ export function CreateStep({
createKind,
createError,
isCreating,
+ manualParentEntry = false,
onNameChange,
+ onParentChange,
onKindChange,
onPickParent,
onCreate
@@ -333,11 +359,21 @@ export function CreateStep({
/>
- {/* Location. The "Choose…" button morphs into a summary + Change once picked. */}
+ {/* Location. The local flow uses a folder picker; runtime servers need
+ manual server-path entry because the client cannot browse that filesystem yet. */}
Location
- {createParent ? (
+ {manualParentEntry ? (
+
onParentChange(e.target.value)}
+ placeholder="/home/user/projects"
+ className="h-11 text-sm font-mono"
+ disabled={isCreating}
+ spellCheck={false}
+ />
+ ) : createParent ? (
diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx
index 7f207bf6224..600c1f7225e 100644
--- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx
@@ -11,19 +11,23 @@ import {
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { track } from '@/lib/telemetry'
import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps'
import { CreateStep, useCreateRepo } from './AddRepoCreateStep'
import { SetupStep } from './AddRepoSetupStep'
import { getDefaultCloneParent } from './clone-defaults'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo, Worktree } from '../../../../shared/types'
+import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
const AddRepoDialog = React.memo(function AddRepoDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
const addRepo = useAppStore((s) => s.addRepo)
+ const addRepoPath = useAppStore((s) => s.addRepoPath)
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
@@ -35,6 +39,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
const [addedRepo, setAddedRepo] = useState(null)
const [isAdding, setIsAdding] = useState(false)
+ const [serverPath, setServerPath] = useState('')
+ const [isAddingServerPath, setIsAddingServerPath] = useState(false)
const [cloneUrl, setCloneUrl] = useState('')
const [cloneDestination, setCloneDestination] = useState('')
const [isCloning, setIsCloning] = useState(false)
@@ -71,6 +77,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
createError,
isCreating,
setCreateName,
+ setCreateParent,
setCreateKind,
setCreateError,
resetCreateState,
@@ -95,15 +102,19 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
if (cloneDestination) {
return
}
+ if (settings?.activeRuntimeEnvironmentId?.trim()) {
+ return
+ }
if (!settings?.workspaceDir) {
return
}
cloneStepAutoFilledRef.current = true
setCloneDestination(getDefaultCloneParent(settings.workspaceDir))
- }, [step, cloneDestination, settings?.workspaceDir])
+ }, [step, cloneDestination, settings?.activeRuntimeEnvironmentId, settings?.workspaceDir])
const isOpen = activeModal === 'add-repo'
const repoId = addedRepo?.id ?? ''
+ const isRuntimeEnvironmentActive = Boolean(settings?.activeRuntimeEnvironmentId?.trim())
const worktrees = useMemo(() => {
return worktreesByRepo[repoId] ?? []
@@ -127,6 +138,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setStep('add')
setAddedRepo(null)
setIsAdding(false)
+ setServerPath('')
+ setIsAddingServerPath(false)
setCloneUrl('')
setCloneDestination('')
setIsCloning(false)
@@ -162,13 +175,42 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}
}, [addRepo, fetchWorktrees, closeModal])
+ const handleAddServerPath = useCallback(
+ async (kind: 'git' | 'folder') => {
+ const path = serverPath.trim()
+ if (!path) {
+ return
+ }
+ setIsAddingServerPath(true)
+ try {
+ const repo = await addRepoPath(path, kind)
+ if (repo && isGitRepoKind(repo)) {
+ setAddedRepo(repo)
+ await fetchWorktrees(repo.id)
+ setStep('setup')
+ } else if (repo) {
+ closeModal()
+ }
+ } finally {
+ setIsAddingServerPath(false)
+ }
+ },
+ [addRepoPath, closeModal, fetchWorktrees, serverPath]
+ )
+
const handlePickDestination = useCallback(async () => {
+ if (settings?.activeRuntimeEnvironmentId?.trim()) {
+ // Why: the native folder picker returns a client-local path. Runtime
+ // clone destinations must be typed as server paths.
+ toast.error('Enter a server path for the clone destination.')
+ return
+ }
const dir = await window.api.repos.pickDirectory()
if (dir) {
setCloneDestination(dir)
setCloneError(null)
}
- }, [])
+ }, [settings?.activeRuntimeEnvironmentId])
const handleClone = useCallback(async () => {
const trimmedUrl = cloneUrl.trim()
@@ -180,10 +222,24 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setCloneError(null)
setCloneProgress(null)
try {
- const repo = (await window.api.repos.clone({
- url: trimmedUrl,
- destination: cloneDestination.trim()
- })) as Repo
+ const target = getActiveRuntimeTarget(useAppStore.getState().settings)
+ const repo =
+ target.kind === 'environment'
+ ? (
+ await callRuntimeRpc<{ repo: Repo }>(
+ target,
+ 'repo.clone',
+ {
+ url: trimmedUrl,
+ destination: cloneDestination.trim()
+ },
+ { timeoutMs: 10 * 60_000 }
+ )
+ ).repo
+ : ((await window.api.repos.clone({
+ url: trimmedUrl,
+ destination: cloneDestination.trim()
+ })) as Repo)
// Why: if the user closed the dialog or clicked Back during the clone,
// cloneGenRef will have been bumped by resetState. Ignore this stale result.
if (gen !== cloneGenRef.current) {
@@ -244,14 +300,26 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
openSettingsPage()
}, [closeModal, openSettingsTarget, openSettingsPage, repoId])
+ const finishImportedRepoWithoutOpening = useCallback(async () => {
+ const importedRepoId = repoId
+ closeModal()
+ resetState()
+ if (!importedRepoId) {
+ return
+ }
+
+ await fetchWorktrees(importedRepoId)
+ const state = useAppStore.getState()
+ finalizeImportedRepoAfterSkip(state, importedRepoId)
+ }, [closeModal, fetchWorktrees, repoId, resetState])
+
// Why: handleBack reuses resetState which already aborts clones and resets all fields.
const handleBack = resetState
const handleSkip = useCallback(() => {
track('add_repo_setup_step_action', { action: 'skip' })
- closeModal()
- resetState()
- }, [closeModal, resetState])
+ void finishImportedRepoWithoutOpening()
+ }, [finishImportedRepoWithoutOpening])
// Why: only the Setup step's "Add another project" back arrow counts as a
// funnel event — the in-flight Back arrows on clone/remote/create are not
@@ -273,6 +341,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
// on the Setup step are funnel-equivalent to Skip.
if (step === 'setup') {
track('add_repo_setup_step_action', { action: 'skip' })
+ void finishImportedRepoWithoutOpening()
+ return
}
closeModal()
resetState()
@@ -310,7 +380,76 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
- {step === 'add' ? (
+ {step === 'add' && isRuntimeEnvironmentActive ? (
+ <>
+
+ Add a server project
+
+ Add a Git repository or folder that already exists on the selected runtime server.
+
+
+
+
+
+
+ Server path
+
+ setServerPath(event.target.value)}
+ placeholder="/home/user/project"
+ className="h-11 text-sm font-mono"
+ disabled={isAddingServerPath}
+ autoFocus
+ spellCheck={false}
+ />
+
+
+ void handleAddServerPath('git')}
+ disabled={!serverPath.trim() || isAddingServerPath}
+ className="h-10"
+ >
+ Add Git Project
+
+ void handleAddServerPath('folder')}
+ disabled={!serverPath.trim() || isAddingServerPath}
+ variant="outline"
+ className="h-10"
+ >
+ Open as Folder
+
+
+
+ {
+ setCloneError(null)
+ setStep('clone')
+ }}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ >
+ Clone into server path
+
+ {
+ setCreateError(null)
+ setStep('create')
+ }}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
+ >
+ Create on server
+
+
+
+ >
+ ) : step === 'add' ? (
<>
Add a project
@@ -411,6 +550,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
+ disableDestinationPicker={isRuntimeEnvironmentActive}
onUrlChange={(value) => {
setCloneUrl(value)
setCloneError(null)
@@ -429,10 +569,15 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
createKind={createKind}
createError={createError}
isCreating={isCreating}
+ manualParentEntry={isRuntimeEnvironmentActive}
onNameChange={(value) => {
setCreateName(value)
setCreateError(null)
}}
+ onParentChange={(value) => {
+ setCreateParent(value)
+ setCreateError(null)
+ }}
onKindChange={(kind) => {
setCreateKind(kind)
setCreateError(null)
diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx
index a40c23c1400..aaf6eb78aee 100644
--- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx
+++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx
@@ -306,6 +306,7 @@ type CloneStepProps = {
cloneError: string | null
cloneProgress: { phase: string; percent: number } | null
isCloning: boolean
+ disableDestinationPicker?: boolean
onUrlChange: (value: string) => void
onDestChange: (value: string) => void
onPickDestination: () => void
@@ -318,6 +319,7 @@ export function CloneStep({
cloneError,
cloneProgress,
isCloning,
+ disableDestinationPicker = false,
onUrlChange,
onDestChange,
onPickDestination,
@@ -369,7 +371,8 @@ export function CloneStep({
size="sm"
className="h-8 px-2 shrink-0"
onClick={onPickDestination}
- disabled={isCloning}
+ disabled={isCloning || disableDestinationPicker}
+ title={disableDestinationPicker ? 'Enter a server path manually' : 'Choose folder'}
>
diff --git a/src/renderer/src/components/sidebar/CacheTimer.tsx b/src/renderer/src/components/sidebar/CacheTimer.tsx
index 61f2f44763d..2f4ee986ee4 100644
--- a/src/renderer/src/components/sidebar/CacheTimer.tsx
+++ b/src/renderer/src/components/sidebar/CacheTimer.tsx
@@ -32,7 +32,7 @@ export default function CacheTimer({
}
let oldest: number | null = null
for (const tab of tabs) {
- // Why: cache timer keys are `${tabId}:${paneId}` composites, so we check
+ // Why: cache timer keys are `${tabId}:${leafId}` composites, so we check
// all keys that belong to this tab's panes.
for (const key of Object.keys(s.cacheTimerByKey)) {
if (!key.startsWith(`${tab.id}:`)) {
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
index 10c01efc75a..2854673162a 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { AlertTriangle, Check, LoaderCircle, Trash2 } from 'lucide-react'
import { useAppStore } from '@/store'
import { toast } from 'sonner'
-import { runWorktreeDeleteWithToast } from './delete-worktree-flow'
+import { runWorktreeDeletesSequentially } from './delete-worktree-flow'
const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const activeModal = useAppStore((s) => s.activeModal)
@@ -35,6 +35,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
: [],
[modalData.worktreeIds, worktreeId]
)
+ const onDeleted =
+ typeof modalData.onDeleted === 'function'
+ ? (modalData.onDeleted as (worktreeIds: string[]) => void)
+ : null
const worktree = useMemo(
() => (worktreeId ? (allWorktrees().find((item) => item.id === worktreeId) ?? null) : null),
[allWorktrees, worktreeId]
@@ -54,6 +58,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
const canForceDelete = deleteState?.canForceDelete ?? false
const confirmButtonRef = useRef(null)
const isBatchDelete = worktreeIds.length > 1
+ const allowSkipConfirm = !isBatchDelete && modalData.allowSkipConfirm !== false
// Why: the main worktree is the repo's original clone directory — `git worktree remove`
// always rejects it. We block the delete button upfront so the user doesn't have to
// discover this limitation via a confusing force-delete dead-end.
@@ -132,7 +137,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
// Saving "don't ask again" from that state would conflate the recovery
// action with a broader preference. Only persist the preference on the
// primary (non-force) confirmation so users intentionally opt in.
- if (dontAskAgain && !force) {
+ if (dontAskAgain && allowSkipConfirm && !force) {
persistDontAskAgainPreference()
}
if (force) {
@@ -146,7 +151,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
toast.error('Force delete failed', {
description: result.error
})
+ return
}
+ onDeleted?.([worktreeId])
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
@@ -154,15 +161,19 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
})
})
} else {
- for (const target of worktrees) {
- runWorktreeDeleteWithToast(target.id, target.displayName)
- }
+ void runWorktreeDeletesSequentially(worktrees).then((deletedIds) => {
+ if (deletedIds.length > 0) {
+ onDeleted?.(deletedIds)
+ }
+ })
}
closeModal()
},
[
closeModal,
dontAskAgain,
+ allowSkipConfirm,
+ onDeleted,
persistDontAskAgainPreference,
removeWorktree,
worktreeIds.length,
@@ -248,7 +259,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
)}
- {!isMainWorktree && !canForceDelete && (
+ {!isMainWorktree && allowSkipConfirm && !canForceDelete && (
// Why: only show "Don't ask again" for the primary confirmation. The
// force-delete variant is a recovery path that shouldn't double as a
// preference checkpoint; see handleDelete for the matching guard.
diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx
index 83ea2a2261b..1cd6b264ce6 100644
--- a/src/renderer/src/components/sidebar/SidebarHeader.tsx
+++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Plus, SlidersHorizontal } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
@@ -17,10 +17,11 @@ import {
} from '@/components/ui/dropdown-menu'
import type { WorktreeCardProperty } from '../../../../shared/types'
import SidebarFilter from './SidebarFilter'
+import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
const GROUP_BY_OPTIONS = [
- { id: 'none', label: 'All' },
- { id: 'pr-status', label: 'PR Status' },
+ { id: 'none', label: 'Status' },
+ { id: 'pr-status', label: 'PR' },
{ id: 'repo', label: 'Repo' }
] as const
@@ -50,8 +51,18 @@ const SORT_OPTIONS = [
const isMac = navigator.userAgent.includes('Mac')
const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
+const WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS = 50
+// Why: gives the pointer room to travel from the header into the board before
+// the temporary hover preview collapses.
+const WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS = 220
const SidebarHeader = React.memo(function SidebarHeader() {
+ const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false)
+ // Why: entering the board turns the hover preview into a persistent drawer
+ // until the user explicitly closes it or clicks outside.
+ const workspaceBoardPinnedOpenRef = useRef(false)
+ const workspaceBoardHoverOpenTimerRef = useRef
(null)
+ const workspaceBoardHoverCloseTimerRef = useRef(null)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
@@ -64,139 +75,242 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const setGroupBy = useAppStore((s) => s.setGroupBy)
const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage)
const setShowWorkspaceLineage = useAppStore((s) => s.setShowWorkspaceLineage)
+
+ const clearWorkspaceBoardHoverClose = useCallback(() => {
+ if (workspaceBoardHoverCloseTimerRef.current === null) {
+ return
+ }
+ window.clearTimeout(workspaceBoardHoverCloseTimerRef.current)
+ workspaceBoardHoverCloseTimerRef.current = null
+ }, [])
+
+ const clearWorkspaceBoardHoverOpen = useCallback(() => {
+ if (workspaceBoardHoverOpenTimerRef.current === null) {
+ return
+ }
+ window.clearTimeout(workspaceBoardHoverOpenTimerRef.current)
+ workspaceBoardHoverOpenTimerRef.current = null
+ }, [])
+
+ useEffect(
+ () => () => {
+ clearWorkspaceBoardHoverOpen()
+ clearWorkspaceBoardHoverClose()
+ },
+ [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen]
+ )
+
+ const setWorkspaceBoardPinned = useCallback((pinned: boolean) => {
+ workspaceBoardPinnedOpenRef.current = pinned
+ }, [])
+
+ const handleWorkspaceBoardOpenChange = useCallback(
+ (open: boolean) => {
+ clearWorkspaceBoardHoverOpen()
+ clearWorkspaceBoardHoverClose()
+ setWorkspaceBoardOpen(open)
+ if (!open) {
+ setWorkspaceBoardPinned(false)
+ }
+ },
+ [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardPinned]
+ )
+
+ const handleWorkspaceHeaderPointerEnter = useCallback(
+ (event: React.PointerEvent) => {
+ if (event.pointerType !== 'mouse') {
+ return
+ }
+ clearWorkspaceBoardHoverClose()
+ if (workspaceBoardOpen) {
+ return
+ }
+ clearWorkspaceBoardHoverOpen()
+ workspaceBoardHoverOpenTimerRef.current = window.setTimeout(() => {
+ workspaceBoardHoverOpenTimerRef.current = null
+ setWorkspaceBoardOpen(true)
+ }, WORKSPACE_BOARD_HOVER_OPEN_DELAY_MS)
+ },
+ [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, workspaceBoardOpen]
+ )
+
+ const handleWorkspaceHeaderPointerLeave = useCallback(
+ (event: React.PointerEvent) => {
+ clearWorkspaceBoardHoverOpen()
+ if (event.pointerType !== 'mouse' || workspaceBoardPinnedOpenRef.current) {
+ return
+ }
+ clearWorkspaceBoardHoverClose()
+ workspaceBoardHoverCloseTimerRef.current = window.setTimeout(() => {
+ workspaceBoardHoverCloseTimerRef.current = null
+ if (workspaceBoardPinnedOpenRef.current) {
+ return
+ }
+ setWorkspaceBoardOpen(false)
+ }, WORKSPACE_BOARD_HOVER_CLOSE_DELAY_MS)
+ },
+ [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen]
+ )
+
+ const handleWorkspaceBoardPointerEnter = useCallback(
+ (event: React.PointerEvent) => {
+ if (event.pointerType !== 'mouse') {
+ return
+ }
+ clearWorkspaceBoardHoverOpen()
+ clearWorkspaceBoardHoverClose()
+ setWorkspaceBoardPinned(true)
+ setWorkspaceBoardOpen(true)
+ },
+ [clearWorkspaceBoardHoverClose, clearWorkspaceBoardHoverOpen, setWorkspaceBoardPinned]
+ )
+
return (
-
-
- Workspaces
-
-
-
-
-
-
-
-
+
+
+
+ Workspaces
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View options
+
+
+
+ Group by
+
+
{
+ if (v) {
+ setGroupBy(v as typeof groupBy)
+ }
+ }}
+ variant="outline"
+ size="sm"
+ className="h-6 w-full justify-start"
>
-
-
-
-
-
- View options
-
-
-
- Group by
-
- {
- if (v) {
- setGroupBy(v as typeof groupBy)
- }
- }}
- variant="outline"
- size="sm"
- className="h-6 w-full justify-start"
- >
- {GROUP_BY_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
+ {GROUP_BY_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
-
- setShowWorkspaceLineage(Boolean(checked))}
- onSelect={(e) => e.preventDefault()}
- >
- Nest child workspaces
-
-
-
- Sort by
- setSortBy(v as typeof sortBy)}
- >
- {SORT_OPTIONS.map((opt) => {
- const radioItem = (
- e.preventDefault()}
- >
- {opt.label}
-
- )
- if (!opt.description) {
- return radioItem
- }
- return (
-
- {radioItem}
-
- {opt.description}
-
-
- )
- })}
-
-
-
- Show properties
- {PROPERTY_OPTIONS.map((opt) => (
+
toggleWorktreeCardProperty(opt.id)}
+ checked={showWorkspaceLineage}
+ onCheckedChange={(checked) => setShowWorkspaceLineage(Boolean(checked))}
onSelect={(e) => e.preventDefault()}
>
- {opt.label}
+ Nest child workspaces
- ))}
-
-
-
-
- {
- if (!canCreateWorktree) {
- return
- }
- openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
- }}
- aria-label="New workspace"
- disabled={!canCreateWorktree}
- >
-
-
-
-
- {canCreateWorktree
- ? `New workspace (${newWorktreeShortcutLabel})`
- : 'Add a Git project to create worktrees'}
-
-
+
+
Sort by
+
setSortBy(v as typeof sortBy)}
+ >
+ {SORT_OPTIONS.map((opt) => {
+ const radioItem = (
+ e.preventDefault()}
+ >
+ {opt.label}
+
+ )
+ if (!opt.description) {
+ return radioItem
+ }
+ return (
+
+ {radioItem}
+
+ {opt.description}
+
+
+ )
+ })}
+
+
+
+
Show properties
+ {PROPERTY_OPTIONS.map((opt) => (
+
toggleWorktreeCardProperty(opt.id)}
+ onSelect={(e) => e.preventDefault()}
+ >
+ {opt.label}
+
+ ))}
+
+
+
+
+
+ {
+ if (!canCreateWorktree) {
+ return
+ }
+ openModal('new-workspace-composer', { telemetrySource: 'sidebar' })
+ }}
+ aria-label="New workspace"
+ disabled={!canCreateWorktree}
+ >
+
+
+
+
+ {canCreateWorktree
+ ? `New workspace (${newWorktreeShortcutLabel})`
+ : 'Add a Git project to create worktrees'}
+
+
+
-
+
+ >
)
})
diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx
index bffed9d67ab..2bc8c6f7200 100644
--- a/src/renderer/src/components/sidebar/SidebarNav.tsx
+++ b/src/renderer/src/components/sidebar/SidebarNav.tsx
@@ -6,6 +6,11 @@ import { cn } from '@/lib/utils'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { getTaskPresetQuery, PER_REPO_FETCH_LIMIT } from '@/lib/new-workspace'
import { LinearIcon } from '@/components/icons/LinearIcon'
+import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
+import {
+ normalizeVisibleTaskProviders,
+ resolveVisibleTaskProvider
+} from '../../../../shared/task-providers'
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
@@ -21,6 +26,16 @@ const SidebarNav = React.memo(function SidebarNav() {
// Why: the setting is opt-out (default true). `!== false` keeps the button
// visible for users whose persisted settings predate this field.
const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false)
+ const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders)
+ const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github')
+ const visibleTaskProviders = React.useMemo(
+ () => normalizeVisibleTaskProviders(rawVisibleTaskProviders),
+ [rawVisibleTaskProviders]
+ )
+ const resolvedDefaultTaskSource = React.useMemo(
+ () => resolveVisibleTaskProvider(defaultTaskSource, visibleTaskProviders),
+ [defaultTaskSource, visibleTaskProviders]
+ )
// Why: warm the GitHub work-item cache on hover/focus so by the time the
// user's click finishes the round-trip has either completed or is already
@@ -29,7 +44,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const activeRepoId = useAppStore((s) => s.activeRepoId)
const defaultTaskViewPreset = useAppStore((s) => s.settings?.defaultTaskViewPreset ?? 'all')
const handlePrefetch = React.useCallback(() => {
- if (!canBrowseTasks) {
+ if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') {
return
}
const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null
@@ -47,7 +62,15 @@ const SidebarNav = React.memo(function SidebarNav() {
getTaskPresetQuery(defaultTaskViewPreset)
)
}
- }, [activeRepoId, canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, repos])
+ }, [
+ activeRepoId,
+ canBrowseTasks,
+ defaultTaskViewPreset,
+ prefetchWorkItems,
+ repoMap,
+ repos,
+ resolvedDefaultTaskSource
+ ])
const tasksActive = activeView === 'tasks'
const automationsActive = activeView === 'automations'
@@ -77,6 +100,15 @@ const SidebarNav = React.memo(function SidebarNav() {
count += 1
}
}
+ for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) {
+ const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
+ if (!entry) {
+ continue
+ }
+ if ((s.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0) < entry.stateStartedAt) {
+ count += 1
+ }
+ }
return count
})
@@ -109,49 +141,57 @@ const SidebarNav = React.memo(function SidebarNav() {
/>
Tasks
- {
- e.stopPropagation()
- if (!canBrowseTasks) {
- return
- }
- openTaskPage({ taskSource: 'github' })
- }}
- className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
- >
-
-
- {
- e.stopPropagation()
- if (!canBrowseTasks) {
- return
- }
- openTaskPage({ taskSource: 'gitlab' })
- }}
- className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
- aria-label="Open GitLab tasks"
- >
-
-
- {
- e.stopPropagation()
- if (!canBrowseTasks) {
- return
- }
- openTaskPage({ taskSource: 'linear' })
- }}
- className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
- >
-
-
+ {visibleTaskProviders.includes('github') ? (
+ {
+ e.stopPropagation()
+ if (!canBrowseTasks) {
+ return
+ }
+ openTaskPage({ taskSource: 'github' })
+ }}
+ className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
+ aria-label="Open GitHub tasks"
+ >
+
+
+ ) : null}
+ {visibleTaskProviders.includes('gitlab') ? (
+ {
+ e.stopPropagation()
+ if (!canBrowseTasks) {
+ return
+ }
+ openTaskPage({ taskSource: 'gitlab' })
+ }}
+ className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
+ aria-label="Open GitLab tasks"
+ >
+
+
+ ) : null}
+ {visibleTaskProviders.includes('linear') ? (
+ {
+ e.stopPropagation()
+ if (!canBrowseTasks) {
+ return
+ }
+ openTaskPage({ taskSource: 'linear' })
+ }}
+ className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
+ aria-label="Open Linear tasks"
+ >
+
+
+ ) : null}
) : null}
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx
new file mode 100644
index 00000000000..adde53c34c6
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback } from 'react'
+import { Pin } from 'lucide-react'
+import { useAppStore } from '@/store'
+import { Badge } from '@/components/ui/badge'
+import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { activateAndRevealWorktree } from '@/lib/worktree-activation'
+import { getWorktreeStatusLabel } from '@/lib/worktree-status'
+import { cn } from '@/lib/utils'
+import type { Repo, Worktree } from '../../../../shared/types'
+import StatusIndicator from './StatusIndicator'
+import WorktreeCard from './WorktreeCard'
+import WorktreeContextMenu from './WorktreeContextMenu'
+import { useWorktreeActivityStatus } from './use-worktree-activity-status'
+import { writeWorkspaceDragData } from './workspace-status'
+
+type WorkspaceKanbanCardProps = {
+ worktree: Worktree
+ repo: Repo | undefined
+ isActive: boolean
+ compact: boolean
+ onActivate: () => void
+}
+
+export default function WorkspaceKanbanCard({
+ worktree,
+ repo,
+ isActive,
+ compact,
+ onActivate
+}: WorkspaceKanbanCardProps): React.JSX.Element {
+ if (compact) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {worktree.isPinned ? (
+
+
+ Pinned
+
+ ) : null}
+
+
+ )
+}
+
+function WorkspaceKanbanCompactCard({
+ worktree,
+ repo,
+ isActive,
+ onActivate
+}: Omit
): React.JSX.Element {
+ const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
+ const isDeleting = deleteState?.isDeleting ?? false
+ const status = useWorktreeActivityStatus(worktree.id)
+
+ const handleActivate = useCallback(() => {
+ if (isDeleting) {
+ return
+ }
+ activateAndRevealWorktree(worktree.id)
+ onActivate()
+ }, [isDeleting, onActivate, worktree.id])
+
+ const handleDragStart = useCallback(
+ (event: React.DragEvent) => {
+ if (isDeleting) {
+ event.preventDefault()
+ return
+ }
+ writeWorkspaceDragData(event.dataTransfer, worktree.id)
+ },
+ [isDeleting, worktree.id]
+ )
+
+ return (
+
+
+
+
+
+ {getWorktreeStatusLabel(status)}
+ {worktree.displayName}
+ {repo ? (
+
+
+
+
+
+ {repo.displayName}
+
+
+
+
+ {repo.displayName}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
new file mode 100644
index 00000000000..9fb88777aa6
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
@@ -0,0 +1,433 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useAppStore } from '@/store'
+import { useAllWorktrees, useRepoMap } from '@/store/selectors'
+import { cn } from '@/lib/utils'
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle
+} from '@/components/ui/sheet'
+import { Button } from '@/components/ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { LayoutList, Pin, Rows3, X } from 'lucide-react'
+import WorkspaceKanbanCard from './WorkspaceKanbanCard'
+import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
+import {
+ getWorkspaceStatus,
+ hasWorkspaceDragData,
+ readWorkspaceDragData,
+ getWorkspaceStatusVisualMeta
+} from './workspace-status'
+import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop'
+import type { WorkspaceStatus, Worktree } from '../../../../shared/types'
+import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
+
+type WorkspaceKanbanDrawerProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onPointerEnter?: React.PointerEventHandler
+}
+
+function sortBoardWorktrees(a: Worktree, b: Worktree): number {
+ return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName)
+}
+
+export default function WorkspaceKanbanDrawer({
+ open,
+ onOpenChange,
+ onPointerEnter
+}: WorkspaceKanbanDrawerProps): React.JSX.Element {
+ const allWorktrees = useAllWorktrees()
+ const repoMap = useRepoMap()
+ const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
+ const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
+ const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
+ const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses)
+ const workspaceBoardOpacity = useAppStore((s) => s.workspaceBoardOpacity)
+ const setWorkspaceBoardOpacity = useAppStore((s) => s.setWorkspaceBoardOpacity)
+ const workspaceBoardCompact = useAppStore((s) => s.workspaceBoardCompact)
+ const setWorkspaceBoardCompact = useAppStore((s) => s.setWorkspaceBoardCompact)
+ const sidebarOpen = useAppStore((s) => s.sidebarOpen)
+ const sidebarWidth = useAppStore((s) => s.sidebarWidth)
+ const boardRef = useRef(null)
+ const [dragOverStatus, setDragOverStatus] = useState(null)
+ const [pinDragOver, setPinDragOver] = useState(false)
+
+ const worktreesByStatus = useMemo(() => {
+ const grouped = new Map(
+ workspaceStatuses.map((status) => [status.id, []])
+ )
+ for (const worktree of allWorktrees) {
+ if (worktree.isArchived) {
+ continue
+ }
+ grouped.get(getWorkspaceStatus(worktree, workspaceStatuses))!.push(worktree)
+ }
+ for (const items of grouped.values()) {
+ items.sort((a, b) => Number(b.isPinned) - Number(a.isPinned) || sortBoardWorktrees(a, b))
+ }
+ return grouped
+ }, [allWorktrees, workspaceStatuses])
+
+ const moveWorktreeToStatus = useCallback(
+ (worktreeId: string, status: WorkspaceStatus) => {
+ const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
+ if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
+ return
+ }
+ void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
+ },
+ [allWorktrees, updateWorktreeMeta, workspaceStatuses]
+ )
+
+ const pinWorktree = useCallback(
+ (worktreeId: string) => {
+ const current = allWorktrees.find((worktree) => worktree.id === worktreeId)
+ if (!current || current.isPinned) {
+ return
+ }
+ void updateWorktreeMeta(worktreeId, { isPinned: true })
+ },
+ [allWorktrees, updateWorktreeMeta]
+ )
+
+ const handleDragOver = useCallback((event: React.DragEvent, status: WorkspaceStatus) => {
+ if (!hasWorkspaceDragData(event.dataTransfer)) {
+ return
+ }
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ setDragOverStatus(status)
+ }, [])
+
+ const handleDragLeave = useCallback((event: React.DragEvent) => {
+ const relatedTarget = event.relatedTarget
+ if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) {
+ return
+ }
+ setDragOverStatus(null)
+ }, [])
+
+ const handlePinDragOver = useCallback((event: React.DragEvent) => {
+ if (!hasWorkspaceDragData(event.dataTransfer)) {
+ return
+ }
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ setPinDragOver(true)
+ }, [])
+
+ const handlePinDragLeave = useCallback((event: React.DragEvent) => {
+ const relatedTarget = event.relatedTarget
+ if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) {
+ return
+ }
+ setPinDragOver(false)
+ }, [])
+
+ const handleDragFinish = useCallback(() => {
+ setDragOverStatus(null)
+ setPinDragOver(false)
+ }, [])
+
+ const handleDrop = useCallback(
+ (event: React.DragEvent, status: WorkspaceStatus) => {
+ const worktreeId = readWorkspaceDragData(event.dataTransfer)
+ if (!worktreeId) {
+ return
+ }
+ event.preventDefault()
+ setDragOverStatus(null)
+ moveWorktreeToStatus(worktreeId, status)
+ },
+ [moveWorktreeToStatus]
+ )
+
+ const handleWorktreeActivate = useCallback(() => {
+ onOpenChange(false)
+ }, [onOpenChange])
+
+ const handleOpacityChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setWorkspaceBoardOpacity(Number(event.target.value) / 100)
+ },
+ [setWorkspaceBoardOpacity]
+ )
+
+ const handleRenameStatus = useCallback(
+ (statusId: string, label: string) => {
+ const trimmed = label.trim()
+ if (!trimmed) {
+ return
+ }
+ setWorkspaceStatuses(
+ workspaceStatuses.map((status) =>
+ status.id === statusId ? { ...status, label: trimmed } : status
+ )
+ )
+ },
+ [setWorkspaceStatuses, workspaceStatuses]
+ )
+
+ const handleChangeStatusColor = useCallback(
+ (statusId: string, color: string) => {
+ setWorkspaceStatuses(
+ workspaceStatuses.map((status) => (status.id === statusId ? { ...status, color } : status))
+ )
+ },
+ [setWorkspaceStatuses, workspaceStatuses]
+ )
+
+ const handleChangeStatusIcon = useCallback(
+ (statusId: string, icon: string) => {
+ setWorkspaceStatuses(
+ workspaceStatuses.map((status) => (status.id === statusId ? { ...status, icon } : status))
+ )
+ },
+ [setWorkspaceStatuses, workspaceStatuses]
+ )
+
+ const handleMoveStatus = useCallback(
+ (statusId: string, direction: -1 | 1) => {
+ const index = workspaceStatuses.findIndex((status) => status.id === statusId)
+ const nextIndex = index + direction
+ if (index === -1 || nextIndex < 0 || nextIndex >= workspaceStatuses.length) {
+ return
+ }
+ const next = [...workspaceStatuses]
+ const [moved] = next.splice(index, 1)
+ next.splice(nextIndex, 0, moved)
+ setWorkspaceStatuses(next)
+ },
+ [setWorkspaceStatuses, workspaceStatuses]
+ )
+
+ const handleAddStatus = useCallback(() => {
+ const label = `Status ${workspaceStatuses.length + 1}`
+ setWorkspaceStatuses([
+ ...workspaceStatuses,
+ { id: makeWorkspaceStatusId(label, workspaceStatuses), label }
+ ])
+ }, [setWorkspaceStatuses, workspaceStatuses])
+
+ const handleRemoveStatus = useCallback(
+ (statusId: string) => {
+ if (workspaceStatuses.length <= 1) {
+ return
+ }
+ const index = workspaceStatuses.findIndex((status) => status.id === statusId)
+ if (index === -1) {
+ return
+ }
+ const next = workspaceStatuses.filter((status) => status.id !== statusId)
+ const fallbackStatus = next[Math.min(index, next.length - 1)]?.id ?? next[0]!.id
+ setWorkspaceStatuses(next)
+ for (const worktree of allWorktrees) {
+ if (getWorkspaceStatus(worktree, workspaceStatuses) === statusId) {
+ void updateWorktreeMeta(worktree.id, { workspaceStatus: fallbackStatus })
+ }
+ }
+ },
+ [allWorktrees, setWorkspaceStatuses, updateWorktreeMeta, workspaceStatuses]
+ )
+
+ useWorkspaceStatusDocumentDrop(
+ boardRef,
+ moveWorktreeToStatus,
+ pinWorktree,
+ handleDragFinish,
+ open
+ )
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+
+ const handlePointerDown = (event: PointerEvent): void => {
+ const content = boardRef.current?.closest('[data-slot="sheet-content"]')
+ if (!content) {
+ return
+ }
+ const target = event.target
+ if (target instanceof Node && content.contains(target)) {
+ return
+ }
+ const rect = content.getBoundingClientRect()
+ if (event.clientX > rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) {
+ onOpenChange(false)
+ }
+ }
+
+ document.addEventListener('pointerdown', handlePointerDown, true)
+ return () => document.removeEventListener('pointerdown', handlePointerDown, true)
+ }, [onOpenChange, open])
+
+ const opacityPercent = Math.round(workspaceBoardOpacity * 100)
+ const drawerLeft = sidebarOpen ? sidebarWidth : 0
+ const BoardModeIcon = workspaceBoardCompact ? Rows3 : LayoutList
+
+ return (
+
+ {
+ // Why: Radix focuses the first toolbar button on open, which opens
+ // its tooltip without hover and makes the drawer feel noisy.
+ event.preventDefault()
+ }}
+ onInteractOutside={(event) => {
+ const originalEvent = event.detail.originalEvent
+ if (originalEvent instanceof PointerEvent && originalEvent.clientX < drawerLeft) {
+ // Why: users need to scroll, click, and drag from the workspace
+ // sidebar while the companion board stays open.
+ event.preventDefault()
+ }
+ }}
+ >
+
+ Workspace board
+
+ Organize workspaces by status and open workspace cards.
+
+
+
+
+
+
+ setWorkspaceBoardCompact(!workspaceBoardCompact)}
+ >
+
+
+
+
+ {workspaceBoardCompact ? 'Show detailed cards' : 'Show compact cards'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pinned
+
Drop here to pin without changing status.
+
+
+
+
+ {workspaceStatuses.map((status) => {
+ const meta = getWorkspaceStatusVisualMeta(status)
+ const items = worktreesByStatus.get(status.id) ?? []
+ const isDragTarget = dragOverStatus === status.id
+
+ return (
+
handleDragOver(event, status.id)}
+ onDragLeave={handleDragLeave}
+ onDrop={(event) => handleDrop(event, status.id)}
+ >
+
+
+
+ {status.label}
+
+
+ {items.length}
+
+
+
+
+ {items.length > 0 ? (
+
+ {items.map((worktree) => (
+
+ ))}
+
+ ) : (
+
+ Empty
+
+ )}
+
+
+ )
+ })}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx
new file mode 100644
index 00000000000..dfc85908acc
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx
@@ -0,0 +1,156 @@
+import React from 'react'
+import { ArrowDown, ArrowUp, Plus, Settings2, Trash2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
+import { cn } from '@/lib/utils'
+import type { WorkspaceStatusDefinition } from '../../../../shared/types'
+import { getWorkspaceStatusVisualMeta } from './workspace-status'
+import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover'
+
+type WorkspaceKanbanSettingsMenuProps = {
+ opacityPercent: number
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
+ onOpacityChange: (event: React.ChangeEvent) => void
+ onRenameStatus: (statusId: string, label: string) => void
+ onChangeStatusColor: (statusId: string, color: string) => void
+ onChangeStatusIcon: (statusId: string, icon: string) => void
+ onMoveStatus: (statusId: string, direction: -1 | 1) => void
+ onRemoveStatus: (statusId: string) => void
+ onAddStatus: () => void
+}
+
+export default function WorkspaceKanbanSettingsMenu({
+ opacityPercent,
+ workspaceStatuses,
+ onOpacityChange,
+ onRenameStatus,
+ onChangeStatusColor,
+ onChangeStatusIcon,
+ onMoveStatus,
+ onRemoveStatus,
+ onAddStatus
+}: WorkspaceKanbanSettingsMenuProps): React.JSX.Element {
+ return (
+
+
+
+
+
+
+ {
+ const target = event.target
+ if (
+ target instanceof Element &&
+ target.closest('[data-workspace-status-appearance-popover]')
+ ) {
+ event.preventDefault()
+ }
+ }}
+ >
+ Board opacity
+
+
+ Pane opacity
+ {opacityPercent}%
+
+
+
+
+ Statuses
+
+ {workspaceStatuses.map((status, index) => {
+ const meta = getWorkspaceStatusVisualMeta(status)
+ return (
+
+
+
+
onRenameStatus(status.id, event.target.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === 'Enter') {
+ event.currentTarget.blur()
+ }
+ }}
+ className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-[12px] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
+ aria-label={`Rename ${status.label}`}
+ />
+
+
onMoveStatus(status.id, -1)}
+ aria-label={`Move ${status.label} left`}
+ >
+
+
+
onMoveStatus(status.id, 1)}
+ aria-label={`Move ${status.label} right`}
+ >
+
+
+
onRemoveStatus(status.id)}
+ aria-label={`Remove ${status.label}`}
+ >
+
+
+
+
+ )
+ })}
+
+
+ Add status
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx b/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx
new file mode 100644
index 00000000000..cce501faf84
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorkspaceStatusAppearancePopover.tsx
@@ -0,0 +1,106 @@
+import React from 'react'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import type { WorkspaceStatusDefinition } from '../../../../shared/types'
+import {
+ WORKSPACE_STATUS_COLOR_OPTIONS,
+ WORKSPACE_STATUS_ICON_OPTIONS,
+ getWorkspaceStatusVisualMeta
+} from './workspace-status'
+
+type WorkspaceStatusAppearancePopoverProps = {
+ status: WorkspaceStatusDefinition
+ onChangeColor: (statusId: string, color: string) => void
+ onChangeIcon: (statusId: string, icon: string) => void
+}
+
+export default function WorkspaceStatusAppearancePopover({
+ status,
+ onChangeColor,
+ onChangeIcon
+}: WorkspaceStatusAppearancePopoverProps): React.JSX.Element {
+ const meta = getWorkspaceStatusVisualMeta(status)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Appearance
+
+
+
+ event.preventDefault()}
+ >
+ Color
+
+ {WORKSPACE_STATUS_COLOR_OPTIONS.map((color) => (
+
+
+ onChangeColor(status.id, color.id)}
+ aria-label={`Set ${status.label} color to ${color.label}`}
+ >
+
+ {color.label}
+
+
+
+ {color.label}
+
+
+ ))}
+
+
+ Icon
+
+ {WORKSPACE_STATUS_ICON_OPTIONS.map((icon) => (
+
+
+ onChangeIcon(status.id, icon.id)}
+ aria-label={`Set ${status.label} icon to ${icon.label}`}
+ >
+
+
+
+
+ {icon.label}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts
index 24aa69d6315..17f28248c1c 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts
+++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
vi.mock('@/lib/agent-status', () => ({
@@ -10,10 +11,15 @@ vi.mock('@/lib/agent-status', () => ({
return 'working'
}
return null
- })
+ }),
+ isExplicitAgentStatusFresh: vi.fn(
+ (entry: AgentStatusEntry, now: number, staleAfterMs: number) =>
+ now - entry.updatedAt <= staleAfterMs
+ )
}))
import { getWorktreeStatus } from '@/lib/worktree-status'
+import { deriveWorktreeCardStatus } from './worktree-card-status'
function makeTerminalTab(title: string): TerminalTab {
return {
@@ -28,6 +34,22 @@ function makeTerminalTab(title: string): TerminalTab {
}
}
+function makeAgentStatusEntry(args: {
+ paneKey: string
+ state: AgentStatusEntry['state']
+ updatedAt?: number
+}): AgentStatusEntry {
+ const updatedAt = args.updatedAt ?? 1_000
+ return {
+ paneKey: args.paneKey,
+ state: args.state,
+ prompt: '',
+ updatedAt,
+ stateStartedAt: updatedAt,
+ stateHistory: []
+ }
+}
+
describe('getWorktreeStatus', () => {
it('treats browser-only worktrees as active', () => {
expect(getWorktreeStatus([], [{ id: 'browser-1' }], {})).toBe('active')
@@ -45,3 +67,38 @@ describe('getWorktreeStatus', () => {
).toBe('working')
})
})
+
+describe('deriveWorktreeCardStatus', () => {
+ it('keeps split-pane heuristics for panes without fresh explicit status', () => {
+ const status = deriveWorktreeCardStatus({
+ tabs: [makeTerminalTab('claude [done]')],
+ browserTabs: [],
+ worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:1', state: 'done' })],
+ runtimePaneTitlesByTabId: {
+ 'tab-1': {
+ 1: 'claude [done]',
+ 2: 'codex [working]'
+ }
+ },
+ now: 1_000
+ })
+
+ expect(status).toBe('working')
+ })
+
+ it('lets fresh explicit status win over the matching pane title heuristic', () => {
+ const status = deriveWorktreeCardStatus({
+ tabs: [makeTerminalTab('codex [working]')],
+ browserTabs: [],
+ worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:2', state: 'done' })],
+ runtimePaneTitlesByTabId: {
+ 'tab-1': {
+ 2: 'codex [working]'
+ }
+ },
+ now: 1_000
+ })
+
+ expect(status).toBe('done')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx
index 966963eccc7..7b73a95ee27 100644
--- a/src/renderer/src/components/sidebar/WorktreeCard.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx
@@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: the worktree card centralizes sidebar card state (selection, drag, agent status, git info, context menu) in one cohesive component so sidebar rendering doesn't fan out across files. */
-import React, { useEffect, useMemo, useCallback, useState } from 'react'
-import { useShallow } from 'zustand/react/shallow'
+import React, { useEffect, useCallback, useState } from 'react'
import { useAppStore } from '@/store'
+import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
@@ -24,13 +24,7 @@ import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import WorktreeCardAgents from './WorktreeCardAgents'
import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
-import {
- getWorktreeStatusLabel,
- resolveWorktreeStatus,
- type WorktreeStatus
-} from '@/lib/worktree-status'
-import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
-import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
+import { getWorktreeStatusLabel } from '@/lib/worktree-status'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Worktree, Repo, IssueInfo } from '../../../../shared/types'
@@ -38,15 +32,11 @@ import {
branchDisplayName,
checksLabel,
CONFLICT_OPERATION_LABELS,
- EMPTY_TABS,
- EMPTY_BROWSER_TABS,
FilledBellIcon
} from './WorktreeCardHelpers'
import { IssueSection, ReviewSection, CommentSection } from './WorktreeCardMeta'
-import {
- selectLivePtyIdsForWorktree,
- selectRuntimePaneTitlesForWorktree
-} from './worktree-card-status-inputs'
+import { writeWorkspaceDragData } from './workspace-status'
+import { useWorktreeActivityStatus } from './use-worktree-activity-status'
type WorktreeCardProps = {
worktree: Worktree
@@ -61,6 +51,7 @@ type WorktreeCardProps = {
lineageCollapsed?: boolean
lineageChildren?: React.ReactNode
onLineageToggle?: (event: React.MouseEvent) => void
+ onActivate?: () => void
onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean
onContextMenuSelect?: (event: React.MouseEvent) => readonly Worktree[]
}
@@ -76,6 +67,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
isActive,
isMultiSelected = false,
selectedWorktrees,
+ onActivate,
onSelectionGesture,
onContextMenuSelect,
hideRepoBadge,
@@ -89,6 +81,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const openModal = useAppStore((s) => s.openModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
+ const settings = useAppStore((s) => s.settings)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const handleEditIssue = useCallback(
@@ -165,33 +158,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
repo?.connectionId ? (s.sshTargetLabels.get(repo.connectionId) ?? '') : ''
)
- // ── GRANULAR selectors: only subscribe to THIS worktree's data ──
- const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
- const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS)
- const hasNotesSurface = useAppStore((s) =>
- (s.unifiedTabsByWorktree[worktree.id] ?? []).some((tab) => tab.contentType === 'notes')
- )
- // Why: keep these as separate shallow selectors. Combining them into one
- // returned object nests freshly-created maps under fresh keys, so Zustand's
- // shallow memoization sees every unrelated store write as a change and
- // re-renders every mounted card.
- // tab.ptyId is the wake-hint sessionId preserved across sleep, not a
- // liveness signal; sleep-then-card-render would lie the dot green if we
- // read tab.ptyId; ptyIdsByTabId is the source of truth.
- // tab.title is the focused-pane title (onActivePaneChange overwrites it),
- // so the per-pane title map is needed to keep the sidebar spinner
- // reflecting *any* working pane, not just the focused one.
- const runtimePaneTitlesForWorktree = useAppStore(
- useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktree.id))
- )
- const ptyIdsForWorktree = useAppStore(
- useShallow((s) => selectLivePtyIdsForWorktree(s, worktree.id))
- )
-
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
- const hostedReviewCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
- const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
+ const hostedReviewCacheKey =
+ repo && branch ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id) : ''
+ const issueCacheKey = repo && worktree.linkedIssue ? `${repo.id}::${worktree.linkedIssue}` : ''
// Subscribe to ONLY the specific cache entry, not entire review/issue caches.
const hostedReviewEntry = useAppStore((s) =>
@@ -219,109 +190,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
: null)
const isDeleting = deleteState?.isDeleting ?? false
- // Why: the sidebar dot overlays the *stable* hook-reported states (blocked,
- // waiting, done) onto the title-heuristic base. `working` remains on the
- // heuristic because hook pings flip on/off mid-turn and users complained
- // that the spinner flickered; the blocked/waiting/done states don't have
- // that problem — they're terminal (done) or attention-needed (blocked/
- // waiting) and persist until the user acts. Retained "done" snapshots are
- // consulted too so the done dot keeps glowing after the agent process exits,
- // matching the dashboard's retention behavior.
- //
- // Priority (highest first): permission (blocked/waiting) > heuristic
- // 'working' > done > other heuristic ('active'/'inactive').
- // permission wins over everything because a newer blocked agent in the same
- // worktree means the user needs to act now, not admire a previous
- // completion.
- // heuristic 'working' wins over done because a spinner means the user has
- // already re-prompted the agent after it reported done — the newer "work
- // in progress" signal is more informative than a retained completion dot.
- // Only the 'working' heuristic earns this precedence; 'active'/'inactive'
- // mean "quiet terminal", which shouldn't drown out a recent done.
- // Why: collapse live hook entries to booleans inside the selector so the
- // snapshot is a stable scalar (useShallow compares element identity — an
- // array of freshly-constructed {state,updatedAt} objects would never hit
- // the cache and trip React's "getSnapshot should be cached" infinite-loop
- // guard). Staleness is applied here too so the selector already reflects
- // the 30-min TTL; agentStatusEpoch pulls in the tick that fires when a
- // fresh entry crosses the stale boundary. The same useShallow wrapper
- // covers hasPermission, hasLiveDone, *and* hasRetainedDone — merging the
- // retained scan into the same selector avoids a second full-map iteration
- // per card on every retention write (with N sidebar cards on screen a
- // standalone retained selector scans Object.values(...) N times per tick).
- const { hasPermission, hasLiveDone, hasRetainedDone } = useAppStore(
- useShallow((s) => {
- // Touch the epoch so this selector re-runs when the freshness scheduler
- // ticks — otherwise a stale transition wouldn't flip the booleans until
- // some unrelated store write happened to rerun us.
- void s.agentStatusEpoch
- const wtTabs = s.tabsByWorktree[worktree.id] ?? EMPTY_TABS
- let perm = false
- let live = false
- if (wtTabs.length > 0) {
- const tabIds = new Set(wtTabs.map((t) => t.id))
- const now = Date.now()
- for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
- const sepIdx = paneKey.indexOf(':')
- if (sepIdx <= 0) {
- continue
- }
- const tabId = paneKey.slice(0, sepIdx)
- if (!tabIds.has(tabId)) {
- continue
- }
- if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
- continue
- }
- if (entry.state === 'blocked' || entry.state === 'waiting') {
- perm = true
- } else if (entry.state === 'done') {
- live = true
- }
- }
- }
- // Retained scan — one pass in the same selector avoids a second
- // Object.values(...) per store tick (one per sidebar card).
- let retained = false
- for (const ra of Object.values(s.retainedAgentsByPaneKey)) {
- if (ra.worktreeId === worktree.id) {
- retained = true
- break
- }
- }
- return { hasPermission: perm, hasLiveDone: live, hasRetainedDone: retained }
- })
- )
-
- // Why: resolveWorktreeStatus enforces the runtime-liveness precondition —
- // when no tab in this worktree has a live PTY (and no browser tab exists)
- // it short-circuits to 'inactive' before consulting hook-reported state or
- // retained-done snapshots. That keeps the dot honest across sleep, crash,
- // and slept-with-retained-done while preserving the row data used by the
- // inline agents list (retained 'done' rows still render inside the card).
- const status: WorktreeStatus = useMemo(
- () =>
- resolveWorktreeStatus({
- tabs,
- browserTabs,
- ptyIdsByTabId: ptyIdsForWorktree,
- runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
- hasNotesSurface,
- hasPermission,
- hasLiveDone,
- hasRetainedDone
- }),
- [
- tabs,
- browserTabs,
- ptyIdsForWorktree,
- runtimePaneTitlesForWorktree,
- hasNotesSurface,
- hasPermission,
- hasLiveDone,
- hasRetainedDone
- ]
- )
+ const status = useWorktreeActivityStatus(worktree.id)
const showPR = cardProps.includes('pr')
const showCI = cardProps.includes('ci')
@@ -331,18 +200,12 @@ const WorktreeCard = React.memo(function WorktreeCard({
// This preference is purely presentational, so background refreshes would
// spend rate limit budget on data the user cannot see.
useEffect(() => {
- if (
- repo &&
- !repo.connectionId &&
- !isFolder &&
- !worktree.isBare &&
- hostedReviewCacheKey &&
- (showPR || showCI)
- ) {
+ if (repo && !isFolder && !worktree.isBare && hostedReviewCacheKey && (showPR || showCI)) {
// Why: pass linkedPR so worktrees created from a PR (whose new local
// branch differs from the remote head ref) still resolve their PR/MR via
// a number-based fallback in the main process.
fetchHostedReviewForBranch(repo.path, branch, {
+ repoId: repo.id,
linkedGitHubPR: worktree.linkedPR ?? null,
linkedGitLabMR: worktree.linkedGitLabMR ?? null
})
@@ -367,11 +230,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
return
}
- fetchIssue(repo.path, worktree.linkedIssue)
+ fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
// Background poll as fallback (activity triggers handle the fast path)
const interval = setInterval(() => {
- fetchIssue(repo.path, worktree.linkedIssue!)
+ fetchIssue(repo.path, worktree.linkedIssue!, { repoId: repo.id })
}, 5 * 60_000) // 5 minutes
return () => clearInterval(interval)
@@ -411,8 +274,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
if (isSshDisconnected) {
setShowDisconnectedDialog(true)
}
+ onActivate?.()
},
- [worktree.id, isSshDisconnected, onSelectionGesture]
+ [worktree.id, isSshDisconnected, onActivate, onSelectionGesture]
)
const handleDoubleClick = useCallback(() => {
@@ -450,6 +314,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
}`
const showLineageChildChip = lineageChildCount > 0 && onLineageToggle !== undefined
+ const handleDragStart = useCallback(
+ (event: React.DragEvent) => {
+ if (isDeleting) {
+ event.preventDefault()
+ return
+ }
+ writeWorkspaceDragData(event.dataTransfer, worktree.id)
+ },
+ [isDeleting, worktree.id]
+ )
+
// Why: the 'unread' card property is the user's opt-out. When off, we render
// as if the workspace is read so bold emphasis never appears. The persisted
// `worktree.isUnread` flag is unchanged; only the rendering changes.
@@ -471,6 +346,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
+ draggable={!isDeleting}
+ onDragStart={handleDragStart}
aria-busy={isDeleting}
>
{isDeleting && (
@@ -604,9 +481,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
- {/* CI Checks & PR state on the right */}
- {cardProps.includes('ci') && hostedReview && hostedReview.status !== 'neutral' && (
-
+
+ {/* CI Checks & PR state on the right */}
+ {cardProps.includes('ci') && hostedReview && hostedReview.status !== 'neutral' && (
@@ -625,8 +502,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
CI checks {checksLabel(hostedReview.status).toLowerCase()}
-
- )}
+ )}
+
{/* Subtitle row: Repo badge + Branch */}
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
index 4d9c5f609de..8499ea1de4c 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx
@@ -7,6 +7,8 @@ import { useNow } from '@/components/dashboard/useNow'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { cn } from '@/lib/utils'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { dismissStaleAgentRowByKey } from '../terminal-pane/stale-agent-row'
type Props = {
worktreeId: string
@@ -51,7 +53,6 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}: BodyProps) {
const dropAgentStatus = useAppStore((s) => s.dropAgentStatus)
const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent)
- const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
// Why: subscribe to the ack map reference (Object.is equality) and derive
// per-agent unvisited flags locally. Keeps the inline list's bold/mute
@@ -79,18 +80,21 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
const handleActivateAgentTab = useCallback(
(tabId: string, paneKey: string) => {
- acknowledgeAgents([paneKey])
- const colon = paneKey.indexOf(':')
- const tail = colon > 0 ? paneKey.slice(colon + 1) : ''
- const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN
- let paneId: number | null = null
- if (Number.isFinite(parsed) && parsed > 0) {
- paneId = parsed
- } else {
- // Why: paneKey for sidebar agent rows is always ${tabId}:${paneId}
- // with a positive integer paneId; anything else (empty, zero,
- // non-numeric) means upstream row construction drifted.
+ const parsed = parsePaneKey(paneKey)
+ if (!parsed) {
+ // Why: malformed or legacy numeric keys cannot be resolved safely after
+ // pane replay/remount, so drop the stale row instead of guessing.
console.warn('[WorktreeCardAgents] malformed paneKey, skipping pane focus', paneKey)
+ dismissStaleAgentRowByKey(paneKey)
+ return
+ }
+ if (parsed.tabId !== tabId) {
+ console.warn('[WorktreeCardAgents] paneKey tabId mismatch, dismissing row', {
+ tabId,
+ paneKey
+ })
+ dismissStaleAgentRowByKey(paneKey)
+ return
}
// Why: route through activateAndRevealWorktree so cross-repo clicks also
// set activeRepoId, record a nav-history entry, clear sidebar filters,
@@ -102,10 +106,12 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
activateAndRevealWorktree(worktreeId)
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.some((t) => t.id === tabId)) {
- activateTabAndFocusPane(tabId, paneId)
+ activateTabAndFocusPane(tabId, parsed.leafId, { ackPaneKeyOnSuccess: paneKey })
+ } else {
+ dismissStaleAgentRowByKey(paneKey)
}
},
- [worktreeId, acknowledgeAgents]
+ [worktreeId]
)
// Why: own one 30s tick per non-empty inline list. Cards with zero agents
diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
index 59ce508b06d..dc9ef9d5ae4 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx
@@ -109,6 +109,9 @@ function getProviderName(review: HostedReviewInfo): string {
if (review.provider === 'bitbucket') {
return 'Bitbucket'
}
+ if (review.provider === 'gitea') {
+ return 'Gitea'
+ }
return 'GitHub'
}
diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
index b05c47b6881..9a4f4c4f747 100644
--- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
@@ -4,12 +4,16 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
- FolderOpen,
Copy,
Bell,
BellOff,
@@ -19,6 +23,7 @@ import {
Pencil,
Pin,
PinOff,
+ Kanban,
Trash2,
Unlink,
Workflow
@@ -32,6 +37,8 @@ import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flo
import { runSleepWorktrees } from './sleep-worktree-flow'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getLineageRenderInfo } from './worktree-list-groups'
+import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status'
+import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
type Props = {
worktree: Worktree
@@ -76,6 +83,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
onContextMenuSelect
}: Props) {
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
+ const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const openModal = useAppStore((s) => s.openModal)
const repo = useRepoById(worktree.repoId)
const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id])
@@ -109,6 +117,16 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
() => activeContextWorktrees.some((item) => deleteStateByWorktreeId[item.id]?.isDeleting),
[activeContextWorktrees, deleteStateByWorktreeId]
)
+ const contextWorkspaceStatus = useMemo(() => {
+ const [first, ...rest] = activeContextWorktrees
+ if (!first) {
+ return ''
+ }
+ const status = getWorkspaceStatus(first, workspaceStatuses)
+ return rest.every((item) => getWorkspaceStatus(item, workspaceStatuses) === status)
+ ? status
+ : ''
+ }, [activeContextWorktrees, workspaceStatuses])
const batchDeleteWorktrees = useMemo(
() =>
activeContextWorktrees.filter((item) => {
@@ -141,10 +159,6 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
- const handleOpenInFinder = useCallback(() => {
- window.api.shell.openPath(worktree.path)
- }, [worktree.path])
-
const handleCopyPath = useCallback(() => {
window.api.ui.writeClipboardText(worktree.path)
}, [worktree.path])
@@ -157,6 +171,20 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
updateWorktreeMeta(worktree.id, { isPinned: !worktree.isPinned })
}, [worktree.id, worktree.isPinned, updateWorktreeMeta])
+ const handleAssignWorkspaceStatus = useCallback(
+ (status: string) => {
+ setMenuOpen(false)
+ void Promise.all(
+ activeContextWorktrees.map((item) =>
+ getWorkspaceStatus(item, workspaceStatuses) === status
+ ? Promise.resolve()
+ : updateWorktreeMeta(item.id, { workspaceStatus: status })
+ )
+ )
+ },
+ [activeContextWorktrees, updateWorktreeMeta, workspaceStatuses]
+ )
+
const handleRename = useCallback(() => {
openModal('edit-meta', {
worktreeId: worktree.id,
@@ -320,10 +348,11 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
>
{!isMultiContext && (
<>
-
-
- Open in Finder
-
+
Copy Path
@@ -381,6 +410,30 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
>
)}
+
+
+
+ {isMultiContext ? 'Move Selected to Status' : 'Move to Status'}
+
+
+
+ {workspaceStatuses.map((status) => {
+ const meta = getWorkspaceStatusVisualMeta(status)
+ return (
+ handleAssignWorkspaceStatus(status.id)}
+ >
+
+ {status.label}
+
+ )
+ })}
+
+
+
+
void
collapsedGroups: Set
handleCreateForRepo: (repoId: string) => void
@@ -111,6 +128,9 @@ type VirtualizedWorktreeViewportProps = {
allRepoIds: string[]
reorderRepos: (orderedIds: string[]) => void
prCache: Record | null
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
+ onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void
+ onPinWorktree: (worktreeId: string) => void
// Why: the viewport remounts when the row structure changes (see
// viewportResetKey) so the virtualizer's measurementsCache cannot hold
// heights tied to shifted indices. A fresh virtualizer would otherwise
@@ -191,6 +211,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeWorktreeId,
groupBy,
showWorkspaceLineage,
+ repoGroupOrdering,
toggleGroup,
collapsedGroups,
handleCreateForRepo,
@@ -209,13 +230,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
allRepoIds,
reorderRepos,
prCache,
+ workspaceStatuses,
+ onMoveWorktreeToStatus,
+ onPinWorktree,
scrollOffsetRef
}: VirtualizedWorktreeViewportProps) {
const scrollRef = useRef(null)
+ const [dragOverStatus, setDragOverStatus] = useState(null)
+ const [pinDragOver, setPinDragOver] = useState(false)
+ const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
- // Drag is only meaningful when the user is grouping by repo. When inert
- // (groupBy !== 'repo'), the controller is still constructed for hook order
- // stability but the handle is never rendered.
+ // Drag is only meaningful when repo headers are using manual order. The
+ // controller is still constructed for hook order stability when inert.
const repoDrag = useRepoHeaderDrag({
orderedRepoIds: allRepoIds,
onCommit: reorderRepos,
@@ -340,19 +366,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
toggleGroup(PINNED_GROUP_KEY)
}
- } else if (targetWorktree && groupBy !== 'none') {
- const groupKey = getGroupKeyForWorktree(groupBy, targetWorktree, repoMap, prCache)
+ } else if (targetWorktree) {
+ const groupKey = getGroupKeyForWorktree(
+ groupBy,
+ targetWorktree,
+ repoMap,
+ prCache,
+ workspaceStatuses
+ )
if (groupKey && collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
}
- } else if (targetWorktree && groupBy === 'none') {
- // Why: when any worktree is pinned, buildRows emits a sibling "All"
- // header for the unpinned block (see worktree-list-groups.ts). If that
- // header is collapsed, revealing an unpinned target would otherwise
- // leave it hidden — uncollapse it so the card is actually visible.
- if (collapsedGroups.has(ALL_GROUP_KEY)) {
- toggleGroup(ALL_GROUP_KEY)
- }
}
}
@@ -384,7 +408,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
virtualizer,
clearPendingRevealWorktreeId,
toggleGroup,
- collapsedGroups
+ collapsedGroups,
+ workspaceStatuses
])
const prCacheLen = useAppStore((s) => Object.keys(s.prCache).length)
@@ -414,6 +439,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
prCache,
new Set(),
repoOrder,
+ workspaceStatuses,
+ repoGroupOrdering,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
@@ -454,10 +481,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeWorktreeId,
virtualizer,
groupBy,
+ repoGroupOrdering,
worktrees,
repoMap,
prCache,
repoOrder,
+ workspaceStatuses,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
@@ -522,6 +551,77 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
virtualItems.some((item) => item.index === activeWorktreeRowIndex)
? getWorktreeOptionId(activeWorktreeId)
: undefined
+
+ const hasWorkspaceDropTargets = useMemo(
+ () =>
+ groupBy === 'none' ||
+ rows.some((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY),
+ [groupBy, rows]
+ )
+
+ const handleWorkspaceStatusDragOver = useCallback(
+ (event: React.DragEvent, status: WorkspaceStatus) => {
+ if (!hasWorkspaceDragData(event.dataTransfer)) {
+ return
+ }
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ setDragOverStatus(status)
+ },
+ []
+ )
+
+ const handleWorkspaceStatusDragLeave = useCallback((event: React.DragEvent) => {
+ const relatedTarget = event.relatedTarget
+ if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) {
+ return
+ }
+ setDragOverStatus(null)
+ }, [])
+
+ const handleWorkspacePinDragOver = useCallback((event: React.DragEvent) => {
+ if (!hasWorkspaceDragData(event.dataTransfer)) {
+ return
+ }
+ event.preventDefault()
+ event.dataTransfer.dropEffect = 'move'
+ setPinDragOver(true)
+ }, [])
+
+ const handleWorkspacePinDragLeave = useCallback((event: React.DragEvent) => {
+ const relatedTarget = event.relatedTarget
+ if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) {
+ return
+ }
+ setPinDragOver(false)
+ }, [])
+
+ const handleWorkspaceStatusDragFinish = useCallback(() => {
+ setDragOverStatus(null)
+ setPinDragOver(false)
+ }, [])
+
+ const handleWorkspaceStatusDrop = useCallback(
+ (event: React.DragEvent, status: WorkspaceStatus) => {
+ const worktreeId = readWorkspaceDragData(event.dataTransfer)
+ if (!worktreeId) {
+ return
+ }
+ event.preventDefault()
+ setDragOverStatus(null)
+ onMoveWorktreeToStatus(worktreeId, status)
+ },
+ [onMoveWorktreeToStatus]
+ )
+
+ useWorkspaceStatusDocumentDrop(
+ scrollRef,
+ onMoveWorktreeToStatus,
+ onPinWorktree,
+ handleWorkspaceStatusDragFinish,
+ hasWorkspaceDropTargets
+ )
+
return (
- {repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? (
+ {canReorderRepoHeaders &&
+ repoDrag.state.draggingRepoId !== null &&
+ repoDrag.state.dropIndicatorY !== null ? (
handleWorkspaceStatusDragOver(event, headerWorkspaceStatus)
+ : undefined
+ }
+ onDragLeave={
+ isPinnedHeader
+ ? handleWorkspacePinDragLeave
+ : headerWorkspaceStatus
+ ? handleWorkspaceStatusDragLeave
+ : undefined
+ }
+ onDrop={
+ headerWorkspaceStatus
+ ? (event) => handleWorkspaceStatusDrop(event, headerWorkspaceStatus)
+ : undefined
+ }
onClick={() => toggleGroup(row.key)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -593,7 +727,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.icon ? (
repoDrag.onHandlePointerDown(e, repoIdForHeader)
: undefined
}
@@ -858,14 +992,30 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
+ const itemWorkspaceStatus =
+ groupBy === 'none' ? getWorkspaceStatus(row.worktree, workspaceStatuses) : null
+
return (
handleWorkspaceStatusDragOver(event, itemWorkspaceStatus)
+ : undefined
+ }
+ onDragLeave={itemWorkspaceStatus ? handleWorkspaceStatusDragLeave : undefined}
+ onDrop={
+ itemWorkspaceStatus
+ ? (event) => handleWorkspaceStatusDrop(event, itemWorkspaceStatus)
+ : undefined
+ }
>
{renderWorktreeRow(row, false)}
@@ -889,11 +1039,13 @@ const WorktreeList = React.memo(function WorktreeList() {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const groupBy = useAppStore((s) => s.groupBy)
const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage)
+ const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const sortBy = useAppStore((s) => s.sortBy)
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const openModal = useAppStore((s) => s.openModal)
+ const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktreeId = useAppStore((s) => s.pendingRevealWorktreeId)
@@ -903,9 +1055,6 @@ const WorktreeList = React.memo(function WorktreeList() {
const needsTabs = showActiveOnly || sortBy === 'smart'
const tabsByWorktree = useAppStore((s) => (needsTabs ? s.tabsByWorktree : null))
const ptyIdsByTabId = useAppStore((s) => (needsTabs ? s.ptyIdsByTabId : null))
- const unifiedTabsByWorktree = useAppStore((s) =>
- showActiveOnly ? s.unifiedTabsByWorktree : null
- )
const browserTabsByWorktree = useAppStore((s) =>
showActiveOnly ? s.browserTabsByWorktree : null
)
@@ -1034,7 +1183,9 @@ const WorktreeList = React.memo(function WorktreeList() {
state.agentStatusByPaneKey,
state.runtimePaneTitlesByTabId,
state.ptyIdsByTabId,
- now
+ now,
+ state.migrationUnsupportedByPtyId,
+ state.terminalLayoutsByTabId
)
: new Map
()
lastAttentionByWorktreeRef.current = sortBy === 'smart' ? attentionByWorktree : null
@@ -1142,7 +1293,15 @@ const WorktreeList = React.memo(function WorktreeList() {
if (sortBy !== 'smart' || sortedIds.length === 0 || !sessionHasHadPty.current) {
return
}
- void window.api.worktrees.persistSortOrder({ orderedIds: sortedIds })
+ const target = getActiveRuntimeTarget(useAppStore.getState().settings)
+ void (target.kind === 'environment'
+ ? callRuntimeRpc(
+ target,
+ 'worktree.persistSortOrder',
+ { orderedIds: sortedIds },
+ { timeoutMs: 15_000 }
+ )
+ : window.api.worktrees.persistSortOrder({ orderedIds: sortedIds }))
}, [sortedIds, sortBy])
// Flatten, filter, and apply stable sort order via the shared utility so
@@ -1153,7 +1312,6 @@ const WorktreeList = React.memo(function WorktreeList() {
showActiveOnly,
tabsByWorktree,
ptyIdsByTabId,
- unifiedTabsByWorktree,
browserTabsByWorktree,
activeWorktreeId,
hideDefaultBranchWorkspace,
@@ -1168,7 +1326,6 @@ const WorktreeList = React.memo(function WorktreeList() {
repoMap,
tabsByWorktree,
ptyIdsByTabId,
- unifiedTabsByWorktree,
browserTabsByWorktree,
sortedIds,
worktreeMap,
@@ -1180,9 +1337,8 @@ const WorktreeList = React.memo(function WorktreeList() {
const collapsedGroups = useAppStore((s) => s.collapsedGroups)
const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup)
- // Why: header order in groupBy='repo' is bound to state.repos array order so
- // manual reorder is the single source of truth. The Map lets buildRows do an
- // O(1) rank lookup per group without depending on Repo identity.
+ // Why: manual repo header order is bound to state.repos. Recent/Smart derive
+ // header order from the sorted visible worktree stream instead.
const repos = useAppStore((s) => s.repos)
const repoOrder = useMemo(() => {
const map = new Map()
@@ -1191,6 +1347,7 @@ const WorktreeList = React.memo(function WorktreeList() {
}, [repos])
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
const reorderReposAction = useAppStore((s) => s.reorderRepos)
+ const repoGroupOrdering = getRepoGroupOrdering(groupBy, sortBy)
// Build flat row list for rendering
const rows: Row[] = useMemo(
@@ -1202,6 +1359,8 @@ const WorktreeList = React.memo(function WorktreeList() {
prCache,
collapsedGroups,
repoOrder,
+ workspaceStatuses,
+ repoGroupOrdering,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
@@ -1213,6 +1372,8 @@ const WorktreeList = React.memo(function WorktreeList() {
prCache,
collapsedGroups,
repoOrder,
+ workspaceStatuses,
+ repoGroupOrdering,
worktreeLineageById,
worktreeMap,
showWorkspaceLineage
@@ -1345,6 +1506,28 @@ const WorktreeList = React.memo(function WorktreeList() {
[openModal]
)
+ const moveWorktreeToStatus = useCallback(
+ (worktreeId: string, status: WorkspaceStatus) => {
+ const current = worktreeMap.get(worktreeId)
+ if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
+ return
+ }
+ void updateWorktreeMeta(worktreeId, { workspaceStatus: status })
+ },
+ [updateWorktreeMeta, worktreeMap, workspaceStatuses]
+ )
+
+ const pinWorktree = useCallback(
+ (worktreeId: string) => {
+ const current = worktreeMap.get(worktreeId)
+ if (!current || current.isPinned) {
+ return
+ }
+ void updateWorktreeMeta(worktreeId, { isPinned: true })
+ },
+ [updateWorktreeMeta, worktreeMap]
+ )
+
// Why: hideDefaultBranchWorkspace is counted as a filter here so the
// empty-sidebar escape hatch (Clear Filters button below) is reachable when
// it's the only reason the list is empty — otherwise a user whose only
@@ -1398,6 +1581,7 @@ const WorktreeList = React.memo(function WorktreeList() {
activeWorktreeId={selectedSidebarWorktreeId}
groupBy={groupBy}
showWorkspaceLineage={showWorkspaceLineage}
+ repoGroupOrdering={repoGroupOrdering}
toggleGroup={toggleGroup}
collapsedGroups={collapsedGroups}
handleCreateForRepo={handleCreateForRepo}
@@ -1418,6 +1602,9 @@ const WorktreeList = React.memo(function WorktreeList() {
void reorderReposAction(orderedIds)
}}
prCache={prCache}
+ workspaceStatuses={workspaceStatuses}
+ onMoveWorktreeToStatus={moveWorktreeToStatus}
+ onPinWorktree={pinWorktree}
scrollOffsetRef={sidebarScrollOffsetRef}
/>
)
diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
index 5abb326083e..b400ad0109b 100644
--- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx
@@ -10,14 +10,27 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
-import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links'
+import { ExternalLink, LoaderCircle } from 'lucide-react'
import type { WorktreeMeta } from '../../../../shared/types'
+function parseExplicitGitHubIssueUrl(input: string): string | null {
+ const trimmed = input.trim()
+ const link = parseGitHubIssueOrPRLink(trimmed)
+ if (!link || link.type !== 'issue') {
+ return null
+ }
+
+ return trimmed
+}
+
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
const closeModal = useAppStore((s) => s.closeModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
+ const fetchIssue = useAppStore((s) => s.fetchIssue)
const isEditMeta = activeModal === 'edit-meta'
const isOpen = isEditMeta
@@ -37,6 +50,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const [prInput, setPrInput] = useState('')
const [commentInput, setCommentInput] = useState('')
const [saving, setSaving] = useState(false)
+ const [openingIssue, setOpeningIssue] = useState(false)
const isMac = navigator.userAgent.includes('Mac')
const issueInputRef = useRef(null)
@@ -49,9 +63,35 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
setIssueInput(currentIssue)
setPrInput(currentPR)
setCommentInput(currentComment)
+ setOpeningIssue(false)
}
prevIsOpenRef.current = isOpen
+ const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput])
+ const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput])
+ const issueInputLooksLikeUrl = useMemo(
+ () => /^https?:\/\//i.test(issueInput.trim()),
+ [issueInput]
+ )
+ const issueRepo = useAppStore((s) => {
+ const worktree = Object.values(s.worktreesByRepo)
+ .flat()
+ .find((item) => item.id === worktreeId)
+ if (!worktree) {
+ return undefined
+ }
+ return s.repos.find((repo) => repo.id === worktree.repoId)
+ })
+ const cachedIssueUrl = useAppStore((s) => {
+ if (!issueRepo || issueNumber === null) {
+ return null
+ }
+ return s.issueCache[`${issueRepo.id}::${issueNumber}`]?.data?.url ?? null
+ })
+ const canOpenIssue = issueInputLooksLikeUrl
+ ? Boolean(issueUrlFromInput)
+ : Boolean(cachedIssueUrl || (issueRepo && issueNumber))
+
const autoResize = useCallback(() => {
const ta = textareaRef.current
if (!ta) {
@@ -154,6 +194,48 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
[handleSave]
)
+ const handleOpenIssue = useCallback(async () => {
+ if (openingIssue) {
+ return
+ }
+
+ if (issueUrlFromInput) {
+ void window.api.shell.openUrl(issueUrlFromInput)
+ return
+ }
+
+ if (issueInputLooksLikeUrl) {
+ return
+ }
+
+ if (cachedIssueUrl) {
+ void window.api.shell.openUrl(cachedIssueUrl)
+ return
+ }
+
+ if (!issueRepo || issueNumber === null) {
+ return
+ }
+
+ setOpeningIssue(true)
+ try {
+ const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
+ if (issue?.url) {
+ void window.api.shell.openUrl(issue.url)
+ }
+ } finally {
+ setOpeningIssue(false)
+ }
+ }, [
+ cachedIssueUrl,
+ fetchIssue,
+ issueInputLooksLikeUrl,
+ issueNumber,
+ issueRepo,
+ issueUrlFromInput,
+ openingIssue
+ ])
+
return (
GH Issue
- setIssueInput(e.target.value)}
- onKeyDown={handleIssueKeyDown}
- placeholder="Issue # or GitHub URL"
- className="h-8 text-xs"
- />
+
+ setIssueInput(e.target.value)}
+ onKeyDown={handleIssueKeyDown}
+ placeholder="Issue # or GitHub URL"
+ className="h-8 pr-9 text-xs"
+ />
+
+
+
+ {openingIssue ? (
+
+ ) : (
+
+ )}
+
+
+
+ Open GitHub issue
+
+
+
Paste an issue URL, or enter a number. Leave blank to remove the link.
diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx
new file mode 100644
index 00000000000..691899bbd9a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx
@@ -0,0 +1,151 @@
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
+import {
+ getLocalFileManagerLabel,
+ openWorktreePath,
+ WorktreeOpenInSubMenu
+} from './WorktreeOpenInMenu'
+
+type ReactElementLike = {
+ type: unknown
+ props: Record
+}
+
+const { mockState, openInExternalEditorMock, openInFileManagerMock, toastErrorMock } = vi.hoisted(
+ () => ({
+ mockState: {
+ settings: { activeRuntimeEnvironmentId: null as string | null }
+ },
+ openInExternalEditorMock: vi.fn(),
+ openInFileManagerMock: vi.fn(),
+ toastErrorMock: vi.fn()
+ })
+)
+
+vi.mock('sonner', () => ({
+ toast: {
+ error: toastErrorMock
+ }
+}))
+
+vi.mock('@/store', () => {
+ const useAppStore = Object.assign(
+ (selector: (state: { settings: typeof mockState.settings }) => unknown) =>
+ selector({ settings: mockState.settings }),
+ {
+ getState: () => ({ settings: mockState.settings })
+ }
+ )
+ return { useAppStore }
+})
+
+function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
+ if (node == null || typeof node === 'string' || typeof node === 'number') {
+ return
+ }
+ if (Array.isArray(node)) {
+ node.forEach((entry) => visit(entry, cb))
+ return
+ }
+ const element = node as ReactElementLike
+ cb(element)
+ if (element.props?.children) {
+ visit(element.props.children, cb)
+ }
+}
+
+function findByType(node: unknown, type: unknown): ReactElementLike {
+ let found: ReactElementLike | null = null
+ visit(node, (entry) => {
+ if (entry.type === type) {
+ found = entry
+ }
+ })
+ if (!found) {
+ throw new Error('element not found')
+ }
+ return found
+}
+
+describe('WorktreeOpenInMenu', () => {
+ beforeEach(() => {
+ mockState.settings = { activeRuntimeEnvironmentId: null }
+ toastErrorMock.mockReset()
+ openInFileManagerMock.mockReset()
+ openInExternalEditorMock.mockReset()
+ openInFileManagerMock.mockResolvedValue({ ok: true })
+ openInExternalEditorMock.mockResolvedValue({ ok: true })
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ api: {
+ shell: {
+ openInFileManager: openInFileManagerMock,
+ openInExternalEditor: openInExternalEditorMock
+ }
+ }
+ }
+ })
+ })
+
+ it('maps file manager labels by platform', () => {
+ expect(getLocalFileManagerLabel('Mozilla/5.0 Mac OS X')).toBe('Finder')
+ expect(getLocalFileManagerLabel('Mozilla/5.0 Windows NT 10.0')).toBe('File Explorer')
+ expect(getLocalFileManagerLabel('Mozilla/5.0 X11 Linux x86_64')).toBe('File Manager')
+ })
+
+ it('disables the Open in submenu while deleting', () => {
+ const tree = WorktreeOpenInSubMenu({
+ worktreePath: '/tmp/workspace',
+ connectionId: null,
+ disabled: true
+ })
+
+ expect(findByType(tree, DropdownMenuSubTrigger).props.disabled).toBe(true)
+ })
+
+ it('stops menu item click propagation', () => {
+ const tree = WorktreeOpenInSubMenu({
+ worktreePath: '/tmp/workspace',
+ connectionId: null
+ })
+ const menuContent = findByType(tree, DropdownMenuSubContent)
+
+ const stopPropagation = vi.fn()
+ const handler = menuContent.props.onClick as ((event: React.SyntheticEvent) => void) | null
+ handler?.({ stopPropagation } as unknown as React.SyntheticEvent)
+ expect(stopPropagation).toHaveBeenCalled()
+ })
+
+ it('uses the blocked-path toast without calling main IPC', async () => {
+ mockState.settings = { activeRuntimeEnvironmentId: 'runtime-1' }
+
+ await openWorktreePath({
+ target: 'file-manager',
+ worktreePath: '/tmp/workspace',
+ connectionId: null
+ })
+
+ expect(toastErrorMock).toHaveBeenCalledWith(
+ 'Opening remote paths in the local OS is not available.'
+ )
+ expect(openInFileManagerMock).not.toHaveBeenCalled()
+ expect(openInExternalEditorMock).not.toHaveBeenCalled()
+ })
+
+ it('shows an actionable toast when the host launcher fails', async () => {
+ openInExternalEditorMock.mockResolvedValueOnce({ ok: false, reason: 'launch-failed' })
+
+ await openWorktreePath({
+ target: 'external-editor',
+ worktreePath: '/tmp/workspace',
+ connectionId: null
+ })
+
+ expect(openInExternalEditorMock).toHaveBeenCalledWith('/tmp/workspace')
+ expect(toastErrorMock).toHaveBeenCalledWith('Could not open workspace folder.', {
+ description: 'Check the editor command or file manager configuration on this machine.'
+ })
+ })
+})
diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx
new file mode 100644
index 00000000000..5624984dc70
--- /dev/null
+++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback } from 'react'
+import { ExternalLink, FolderOpen } from 'lucide-react'
+import { toast } from 'sonner'
+import {
+ DropdownMenuItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger
+} from '@/components/ui/dropdown-menu'
+import { useAppStore } from '@/store'
+import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
+import type { ShellOpenLocalPathFailureReason } from '../../../../shared/shell-open-types'
+
+type WorktreeOpenInMenuItemsProps = {
+ worktreePath: string
+ connectionId?: string | null
+ disabled?: boolean
+}
+
+export function getLocalFileManagerLabel(userAgent?: string): string {
+ const resolvedUserAgent =
+ userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent)
+ if (resolvedUserAgent.includes('Mac')) {
+ return 'Finder'
+ }
+ if (resolvedUserAgent.includes('Windows')) {
+ return 'File Explorer'
+ }
+ return 'File Manager'
+}
+
+function showOpenFailureToast(reason: ShellOpenLocalPathFailureReason): void {
+ if (reason === 'not-absolute') {
+ toast.error('Workspace path is not a valid local path.')
+ return
+ }
+ if (reason === 'not-found') {
+ toast.error('Workspace folder was not found.', {
+ description: 'It may have been moved or deleted. Refresh workspaces or remove it from Orca.'
+ })
+ return
+ }
+ toast.error('Could not open workspace folder.', {
+ description: 'Check the editor command or file manager configuration on this machine.'
+ })
+}
+
+function stopMenuPropagation(event: React.SyntheticEvent): void {
+ event.stopPropagation()
+}
+
+export async function openWorktreePath(args: {
+ target: 'file-manager' | 'external-editor'
+ worktreePath: string
+ connectionId?: string | null
+}): Promise {
+ if (
+ isLocalPathOpenBlocked(useAppStore.getState().settings, {
+ connectionId: args.connectionId ?? null
+ })
+ ) {
+ showLocalPathOpenBlockedToast()
+ return
+ }
+
+ const result =
+ args.target === 'file-manager'
+ ? await window.api.shell.openInFileManager(args.worktreePath)
+ : await window.api.shell.openInExternalEditor(args.worktreePath)
+ if (!result.ok) {
+ showOpenFailureToast(result.reason)
+ }
+}
+
+function useOpenInWorktreePath({
+ worktreePath,
+ connectionId
+}: WorktreeOpenInMenuItemsProps): (target: 'file-manager' | 'external-editor') => Promise {
+ return useCallback(
+ async (target) => {
+ await openWorktreePath({ target, worktreePath, connectionId })
+ },
+ [connectionId, worktreePath]
+ )
+}
+
+export function WorktreeOpenInMenuItems({
+ worktreePath,
+ connectionId,
+ disabled
+}: WorktreeOpenInMenuItemsProps): React.JSX.Element {
+ const openInWorktreePath = useOpenInWorktreePath({ worktreePath, connectionId })
+ const fileManagerLabel = getLocalFileManagerLabel()
+
+ return (
+ <>
+ {
+ void openInWorktreePath('external-editor')
+ }}
+ disabled={disabled}
+ >
+
+ VS Code
+
+ {
+ void openInWorktreePath('file-manager')
+ }}
+ disabled={disabled}
+ >
+
+ {fileManagerLabel}
+
+ >
+ )
+}
+
+export function WorktreeOpenInSubMenu({
+ worktreePath,
+ connectionId,
+ disabled
+}: WorktreeOpenInMenuItemsProps): React.JSX.Element {
+ return (
+
+
+
+ Open in
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts
new file mode 100644
index 00000000000..5654f6417ca
--- /dev/null
+++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { Worktree } from '../../../../shared/types'
+import {
+ finalizeImportedRepoAfterSkip,
+ type AddRepoSkipFinalizationState
+} from './add-repo-skip-finalization'
+
+function makeWorktree(overrides: Partial & { id: string; repoId: string }): Worktree {
+ return {
+ path: `/tmp/${overrides.id}`,
+ head: 'abc123',
+ branch: 'refs/heads/feature',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: overrides.id,
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ linkedGitLabMR: null,
+ linkedGitLabIssue: null,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0,
+ ...overrides
+ }
+}
+
+function makeState(overrides: Partial): AddRepoSkipFinalizationState {
+ return {
+ activeRepoId: null,
+ filterRepoIds: [],
+ showActiveOnly: false,
+ hideDefaultBranchWorkspace: false,
+ worktreesByRepo: {},
+ setActiveRepo: vi.fn(),
+ setFilterRepoIds: vi.fn(),
+ setShowActiveOnly: vi.fn(),
+ setHideDefaultBranchWorkspace: vi.fn(),
+ ...overrides
+ }
+}
+
+describe('finalizeImportedRepoAfterSkip', () => {
+ it('keeps skipped imported worktrees visible without activating a worktree', () => {
+ const state = makeState({
+ activeRepoId: 'repo-old',
+ filterRepoIds: ['repo-old'],
+ showActiveOnly: true,
+ hideDefaultBranchWorkspace: false,
+ worktreesByRepo: {
+ 'repo-new': [makeWorktree({ id: 'repo-new::/repo/feature', repoId: 'repo-new' })]
+ }
+ })
+
+ finalizeImportedRepoAfterSkip(state, 'repo-new')
+
+ expect(state.setActiveRepo).toHaveBeenCalledWith('repo-new')
+ expect(state.setFilterRepoIds).toHaveBeenCalledWith([])
+ expect(state.setShowActiveOnly).toHaveBeenCalledWith(false)
+ expect(state.setHideDefaultBranchWorkspace).not.toHaveBeenCalled()
+ })
+
+ it('clears default-branch hiding when it would hide every imported worktree', () => {
+ const state = makeState({
+ hideDefaultBranchWorkspace: true,
+ worktreesByRepo: {
+ 'repo-new': [
+ makeWorktree({
+ id: 'repo-new::/repo/main',
+ repoId: 'repo-new',
+ isMainWorktree: true,
+ branch: 'refs/heads/main'
+ })
+ ]
+ }
+ })
+
+ finalizeImportedRepoAfterSkip(state, 'repo-new')
+
+ expect(state.setHideDefaultBranchWorkspace).toHaveBeenCalledWith(false)
+ })
+
+ it('does nothing when the imported repo has no discovered worktrees yet', () => {
+ const state = makeState({
+ activeRepoId: 'repo-old',
+ filterRepoIds: ['repo-old'],
+ showActiveOnly: true,
+ hideDefaultBranchWorkspace: true,
+ worktreesByRepo: { 'repo-new': [] }
+ })
+
+ finalizeImportedRepoAfterSkip(state, 'repo-new')
+
+ expect(state.setActiveRepo).not.toHaveBeenCalled()
+ expect(state.setFilterRepoIds).not.toHaveBeenCalled()
+ expect(state.setShowActiveOnly).not.toHaveBeenCalled()
+ expect(state.setHideDefaultBranchWorkspace).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts
new file mode 100644
index 00000000000..4185c3e6fbe
--- /dev/null
+++ b/src/renderer/src/components/sidebar/add-repo-skip-finalization.ts
@@ -0,0 +1,42 @@
+import type { Worktree } from '../../../../shared/types'
+import { isDefaultBranchWorkspace } from './visible-worktrees'
+
+export type AddRepoSkipFinalizationState = {
+ activeRepoId: string | null
+ filterRepoIds: string[]
+ showActiveOnly: boolean
+ hideDefaultBranchWorkspace: boolean
+ worktreesByRepo: Record
+ setActiveRepo: (repoId: string | null) => void
+ setFilterRepoIds: (repoIds: string[]) => void
+ setShowActiveOnly: (value: boolean) => void
+ setHideDefaultBranchWorkspace: (value: boolean) => void
+}
+
+export function finalizeImportedRepoAfterSkip(
+ state: AddRepoSkipFinalizationState,
+ importedRepoId: string
+): void {
+ const importedWorktrees = state.worktreesByRepo[importedRepoId] ?? []
+ if (importedWorktrees.length === 0) {
+ return
+ }
+
+ // Why: Skip means "do not open or create a worktree", not "hide the
+ // imported project behind sidebar filters so it looks like nothing landed."
+ if (state.activeRepoId !== importedRepoId) {
+ state.setActiveRepo(importedRepoId)
+ }
+ if (state.filterRepoIds.length > 0 && !state.filterRepoIds.includes(importedRepoId)) {
+ state.setFilterRepoIds([])
+ }
+ if (state.showActiveOnly) {
+ state.setShowActiveOnly(false)
+ }
+ if (
+ state.hideDefaultBranchWorkspace &&
+ importedWorktrees.every((worktree) => isDefaultBranchWorkspace(worktree))
+ ) {
+ state.setHideDefaultBranchWorkspace(false)
+ }
+}
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
index 35577dfca79..2c1556833a5 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
@@ -33,6 +33,7 @@ vi.mock('sonner', () => ({
}
}))
+import { toast } from 'sonner'
import { runWorktreeBatchDelete } from './delete-worktree-flow'
function setWorktrees(
@@ -57,41 +58,95 @@ describe('runWorktreeBatchDelete', () => {
mocks.state.openModal.mockClear()
mocks.state.removeWorktree.mockClear().mockResolvedValue({ ok: true })
mocks.state.deleteStateByWorktreeId = {}
+ vi.mocked(toast.error).mockClear()
+ vi.mocked(toast.info).mockClear()
setWorktrees([])
})
it('filters main worktrees and opens a batch confirmation for eligible targets', () => {
setWorktrees([{ id: 'main', isMainWorktree: true }, { id: 'wt-1' }, { id: 'wt-2' }])
- runWorktreeBatchDelete(['main', 'wt-1', 'wt-2'])
+ const started = runWorktreeBatchDelete(['main', 'wt-1', 'wt-2'])
+ expect(started).toBe(true)
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-1')
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-2')
expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalledWith('main')
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
- worktreeIds: ['wt-1', 'wt-2']
+ worktreeIds: ['wt-1', 'wt-2'],
+ allowSkipConfirm: false
})
})
it('opens the single-delete confirmation when only one target is eligible', () => {
setWorktrees([{ id: 'main', isMainWorktree: true }, { id: 'wt-1' }])
- runWorktreeBatchDelete(['main', 'wt-1'])
+ const started = runWorktreeBatchDelete(['main', 'wt-1'])
+ expect(started).toBe(true)
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' })
})
- it('runs every eligible delete immediately when confirmation is skipped', () => {
+ it('keeps batch deletes behind confirmation when confirmation is skipped', () => {
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
setWorktrees([
{ id: 'wt-1', displayName: 'one' },
{ id: 'wt-2', displayName: 'two' }
])
+ const onDeleted = vi.fn()
- runWorktreeBatchDelete(['wt-1', 'wt-2'])
+ const started = runWorktreeBatchDelete(['wt-1', 'wt-2'], { onDeleted })
+ expect(started).toBe(true)
+ expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
+ expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
+ worktreeIds: ['wt-1', 'wt-2'],
+ allowSkipConfirm: false,
+ onDeleted
+ })
+ })
+
+ it('runs a single eligible delete immediately when confirmation is skipped', async () => {
+ mocks.state.settings = { skipDeleteWorktreeConfirm: true }
+ setWorktrees([{ id: 'wt-1', displayName: 'one' }])
+ const onDeleted = vi.fn()
+
+ const started = runWorktreeBatchDelete(['wt-1'], { onDeleted })
+
+ expect(started).toBe(true)
expect(mocks.state.openModal).not.toHaveBeenCalled()
expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)
- expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-2', false)
+ await vi.waitFor(() => {
+ expect(onDeleted).toHaveBeenCalledWith(['wt-1'])
+ })
+ })
+
+ it('can force confirmation for a single eligible delete', () => {
+ mocks.state.settings = { skipDeleteWorktreeConfirm: true }
+ setWorktrees([{ id: 'wt-1', displayName: 'one' }])
+ const onDeleted = vi.fn()
+
+ const started = runWorktreeBatchDelete(['wt-1'], { forceConfirm: true, onDeleted })
+
+ expect(started).toBe(true)
+ expect(mocks.state.removeWorktree).not.toHaveBeenCalled()
+ expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', {
+ worktreeId: 'wt-1',
+ allowSkipConfirm: false,
+ onDeleted
+ })
+ })
+
+ it('reports when no selected worktrees are eligible', () => {
+ setWorktrees([{ id: 'main', isMainWorktree: true }])
+
+ const started = runWorktreeBatchDelete(['main', 'missing'])
+
+ expect(started).toBe(false)
+ expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled()
+ expect(mocks.state.openModal).not.toHaveBeenCalled()
+ expect(toast.info).toHaveBeenCalledWith('No deletable workspaces selected', {
+ description: 'Refresh Space and try again if the workspace list looks stale.'
+ })
})
})
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts
index d64d8a3fab7..f915b13f98a 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts
@@ -5,6 +5,11 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
import type { Worktree } from '../../../../shared/types'
+type WorktreeBatchDeleteOptions = {
+ forceConfirm?: boolean
+ onDeleted?: (worktreeIds: string[]) => void
+}
+
// Why: a failed delete almost always means the worktree still has changes
// that need attention (uncommitted work, unpushed commits, conflicts). The
// "View" affordance should surface those changes directly, not just bring
@@ -17,6 +22,21 @@ function viewWorktreeDiff(worktreeId: string): void {
state.setRightSidebarOpen(true)
}
+export async function runWorktreeDeletesSequentially(
+ targets: readonly Pick[]
+): Promise {
+ const deletedIds: string[] = []
+ for (const target of targets) {
+ // Why: git worktree removals for one repo contend on git lock files.
+ // Running the user-selected batch sequentially avoids partial lock races.
+ const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName)
+ if (deleted) {
+ deletedIds.push(target.id)
+ }
+ }
+ return deletedIds
+}
+
/**
* Shared delete-with-toast flow used by both DeleteWorktreeDialog (confirm
* path) and WorktreeContextMenu (skip-confirm path). Centralizes the error
@@ -30,13 +50,16 @@ function viewWorktreeDiff(worktreeId: string): void {
* concerns into the store slice while still preventing the two delete
* entry points from drifting apart.
*/
-export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: string): void {
+export function runWorktreeDeleteWithToast(
+ worktreeId: string,
+ worktreeName: string
+): Promise {
const removeWorktree = useAppStore.getState().removeWorktree
- removeWorktree(worktreeId, false)
+ return removeWorktree(worktreeId, false)
.then((result) => {
if (result.ok) {
- return
+ return true
}
const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
const canForceDelete = state?.canForceDelete ?? false
@@ -80,11 +103,13 @@ export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: str
}
: undefined
})
+ return false
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err)
})
+ return false
})
}
@@ -122,13 +147,16 @@ export function runWorktreeDelete(worktreeId: string): void {
state.clearWorktreeDeleteState(worktreeId)
const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
if (skipConfirm) {
- runWorktreeDeleteWithToast(worktreeId, target.displayName)
+ void runWorktreeDeleteWithToast(worktreeId, target.displayName)
return
}
state.openModal('delete-worktree', { worktreeId })
}
-export function runWorktreeBatchDelete(worktreeIds: readonly string[]): void {
+export function runWorktreeBatchDelete(
+ worktreeIds: readonly string[],
+ options: WorktreeBatchDeleteOptions = {}
+): boolean {
const state = useAppStore.getState()
const worktreeMap = getWorktreeMapFromState(state)
const targets = worktreeIds
@@ -136,25 +164,44 @@ export function runWorktreeBatchDelete(worktreeIds: readonly string[]): void {
.filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree)
if (targets.length === 0) {
- return
+ toast.info('No deletable workspaces selected', {
+ description: 'Refresh Space and try again if the workspace list looks stale.'
+ })
+ return false
}
for (const target of targets) {
state.clearWorktreeDeleteState(target.id)
}
- const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
+ // Why: bulk cleanup can destroy many directories at once, so batch deletes
+ // and Space-triggered deletes must keep an explicit confirmation step.
+ const skipConfirm =
+ !options.forceConfirm &&
+ targets.length === 1 &&
+ (state.settings?.skipDeleteWorktreeConfirm ?? false)
if (skipConfirm) {
- for (const target of targets) {
- runWorktreeDeleteWithToast(target.id, target.displayName)
- }
- return
+ void runWorktreeDeletesSequentially(targets).then((deletedIds) => {
+ if (deletedIds.length > 0) {
+ options.onDeleted?.(deletedIds)
+ }
+ })
+ return true
}
if (targets.length === 1) {
- state.openModal('delete-worktree', { worktreeId: targets[0].id })
- return
+ state.openModal('delete-worktree', {
+ worktreeId: targets[0].id,
+ ...(options.forceConfirm ? { allowSkipConfirm: false } : {}),
+ ...(options.onDeleted ? { onDeleted: options.onDeleted } : {})
+ })
+ return true
}
- state.openModal('delete-worktree', { worktreeIds: targets.map((target) => target.id) })
+ state.openModal('delete-worktree', {
+ worktreeIds: targets.map((target) => target.id),
+ allowSkipConfirm: false,
+ ...(options.onDeleted ? { onDeleted: options.onDeleted } : {})
+ })
+ return true
}
diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx
index 51f9979a2f8..e06c40087fb 100644
--- a/src/renderer/src/components/sidebar/index.tsx
+++ b/src/renderer/src/components/sidebar/index.tsx
@@ -7,7 +7,6 @@ import SidebarNav from './SidebarNav'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorktreeMetaDialog from './WorktreeMetaDialog'
-import DeleteWorktreeDialog from './DeleteWorktreeDialog'
import NonGitFolderDialog from './NonGitFolderDialog'
import RemoveFolderDialog from './RemoveFolderDialog'
import AddRepoDialog from './AddRepoDialog'
@@ -64,7 +63,6 @@ function Sidebar(): React.JSX.Element {
{/* Dialog (rendered outside sidebar to avoid clipping) */}
-
diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts
index 05120eae905..c4fbd18ce73 100644
--- a/src/renderer/src/components/sidebar/smart-attention.test.ts
+++ b/src/renderer/src/components/sidebar/smart-attention.test.ts
@@ -12,7 +12,7 @@ import {
resolveAttention,
type PaneInput
} from './smart-attention'
-import type { TerminalTab, Worktree } from '../../../../shared/types'
+import type { TerminalLayoutSnapshot, TerminalTab, Worktree } from '../../../../shared/types'
function hookPane(entry: AgentStatusEntry): PaneInput {
return { kind: 'hook', entry }
@@ -23,6 +23,31 @@ function hookPanes(entries: AgentStatusEntry[]): PaneInput[] {
}
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
+const LEAF_1 = '11111111-1111-4111-8111-111111111111'
+const LEAF_2 = '22222222-2222-4222-8222-222222222222'
+
+function paneKey(tabId: string, leafId: string): string {
+ return `${tabId}:${leafId}`
+}
+
+function splitLayout(
+ tabId: string,
+ firstLeafId = LEAF_1,
+ secondLeafId = LEAF_2
+): Record {
+ return {
+ [tabId]: {
+ root: {
+ type: 'split',
+ direction: 'vertical',
+ first: { type: 'leaf', leafId: firstLeafId },
+ second: { type: 'leaf', leafId: secondLeafId }
+ },
+ activeLeafId: firstLeafId,
+ expandedLeafId: null
+ }
+ }
+}
function makeEntry(overrides: Partial & { paneKey: string }): AgentStatusEntry {
return {
@@ -382,14 +407,14 @@ describe('buildAttentionByWorktree', () => {
const w = makeWorktree('wt-1')
const tab = makeTab('tab-1', w.id)
const entries: Record = {
- 'tab-1:1': makeEntry({
- paneKey: 'tab-1:1',
+ [paneKey(tab.id, LEAF_1)]: makeEntry({
+ paneKey: paneKey(tab.id, LEAF_1),
state: 'working',
stateStartedAt: NOW - 10_000,
updatedAt: NOW - 1_000
}),
- 'tab-1:2': makeEntry({
- paneKey: 'tab-1:2',
+ [paneKey(tab.id, LEAF_2)]: makeEntry({
+ paneKey: paneKey(tab.id, LEAF_2),
state: 'blocked',
stateStartedAt: NOW - 5_000,
updatedAt: NOW - 1_000
@@ -460,8 +485,8 @@ describe('buildAttentionByWorktree', () => {
const w = makeWorktree('wt-1')
const tab = makeTab('tab-1', w.id)
const entries: Record = {
- 'tab-1:1': makeEntry({
- paneKey: 'tab-1:1',
+ [paneKey(tab.id, LEAF_1)]: makeEntry({
+ paneKey: paneKey(tab.id, LEAF_1),
state: 'done',
stateStartedAt: NOW - 30_000,
updatedAt: NOW - 1_000
@@ -474,7 +499,9 @@ describe('buildAttentionByWorktree', () => {
// Same paneId 1 — must NOT double-promote into Class 3.
{ [tab.id]: { 1: '⠋ Claude' } },
ptyMap([tab.id]),
- NOW
+ NOW,
+ undefined,
+ splitLayout(tab.id)
)
expect(map.get(w.id)).toEqual({ cls: 2, attentionTimestamp: NOW - 30_000 })
})
@@ -483,8 +510,8 @@ describe('buildAttentionByWorktree', () => {
const w = makeWorktree('wt-1')
const tab = makeTab('tab-1', w.id)
const entries: Record = {
- 'tab-1:1': makeEntry({
- paneKey: 'tab-1:1',
+ [paneKey(tab.id, LEAF_1)]: makeEntry({
+ paneKey: paneKey(tab.id, LEAF_1),
state: 'done',
stateStartedAt: NOW - 30_000,
updatedAt: NOW - 1_000
@@ -497,7 +524,9 @@ describe('buildAttentionByWorktree', () => {
// Pane 2 has no hook — title fallback fires for it.
{ [tab.id]: { 1: 'something', 2: '✋ Gemini CLI' } },
ptyMap([tab.id]),
- NOW
+ NOW,
+ undefined,
+ splitLayout(tab.id)
)
expect(map.get(w.id)).toEqual({
cls: 1,
diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts
index 516ced3a16d..7a1ba82e4c2 100644
--- a/src/renderer/src/components/sidebar/smart-attention.ts
+++ b/src/renderer/src/components/sidebar/smart-attention.ts
@@ -1,12 +1,21 @@
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
+import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type { AgentStatus } from '../../../../shared/agent-detection'
-import type { TerminalTab, Worktree } from '../../../../shared/types'
+import type {
+ TerminalLayoutSnapshot,
+ TerminalPaneLayoutNode,
+ TerminalTab,
+ Worktree
+} from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStateHistoryEntry,
- type AgentStatusEntry
+ type AgentStatusEntry,
+ type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
+import { isTerminalLeafId, parsePaneKey } from '../../../../shared/stable-pane-id'
+import { FIRST_PANE_ID } from '../../../../shared/pane-key'
/**
* Ordinal class for the "Smart" sort. Lower number = more attention-demanding.
@@ -191,43 +200,90 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
* resolution pay O(T) lookups instead of scanning the full map.
*/
export function buildExplicitEntriesByTabId(
- agentStatusByPaneKey: Record | undefined
+ agentStatusByPaneKey: Record | undefined,
+ migrationUnsupportedByPtyId?: Record
): Map {
const byTab = new Map()
- if (!agentStatusByPaneKey) {
+ const entries = [
+ ...Object.values(agentStatusByPaneKey ?? {}),
+ ...Object.values(migrationUnsupportedByPtyId ?? {}).flatMap((entry) => {
+ const agentEntry = migrationUnsupportedToAgentStatusEntry(entry)
+ return agentEntry ? [agentEntry] : []
+ })
+ ]
+ if (entries.length === 0) {
return byTab
}
- for (const entry of Object.values(agentStatusByPaneKey)) {
- const colon = entry.paneKey.indexOf(':')
- // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
- // colon or leading colon) rather than bucketing them under an empty tabId.
- if (colon <= 0) {
+ for (const entry of entries) {
+ const parsed = parsePaneKey(entry.paneKey)
+ // Why: paneKey must be `${tabId}:${leafUuid}`. Skip malformed or legacy
+ // numeric entries rather than bucketing unroutable rows under a tab.
+ if (!parsed) {
continue
}
- const tabId = entry.paneKey.slice(0, colon)
- const bucket = byTab.get(tabId)
+ const bucket = byTab.get(parsed.tabId)
if (bucket) {
bucket.push(entry)
} else {
- byTab.set(tabId, [entry])
+ byTab.set(parsed.tabId, [entry])
}
}
return byTab
}
/**
- * Extract the paneId from a `${tabId}:${paneId}` paneKey, returning null for
- * malformed keys (no colon or non-numeric tail). Used for per-pane authority:
- * we need to know which paneIds already have a fresh hook entry so we don't
- * double-count them via the title fallback.
+ * Extract the stable leaf id from a `${tabId}:${leafId}` paneKey. Used for
+ * per-pane authority: we need to know which leaves already have a fresh hook
+ * entry so we don't double-count them via the title fallback.
*/
-function paneIdFromPaneKey(paneKey: string): number | null {
- const colon = paneKey.indexOf(':')
- if (colon <= 0) {
+function leafIdFromPaneKey(paneKey: string): string | null {
+ return parsePaneKey(paneKey)?.leafId ?? null
+}
+
+function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
+ return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
+}
+
+function collectReplayCreatedPaneLeafIds(
+ node: Extract,
+ leafIdsInReplayCreationOrder: string[]
+): void {
+ leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second))
+
+ if (node.first.type === 'split') {
+ collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder)
+ }
+ if (node.second.type === 'split') {
+ collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder)
+ }
+}
+
+function collectLeafIdsInReplayCreationOrder(
+ node: TerminalPaneLayoutNode | null | undefined
+): string[] {
+ if (!node) {
+ return []
+ }
+ const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)]
+ if (node.type === 'split') {
+ collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder)
+ }
+ return leafIdsInReplayCreationOrder
+}
+
+function resolveRuntimePaneTitleLeafId(
+ tabLayout: TerminalLayoutSnapshot | undefined,
+ runtimePaneId: string
+): string | null {
+ if (isTerminalLeafId(runtimePaneId)) {
+ return runtimePaneId
+ }
+ const numericPaneId = Number(runtimePaneId)
+ if (!Number.isInteger(numericPaneId) || numericPaneId < FIRST_PANE_ID) {
return null
}
- const id = Number(paneKey.slice(colon + 1))
- return Number.isFinite(id) ? id : null
+ const leafIds = collectLeafIdsInReplayCreationOrder(tabLayout?.root)
+ return leafIds[numericPaneId - FIRST_PANE_ID] ?? null
}
/**
@@ -248,9 +304,11 @@ export function buildAttentionByWorktree(
agentStatusByPaneKey: Record | undefined,
runtimePaneTitlesByTabId: Record>,
ptyIdsByTabId: Record,
- now: number
+ now: number,
+ migrationUnsupportedByPtyId?: Record,
+ terminalLayoutsByTabId?: Record
): Map {
- const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey)
+ const byTab = buildExplicitEntriesByTabId(agentStatusByPaneKey, migrationUnsupportedByPtyId)
const result = new Map()
for (const worktree of worktrees) {
@@ -262,9 +320,9 @@ export function buildAttentionByWorktree(
const panes: PaneInput[] = []
for (const tab of tabs) {
const hookEntries = byTab.get(tab.id)
- // Why: paneIds covered by a hook entry skip the title fallback so we
+ // Why: leaf ids covered by a hook entry skip the title fallback so we
// don't double-count them. Hook authority is per-pane.
- const hookPaneIds = new Set()
+ const hookLeafIds = new Set()
if (hookEntries) {
for (const entry of hookEntries) {
panes.push({ kind: 'hook', entry })
@@ -275,9 +333,9 @@ export function buildAttentionByWorktree(
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
continue
}
- const paneId = paneIdFromPaneKey(entry.paneKey)
- if (paneId !== null) {
- hookPaneIds.add(paneId)
+ const leafId = leafIdFromPaneKey(entry.paneKey)
+ if (leafId !== null) {
+ hookLeafIds.add(leafId)
}
}
}
@@ -293,9 +351,10 @@ export function buildAttentionByWorktree(
if (paneTitles && Object.keys(paneTitles).length > 0) {
// Why: split-pane tabs can host multiple agents; each pane reports
// its own title. Mirrors the precedence used by getWorkingAgentsPerWorktree.
- for (const [paneIdStr, title] of Object.entries(paneTitles)) {
- const paneId = Number(paneIdStr)
- if (hookPaneIds.has(paneId)) {
+ const tabLayout = terminalLayoutsByTabId?.[tab.id]
+ for (const [runtimePaneId, title] of Object.entries(paneTitles)) {
+ const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId)
+ if (leafId !== null && hookLeafIds.has(leafId)) {
continue
}
panes.push({
@@ -304,7 +363,7 @@ export function buildAttentionByWorktree(
worktreeLastActivityAt: worktree.lastActivityAt
})
}
- } else if (hookPaneIds.size === 0) {
+ } else if (hookLeafIds.size === 0) {
// Why: tabs we have not mounted yet (restored-but-unvisited) only
// expose the legacy tab title. Fall back to it only when no pane-level
// titles or hook entries exist for this tab.
diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts
index 6c4f9a0144b..a05536101ff 100644
--- a/src/renderer/src/components/sidebar/smart-sort.test.ts
+++ b/src/renderer/src/components/sidebar/smart-sort.test.ts
@@ -13,8 +13,15 @@ import {
type AgentStateHistoryEntry,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
+const LEAF_ID_1 = '11111111-1111-4111-8111-111111111111'
+const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222'
+
+function paneKey(tabId: string, leaf: '1' | '2' = '1'): string {
+ return makePaneKey(tabId, leaf === '1' ? LEAF_ID_1 : LEAF_ID_2)
+}
const repoMap = new Map([
[
@@ -126,15 +133,15 @@ describe('smart sort — class invariants', () => {
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })]
}
const entries = {
- 'tab-blocked:1': makeEntry({
- paneKey: 'tab-blocked:1',
+ [paneKey('tab-blocked', '1')]: makeEntry({
+ paneKey: paneKey('tab-blocked', '1'),
state: 'blocked',
// older than the done timestamp
stateStartedAt: NOW - 5 * 60_000,
updatedAt: NOW - 1_000
}),
- 'tab-done:1': makeEntry({
- paneKey: 'tab-done:1',
+ [paneKey('tab-done', '1')]: makeEntry({
+ paneKey: paneKey('tab-done', '1'),
state: 'done',
// newer
stateStartedAt: NOW - 10_000,
@@ -153,14 +160,14 @@ describe('smart sort — class invariants', () => {
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })]
}
const entries = {
- 'tab-done:1': makeEntry({
- paneKey: 'tab-done:1',
+ [paneKey('tab-done', '1')]: makeEntry({
+ paneKey: paneKey('tab-done', '1'),
state: 'done',
stateStartedAt: NOW - 10 * 60_000,
updatedAt: NOW - 1_000
}),
- 'tab-working:1': makeEntry({
- paneKey: 'tab-working:1',
+ [paneKey('tab-working', '1')]: makeEntry({
+ paneKey: paneKey('tab-working', '1'),
state: 'working',
// newer than the done — must still lose because class wins
stateStartedAt: NOW - 1_000,
@@ -185,8 +192,8 @@ describe('smart sort — class invariants', () => {
[idle.id]: [makeTab({ id: 'tab-idle', worktreeId: idle.id })]
}
const entries = {
- 'tab-working:1': makeEntry({
- paneKey: 'tab-working:1',
+ [paneKey('tab-working', '1')]: makeEntry({
+ paneKey: paneKey('tab-working', '1'),
state: 'working',
stateStartedAt: NOW - 60_000,
updatedAt: NOW - 1_000
@@ -206,14 +213,14 @@ describe('smart sort — within-class recency', () => {
[newer.id]: [makeTab({ id: 'tab-newer', worktreeId: newer.id })]
}
const entries = {
- 'tab-older:1': makeEntry({
- paneKey: 'tab-older:1',
+ [paneKey('tab-older', '1')]: makeEntry({
+ paneKey: paneKey('tab-older', '1'),
state: 'blocked',
stateStartedAt: NOW - 5 * 60_000,
updatedAt: NOW - 1_000
}),
- 'tab-newer:1': makeEntry({
- paneKey: 'tab-newer:1',
+ [paneKey('tab-newer', '1')]: makeEntry({
+ paneKey: paneKey('tab-newer', '1'),
state: 'blocked',
stateStartedAt: NOW - 30_000,
updatedAt: NOW - 1_000
@@ -231,16 +238,16 @@ describe('smart sort — within-class recency', () => {
[fresh.id]: [makeTab({ id: 'tab-fresh', worktreeId: fresh.id })]
}
const entries = {
- 'tab-with:1': makeEntry({
- paneKey: 'tab-with:1',
+ [paneKey('tab-with', '1')]: makeEntry({
+ paneKey: paneKey('tab-with', '1'),
state: 'working',
stateStartedAt: NOW - 60_000,
updatedAt: NOW - 1_000,
// Prior done from earlier in the session bumps within-class recency.
stateHistory: [makeHistory('done', NOW - 5_000)]
}),
- 'tab-fresh:1': makeEntry({
- paneKey: 'tab-fresh:1',
+ [paneKey('tab-fresh', '1')]: makeEntry({
+ paneKey: paneKey('tab-fresh', '1'),
state: 'working',
// Even newer current stateStartedAt — but with no history, falls back
// to this timestamp (older than the prior done above).
@@ -263,15 +270,15 @@ describe('smart sort — within-class recency', () => {
[fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })]
}
const entries = {
- 'tab-i:1': makeEntry({
- paneKey: 'tab-i:1',
+ [paneKey('tab-i', '1')]: makeEntry({
+ paneKey: paneKey('tab-i', '1'),
state: 'working',
stateStartedAt: NOW - 60_000,
updatedAt: NOW - 1_000,
stateHistory: [makeHistory('done', NOW - 5_000, true)]
}),
- 'tab-f:1': makeEntry({
- paneKey: 'tab-f:1',
+ [paneKey('tab-f', '1')]: makeEntry({
+ paneKey: paneKey('tab-f', '1'),
state: 'working',
stateStartedAt: NOW - 30_000,
updatedAt: NOW - 1_000
@@ -297,15 +304,15 @@ describe('smart sort — interrupted and stale handling', () => {
[realDone.id]: [makeTab({ id: 'tab-d', worktreeId: realDone.id })]
}
const entries = {
- 'tab-i:1': makeEntry({
- paneKey: 'tab-i:1',
+ [paneKey('tab-i', '1')]: makeEntry({
+ paneKey: paneKey('tab-i', '1'),
state: 'done',
interrupted: true,
stateStartedAt: NOW - 1_000,
updatedAt: NOW - 500
}),
- 'tab-d:1': makeEntry({
- paneKey: 'tab-d:1',
+ [paneKey('tab-d', '1')]: makeEntry({
+ paneKey: paneKey('tab-d', '1'),
state: 'done',
stateStartedAt: NOW - 5 * 60_000,
updatedAt: NOW - 1_000
@@ -327,14 +334,14 @@ describe('smart sort — interrupted and stale handling', () => {
[fresh.id]: [makeTab({ id: 'tab-f', worktreeId: fresh.id })]
}
const entries = {
- 'tab-s:1': makeEntry({
- paneKey: 'tab-s:1',
+ [paneKey('tab-s', '1')]: makeEntry({
+ paneKey: paneKey('tab-s', '1'),
state: 'blocked',
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
}),
- 'tab-f:1': makeEntry({
- paneKey: 'tab-f:1',
+ [paneKey('tab-f', '1')]: makeEntry({
+ paneKey: paneKey('tab-f', '1'),
state: 'done',
stateStartedAt: NOW - 5 * 60_000,
updatedAt: NOW - 1_000
@@ -417,20 +424,20 @@ describe('smart sort — multi-pane resolution', () => {
[otherDone.id]: [makeTab({ id: 'tab-other', worktreeId: otherDone.id })]
}
const entries = {
- 'tab-split:1': makeEntry({
- paneKey: 'tab-split:1',
+ [paneKey('tab-split', '1')]: makeEntry({
+ paneKey: paneKey('tab-split', '1'),
state: 'working',
stateStartedAt: NOW - 60_000,
updatedAt: NOW - 1_000
}),
- 'tab-split:2': makeEntry({
- paneKey: 'tab-split:2',
+ [paneKey('tab-split', '2')]: makeEntry({
+ paneKey: paneKey('tab-split', '2'),
state: 'blocked',
stateStartedAt: NOW - 30_000,
updatedAt: NOW - 1_000
}),
- 'tab-other:1': makeEntry({
- paneKey: 'tab-other:1',
+ [paneKey('tab-other', '1')]: makeEntry({
+ paneKey: paneKey('tab-other', '1'),
state: 'done',
stateStartedAt: NOW - 5_000,
updatedAt: NOW - 1_000
@@ -473,14 +480,14 @@ describe('sortWorktreesSmart — cold start fallback', () => {
[done.id]: [makeTab({ id: 'tab-done', worktreeId: done.id })]
}
const entries = {
- 'tab-blocked:1': makeEntry({
- paneKey: 'tab-blocked:1',
+ [paneKey('tab-blocked', '1')]: makeEntry({
+ paneKey: paneKey('tab-blocked', '1'),
state: 'blocked',
stateStartedAt: NOW - 60_000,
updatedAt: NOW - 1_000
}),
- 'tab-done:1': makeEntry({
- paneKey: 'tab-done:1',
+ [paneKey('tab-done', '1')]: makeEntry({
+ paneKey: paneKey('tab-done', '1'),
state: 'done',
stateStartedAt: NOW - 30_000,
updatedAt: NOW - 1_000
@@ -511,14 +518,14 @@ describe('sortWorktreesSmart — palette caller regression', () => {
[working.id]: [makeTab({ id: 'tab-working', worktreeId: working.id })]
}
const agentStatusByPaneKey: Record = {
- 'tab-blocked:1': makeEntry({
- paneKey: 'tab-blocked:1',
+ [paneKey('tab-blocked', '1')]: makeEntry({
+ paneKey: paneKey('tab-blocked', '1'),
state: 'blocked',
stateStartedAt: NOW - 60_000,
updatedAt: NOW - 1_000
}),
- 'tab-working:1': makeEntry({
- paneKey: 'tab-working:1',
+ [paneKey('tab-working', '1')]: makeEntry({
+ paneKey: paneKey('tab-working', '1'),
state: 'working',
// newer than the blocked one — would win on recency alone
stateStartedAt: NOW - 1_000,
diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts
index 3d539c2f3b9..3ccc7ae383c 100644
--- a/src/renderer/src/components/sidebar/smart-sort.ts
+++ b/src/renderer/src/components/sidebar/smart-sort.ts
@@ -1,9 +1,12 @@
-import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
-import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import type { Worktree, Repo, TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
+import type {
+ AgentStatusEntry,
+ MigrationUnsupportedPtyEntry
+} from '../../../../shared/agent-status-types'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention'
-type SortBy = 'name' | 'smart' | 'recent' | 'repo'
+export type SortBy = 'name' | 'smart' | 'recent' | 'repo'
// Why: a newly-created worktree's lastActivityAt is stamped at the moment
// createLocalWorktree finishes git + setup-runner prep (often several seconds
@@ -121,7 +124,9 @@ export function sortWorktreesSmart(
repoMap: Map,
agentStatusByPaneKey: Record,
runtimePaneTitlesByTabId: Record>,
- ptyIdsByTabId: Record
+ ptyIdsByTabId: Record,
+ migrationUnsupportedByPtyId?: Record,
+ terminalLayoutsByTabId?: Record
): Worktree[] {
// Why: `tabHasLivePty` (over `ptyIdsByTabId`) is the source of truth for
// liveness — slept terminals retain `tab.ptyId` as a wake hint, so reading
@@ -145,7 +150,9 @@ export function sortWorktreesSmart(
agentStatusByPaneKey,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
- now
+ now,
+ migrationUnsupportedByPtyId,
+ terminalLayoutsByTabId
)
return [...worktrees].sort(buildWorktreeComparator('smart', repoMap, now, attentionByWorktree))
diff --git a/src/renderer/src/components/sidebar/use-workspace-status-drop.ts b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts
new file mode 100644
index 00000000000..2dcb4c706dd
--- /dev/null
+++ b/src/renderer/src/components/sidebar/use-workspace-status-drop.ts
@@ -0,0 +1,81 @@
+import { useEffect } from 'react'
+import type React from 'react'
+import type { WorkspaceStatus } from '../../../../shared/types'
+import { hasWorkspaceDragData, readWorkspaceDragData } from './workspace-status'
+
+const WORKSPACE_STATUS_DROP_TARGET = '[data-workspace-status-drop-target]'
+const WORKSPACE_PIN_DROP_TARGET = '[data-workspace-pin-drop-target]'
+
+type MoveWorktreeToStatus = (worktreeId: string, status: WorkspaceStatus) => void
+type PinWorktree = (worktreeId: string) => void
+
+export function useWorkspaceStatusDocumentDrop(
+ containerRef: React.RefObject,
+ onMoveWorktreeToStatus: MoveWorktreeToStatus,
+ onPinWorktree: PinWorktree,
+ onDragFinish: () => void,
+ enabled = true
+): void {
+ useEffect(() => {
+ if (!enabled) {
+ return
+ }
+
+ const handleDrop = (event: DragEvent): void => {
+ const dataTransfer = event.dataTransfer
+ if (!dataTransfer || !hasWorkspaceDragData(dataTransfer)) {
+ return
+ }
+
+ onDragFinish()
+
+ const container = containerRef.current
+ const target = event.target
+ if (!container || !(target instanceof Element) || !container.contains(target)) {
+ return
+ }
+
+ const pinTarget = target.closest(WORKSPACE_PIN_DROP_TARGET)
+ const statusTarget = target.closest(WORKSPACE_STATUS_DROP_TARGET)
+ const dropTarget =
+ pinTarget && container.contains(pinTarget)
+ ? pinTarget
+ : statusTarget && container.contains(statusTarget)
+ ? statusTarget
+ : null
+ if (!dropTarget) {
+ return
+ }
+
+ const worktreeId = readWorkspaceDragData(dataTransfer)
+ if (!worktreeId) {
+ return
+ }
+
+ // Why: Electron's preload bridge stops native drops before React sees
+ // them, so board drops commit from this scoped capture listener.
+ event.preventDefault()
+ event.stopPropagation()
+ if (dropTarget === pinTarget) {
+ onPinWorktree(worktreeId)
+ return
+ }
+
+ const status = dropTarget.dataset.workspaceStatus
+ if (status) {
+ onMoveWorktreeToStatus(worktreeId, status)
+ }
+ }
+
+ const handleDragFinish = (): void => {
+ onDragFinish()
+ }
+
+ document.addEventListener('drop', handleDrop, true)
+ document.addEventListener('dragend', handleDragFinish, true)
+ return () => {
+ document.removeEventListener('drop', handleDrop, true)
+ document.removeEventListener('dragend', handleDragFinish, true)
+ }
+ }, [containerRef, enabled, onDragFinish, onMoveWorktreeToStatus, onPinWorktree])
+}
diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx
new file mode 100644
index 00000000000..f3bb8066ec0
--- /dev/null
+++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx
@@ -0,0 +1,95 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import type { TerminalTab } from '../../../../shared/types'
+import { useWorktreeActivityStatus } from './use-worktree-activity-status'
+
+const LEAF_ID = '11111111-1111-4111-8111-111111111111'
+
+type MockState = {
+ tabsByWorktree: Record
+ browserTabsByWorktree: Record
+ runtimePaneTitlesByTabId: Record>
+ ptyIdsByTabId: Record
+ agentStatusEpoch: number
+ agentStatusByPaneKey: Record
+ retainedAgentsByPaneKey: Record
+}
+
+let mockState: MockState
+
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: MockState) => unknown) => selector(mockState)
+}))
+
+function makeTab(id: string, worktreeId: string): TerminalTab {
+ return {
+ id,
+ worktreeId,
+ ptyId: 'pty-1',
+ title: 'bash',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ }
+}
+
+function makeAgentStatusEntry(args: {
+ paneKey: string
+ state: AgentStatusEntry['state']
+}): AgentStatusEntry {
+ return {
+ paneKey: args.paneKey,
+ state: args.state,
+ prompt: '',
+ updatedAt: 1_000,
+ stateStartedAt: 1_000,
+ stateHistory: []
+ }
+}
+
+function StatusProbe({ worktreeId }: { worktreeId: string }) {
+ return {useWorktreeActivityStatus(worktreeId)}
+}
+
+describe('useWorktreeActivityStatus', () => {
+ beforeEach(() => {
+ vi.spyOn(Date, 'now').mockReturnValue(2_000)
+ mockState = {
+ tabsByWorktree: {},
+ browserTabsByWorktree: {},
+ runtimePaneTitlesByTabId: {},
+ ptyIdsByTabId: {},
+ agentStatusEpoch: 0,
+ agentStatusByPaneKey: {},
+ retainedAgentsByPaneKey: {}
+ }
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('keeps a restored offscreen working agent yellow from the hook snapshot', () => {
+ const worktreeId = 'repo1::/path/wt1'
+ const paneKey = makePaneKey('tab-1', LEAF_ID)
+ mockState = {
+ ...mockState,
+ tabsByWorktree: {
+ [worktreeId]: [makeTab('tab-1', worktreeId)]
+ },
+ ptyIdsByTabId: {
+ 'tab-1': ['pty-1']
+ },
+ agentStatusByPaneKey: {
+ [paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' })
+ }
+ }
+
+ expect(renderToStaticMarkup( )).toBe(
+ 'working '
+ )
+ })
+})
diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts
new file mode 100644
index 00000000000..66ffb16bc47
--- /dev/null
+++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts
@@ -0,0 +1,106 @@
+import { useMemo } from 'react'
+import { useShallow } from 'zustand/react/shallow'
+import { useAppStore } from '@/store'
+import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
+import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
+import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status'
+import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { EMPTY_BROWSER_TABS, EMPTY_TABS } from './WorktreeCardHelpers'
+import {
+ selectLivePtyIdsForWorktree,
+ selectRuntimePaneTitlesForWorktree
+} from './worktree-card-status-inputs'
+
+export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
+ const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
+ const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS)
+ const runtimePaneTitlesForWorktree = useAppStore(
+ useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId))
+ )
+ const ptyIdsForWorktree = useAppStore(
+ useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId))
+ )
+ const { hasPermission, hasLiveWorking, hasLiveDone, hasRetainedDone } = useAppStore(
+ useShallow((s) => {
+ // Touch the epoch so this selector re-runs when a fresh hook entry
+ // crosses the stale boundary.
+ void s.agentStatusEpoch
+ const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS
+ let perm = false
+ let working = false
+ let done = false
+ if (wtTabs.length > 0) {
+ const tabIds = new Set(wtTabs.map((tab) => tab.id))
+ const now = Date.now()
+ for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
+ const parsed = parsePaneKey(paneKey)
+ if (!parsed || !tabIds.has(parsed.tabId)) {
+ continue
+ }
+ if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
+ continue
+ }
+ if (entry.state === 'blocked' || entry.state === 'waiting') {
+ perm = true
+ } else if (entry.state === 'working') {
+ working = true
+ } else if (entry.state === 'done') {
+ done = true
+ }
+ }
+ for (const unsupported of Object.values(s.migrationUnsupportedByPtyId ?? {})) {
+ const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
+ if (!entry) {
+ continue
+ }
+ const parsed = parsePaneKey(entry.paneKey)
+ if (parsed && tabIds.has(parsed.tabId)) {
+ perm = true
+ }
+ }
+ }
+
+ let retained = false
+ for (const agent of Object.values(s.retainedAgentsByPaneKey)) {
+ if (agent.worktreeId === worktreeId) {
+ retained = true
+ break
+ }
+ }
+ return {
+ hasPermission: perm,
+ hasLiveWorking: working,
+ hasLiveDone: done,
+ hasRetainedDone: retained
+ }
+ })
+ )
+
+ // Why: compact and detailed cards need the same status-dot semantics:
+ // runtime liveness gates title-derived states, then explicit agent rows can
+ // promote working/permission/done so the dot matches visible agent state.
+ return useMemo(
+ () =>
+ resolveWorktreeStatus({
+ tabs,
+ browserTabs,
+ ptyIdsByTabId: ptyIdsForWorktree,
+ runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
+ hasPermission,
+ hasLiveWorking,
+ hasLiveDone,
+ hasRetainedDone
+ }),
+ [
+ tabs,
+ browserTabs,
+ ptyIdsForWorktree,
+ runtimePaneTitlesForWorktree,
+ hasPermission,
+ hasLiveWorking,
+ hasLiveDone,
+ hasRetainedDone
+ ]
+ )
+}
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
index 1b010a43db2..a9827e3d217 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
@@ -1,11 +1,20 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_STATUS_STALE_AFTER_MS,
- type AgentStatusEntry
+ type AgentStatusEntry,
+ type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
-import { buildWorktreeAgentRows } from './useWorktreeAgentRows'
+import {
+ buildWorktreeAgentRows,
+ selectMigrationUnsupportedEntriesForWorktree
+} from './useWorktreeAgentRows'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+
+const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111')
+const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222')
+const PANE_KEY_2 = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333')
function makeTab(id: string): TerminalTab {
return {
@@ -57,20 +66,20 @@ describe('buildWorktreeAgentRows', () => {
// Why: useWorktreeAgentRows filters retained snapshots by worktreeId, not
// current tab membership. This is the sidebar behavior that sleep cleanup
// must counter by dropping worktree-scoped retained rows.
- retained: [makeRetained('tab-orphan:0', 'wt-1', 1000)],
+ retained: [makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000)],
now: 2000
})
- expect(rows.map((row) => row.paneKey)).toEqual(['tab-orphan:0'])
+ expect(rows.map((row) => row.paneKey)).toEqual([ORPHAN_PANE_KEY])
expect(rows[0].state).toBe('done')
})
it('prefers a live row over a retained snapshot with the same paneKey', () => {
- const liveEntry = makeEntry('tab-1:0', 2000)
+ const liveEntry = makeEntry(PANE_KEY_1, 2000)
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [liveEntry],
- retained: [makeRetained('tab-1:0', 'wt-1', 1000)],
+ retained: [makeRetained(PANE_KEY_1, 'wt-1', 1000)],
now: 3000
})
@@ -90,16 +99,47 @@ describe('buildWorktreeAgentRows', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1'), makeTab('tab-2')],
entries: [
- makeEntry('tab-1:0', staleAt, { state: 'working', updatedAt: staleAt }),
- makeEntry('tab-2:0', freshDoneAt, { state: 'done', updatedAt: freshDoneAt })
+ makeEntry(PANE_KEY_1, staleAt, { state: 'working', updatedAt: staleAt }),
+ makeEntry(PANE_KEY_2, freshDoneAt, { state: 'done', updatedAt: freshDoneAt })
],
retained: [],
now
})
- const working = rows.find((r) => r.paneKey === 'tab-1:0')
- const done = rows.find((r) => r.paneKey === 'tab-2:0')
+ const working = rows.find((r) => r.paneKey === PANE_KEY_1)
+ const done = rows.find((r) => r.paneKey === PANE_KEY_2)
expect(working?.state).toBe('idle')
expect(done?.state).toBe('done')
})
})
+
+describe('selectMigrationUnsupportedEntriesForWorktree', () => {
+ it('returns raw migration records so shallow selectors can cache snapshots', () => {
+ const unsupported: MigrationUnsupportedPtyEntry = {
+ ptyId: 'pty-1',
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: '44444444-4444-4444-8444-444444444444',
+ paneKey: makePaneKey('tab-1', '44444444-4444-4444-8444-444444444444'),
+ reason: 'legacy-numeric-pane-key',
+ source: 'local',
+ updatedAt: 1000
+ }
+ const state = {
+ tabsByWorktree: { 'wt-1': [makeTab('tab-1')] },
+ agentStatusByPaneKey: {},
+ migrationUnsupportedByPtyId: { 'pty-1': unsupported },
+ retainedAgentsByPaneKey: {}
+ }
+
+ const first = selectMigrationUnsupportedEntriesForWorktree(state, 'wt-1')
+ const second = selectMigrationUnsupportedEntriesForWorktree(state, 'wt-1')
+
+ // Why: the Electron black-screen regression came from creating converted
+ // AgentStatusEntry objects inside the Zustand selector. Returning store
+ // records preserves element identity for useShallow.
+ expect(first).toEqual([unsupported])
+ expect(second).toEqual([unsupported])
+ expect(first[0]).toBe(second[0])
+ })
+})
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
index c0330715a01..196e6a116ca 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
@@ -4,11 +4,15 @@ import { useAppStore } from '@/store'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
+import type { AppState } from '@/store/types'
import type { TerminalTab } from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
- type AgentStatusEntry
+ type AgentStatusEntry,
+ type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
+import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
// Why: stable empty-array references so narrow selectors return the same
// reference when there's nothing for this worktree. Without stable empties,
@@ -16,8 +20,76 @@ import {
// unnecessary re-renders — defeating the purpose of the narrow selector.
const EMPTY_TABS: TerminalTab[] = []
const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
+const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
+type WorktreeAgentRowsState = Pick<
+ AppState,
+ | 'agentStatusByPaneKey'
+ | 'migrationUnsupportedByPtyId'
+ | 'retainedAgentsByPaneKey'
+ | 'tabsByWorktree'
+>
+
+export function selectLiveAgentStatusEntriesForWorktree(
+ state: WorktreeAgentRowsState,
+ worktreeId: string
+): AgentStatusEntry[] {
+ const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS
+ if (wtTabs.length === 0) {
+ return EMPTY_LIVE_ENTRIES
+ }
+ const tabIds = new Set(wtTabs.map((t) => t.id))
+ const out: AgentStatusEntry[] = []
+ for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
+ const parsed = parsePaneKey(paneKey)
+ if (!parsed) {
+ continue
+ }
+ if (!tabIds.has(parsed.tabId)) {
+ continue
+ }
+ out.push(entry)
+ }
+ return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
+}
+
+export function selectMigrationUnsupportedEntriesForWorktree(
+ state: WorktreeAgentRowsState,
+ worktreeId: string
+): MigrationUnsupportedPtyEntry[] {
+ const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS
+ if (wtTabs.length === 0) {
+ return EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
+ }
+ const tabIds = new Set(wtTabs.map((t) => t.id))
+ const out: MigrationUnsupportedPtyEntry[] = []
+ for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) {
+ if (!unsupported.paneKey) {
+ continue
+ }
+ const parsed = parsePaneKey(unsupported.paneKey)
+ if (!parsed || !tabIds.has(parsed.tabId)) {
+ continue
+ }
+ out.push(unsupported)
+ }
+ return out.length > 0 ? out : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
+}
+
+export function selectRetainedAgentEntriesForWorktree(
+ state: WorktreeAgentRowsState,
+ worktreeId: string
+): RetainedAgentEntry[] {
+ const out: RetainedAgentEntry[] = []
+ for (const ra of Object.values(state.retainedAgentsByPaneKey)) {
+ if (ra.worktreeId === worktreeId) {
+ out.push(ra)
+ }
+ }
+ return out.length > 0 ? out : EMPTY_RETAINED
+}
+
export function buildWorktreeAgentRows(args: {
tabs: TerminalTab[]
entries: AgentStatusEntry[]
@@ -29,16 +101,15 @@ export function buildWorktreeAgentRows(args: {
const entriesByTabId = new Map()
for (const entry of args.entries) {
- const colonIndex = entry.paneKey.indexOf(':')
- if (colonIndex === -1) {
+ const parsed = parsePaneKey(entry.paneKey)
+ if (!parsed) {
continue
}
- const tabId = entry.paneKey.slice(0, colonIndex)
- const bucket = entriesByTabId.get(tabId)
+ const bucket = entriesByTabId.get(parsed.tabId)
if (bucket) {
bucket.push(entry)
} else {
- entriesByTabId.set(tabId, [entry])
+ entriesByTabId.set(parsed.tabId, [entry])
}
}
@@ -96,38 +167,17 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
// every on-screen card re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card
// only re-renders when something relevant to THIS worktree changes.
- const entries = useAppStore(
- useShallow((s) => {
- const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS
- if (wtTabs.length === 0) {
- return EMPTY_LIVE_ENTRIES
- }
- const tabIds = new Set(wtTabs.map((t) => t.id))
- const out: AgentStatusEntry[] = []
- for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
- const sepIdx = paneKey.indexOf(':')
- if (sepIdx <= 0) {
- continue
- }
- const tabId = paneKey.slice(0, sepIdx)
- if (!tabIds.has(tabId)) {
- continue
- }
- out.push(entry)
- }
- return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
- })
+ const liveEntries = useAppStore(
+ useShallow((s) => selectLiveAgentStatusEntriesForWorktree(s, worktreeId))
+ )
+ // Why: keep the store selector limited to stable raw records. Converting
+ // migration entries creates fresh objects with Date.now(), which breaks
+ // useSyncExternalStore's cached-snapshot contract and can blank Electron.
+ const migrationUnsupported = useAppStore(
+ useShallow((s) => selectMigrationUnsupportedEntriesForWorktree(s, worktreeId))
)
const retained = useAppStore(
- useShallow((s) => {
- const out: RetainedAgentEntry[] = []
- for (const ra of Object.values(s.retainedAgentsByPaneKey)) {
- if (ra.worktreeId === worktreeId) {
- out.push(ra)
- }
- }
- return out.length > 0 ? out : EMPTY_RETAINED
- })
+ useShallow((s) => selectRetainedAgentEntriesForWorktree(s, worktreeId))
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries
@@ -139,12 +189,23 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks — same pattern as
// useDashboardData.
+ const now = Date.now()
+ const entries =
+ migrationUnsupported.length > 0
+ ? [
+ ...liveEntries,
+ ...migrationUnsupported.flatMap((unsupported) => {
+ const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
+ return entry ? [entry] : []
+ })
+ ]
+ : liveEntries
return buildWorktreeAgentRows({
tabs: tabs ?? [],
entries,
retained,
- now: Date.now()
+ now
})
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [tabs, entries, retained, agentStatusEpoch])
+ }, [tabs, liveEntries, migrationUnsupported, retained, agentStatusEpoch])
}
diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts
index becd27e9d3f..d53d8095dab 100644
--- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts
+++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts
@@ -6,7 +6,7 @@ import {
isDefaultBranchWorkspace,
sidebarHasActiveFilters
} from './visible-worktrees'
-import type { Repo, Tab, TerminalTab, Worktree } from '../../../../shared/types'
+import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab {
return {
@@ -21,21 +21,6 @@ function makeTab(id: string, worktreeId: string, ptyId: string | null): Terminal
}
}
-function makeNotesTab(id: string, worktreeId: string): Tab {
- return {
- id,
- entityId: `notes:${worktreeId}:${id}`,
- groupId: 'group-1',
- worktreeId,
- contentType: 'notes',
- label: 'Project Notes',
- customLabel: null,
- color: null,
- sortOrder: 0,
- createdAt: 0
- }
-}
-
function makeWorktree(id: string, repoId = 'repo1'): Worktree {
return {
id,
@@ -110,22 +95,6 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([wt.id])
})
- it('treats project-notes tabs as active for the active-only filter', () => {
- const notesWt = makeWorktree('wt-notes')
- const unrelatedWt = makeWorktree('wt-unrelated')
-
- const result = computeVisibleWorktreeIds(
- { repo1: [notesWt, unrelatedWt] },
- [notesWt.id, unrelatedWt.id],
- visibleOptions({
- showActiveOnly: true,
- unifiedTabsByWorktree: { [notesWt.id]: [makeNotesTab('notes-1', notesWt.id)] }
- })
- )
-
- expect(result).toEqual([notesWt.id])
- })
-
it('keeps the currently active worktree visible even without PTYs', () => {
const wt = makeWorktree('wt-active')
diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts
index 78cccf01c3f..d901b2c35d1 100644
--- a/src/renderer/src/components/sidebar/visible-worktrees.ts
+++ b/src/renderer/src/components/sidebar/visible-worktrees.ts
@@ -1,4 +1,4 @@
-import type { Worktree, Repo, Tab, TerminalTab } from '../../../../shared/types'
+import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { useAppStore } from '@/store'
@@ -81,7 +81,6 @@ export function computeVisibleWorktreeIds(
showActiveOnly: boolean
tabsByWorktree: Record | null
ptyIdsByTabId: Record | null
- unifiedTabsByWorktree?: Record | null
browserTabsByWorktree?: Record | null
activeWorktreeId?: string | null
// Why required: every caller (WorktreeList, getVisibleWorktreeIds
@@ -115,15 +114,11 @@ export function computeVisibleWorktreeIds(
opts.ptyIdsByTabId ? tabHasLivePty(opts.ptyIdsByTabId, tab.id) : false
)
const hasBrowserTabs = (opts.browserTabsByWorktree?.[w.id] ?? []).length > 0
- const hasNotesTabs = (opts.unifiedTabsByWorktree?.[w.id] ?? []).some(
- (tab) => tab.contentType === 'notes'
- )
// Why: "Active only" should reflect the surfaces Orca can actually
// restore into, not just PTY-backed terminals. A browser-tab worktree is
- // still active from the user's point of view even if it has no live PTY.
- // Project notes follow the same rule because they are workspace tabs
- // backed by the notes store, not PTY-backed terminal state.
- return hasLiveTerminal || hasBrowserTabs || hasNotesTabs || opts.activeWorktreeId === w.id
+ // still active from the user's point of view even if it has no live PTY,
+ // and the currently selected worktree should never vanish from the list.
+ return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id
})
}
@@ -191,7 +186,9 @@ export function getVisibleWorktreeIds(): string[] {
repoMap,
state.agentStatusByPaneKey,
state.runtimePaneTitlesByTabId,
- state.ptyIdsByTabId
+ state.ptyIdsByTabId,
+ state.migrationUnsupportedByPtyId,
+ state.terminalLayoutsByTabId
).map((w) => w.id)
} else {
// Why empty map: non-smart branches don't read attentionByWorktree, but
@@ -207,7 +204,6 @@ export function getVisibleWorktreeIds(): string[] {
showActiveOnly: state.showActiveOnly,
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
- unifiedTabsByWorktree: state.unifiedTabsByWorktree,
browserTabsByWorktree: state.browserTabsByWorktree,
activeWorktreeId: state.activeWorktreeId,
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
diff --git a/src/renderer/src/components/sidebar/workspace-status.ts b/src/renderer/src/components/sidebar/workspace-status.ts
new file mode 100644
index 00000000000..79f91afc69a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/workspace-status.ts
@@ -0,0 +1,232 @@
+import type React from 'react'
+import {
+ Ban,
+ Circle,
+ CircleAlert,
+ CircleCheckBig,
+ CircleDashed,
+ CircleDot,
+ CircleEllipsis,
+ CirclePause,
+ CirclePlay,
+ Flag,
+ GitPullRequest,
+ Timer
+} from 'lucide-react'
+import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types'
+import {
+ DEFAULT_WORKSPACE_STATUS_COLOR_ID,
+ DEFAULT_WORKSPACE_STATUS_ICON_ID,
+ DEFAULT_WORKSPACE_STATUS_ID,
+ DEFAULT_WORKSPACE_STATUSES,
+ WORKSPACE_STATUS_COLOR_IDS,
+ WORKSPACE_STATUS_ICON_IDS,
+ getWorkspaceStatus,
+ getWorkspaceStatusFromGroupKey,
+ getWorkspaceStatusGroupKey,
+ isWorkspaceStatusId
+} from '../../../../shared/workspace-statuses'
+
+export {
+ DEFAULT_WORKSPACE_STATUS_COLOR_ID,
+ DEFAULT_WORKSPACE_STATUS_ICON_ID,
+ DEFAULT_WORKSPACE_STATUS_ID,
+ DEFAULT_WORKSPACE_STATUSES,
+ WORKSPACE_STATUS_COLOR_IDS,
+ WORKSPACE_STATUS_ICON_IDS,
+ getWorkspaceStatus,
+ getWorkspaceStatusFromGroupKey,
+ getWorkspaceStatusGroupKey,
+ isWorkspaceStatusId
+}
+
+export const WORKSPACE_STATUS_DRAG_TYPE = 'application/x-orca-worktree-id'
+
+type WorkspaceStatusColorOption = {
+ id: string
+ label: string
+ tone: string
+ swatch: string
+ border: string
+ laneTint: string
+}
+
+type WorkspaceStatusIconOption = {
+ id: string
+ label: string
+ icon: React.ComponentType<{ className?: string }>
+}
+
+export const WORKSPACE_STATUS_COLOR_OPTIONS: WorkspaceStatusColorOption[] = [
+ {
+ id: 'neutral',
+ label: 'Neutral',
+ tone: 'text-muted-foreground',
+ swatch: 'bg-muted-foreground',
+ border: 'border-t-muted-foreground/45',
+ laneTint: 'bg-background/55'
+ },
+ {
+ id: 'blue',
+ label: 'Blue',
+ tone: 'text-blue-600 dark:text-blue-300',
+ swatch: 'bg-blue-500',
+ border: 'border-t-blue-500/70',
+ laneTint: 'bg-blue-500/[0.04]'
+ },
+ {
+ id: 'sky',
+ label: 'Sky',
+ tone: 'text-sky-600 dark:text-sky-300',
+ swatch: 'bg-sky-500',
+ border: 'border-t-sky-500/70',
+ laneTint: 'bg-sky-500/[0.04]'
+ },
+ {
+ id: 'violet',
+ label: 'Violet',
+ tone: 'text-violet-600 dark:text-violet-300',
+ swatch: 'bg-violet-500',
+ border: 'border-t-violet-500/70',
+ laneTint: 'bg-violet-500/[0.04]'
+ },
+ {
+ id: 'amber',
+ label: 'Amber',
+ tone: 'text-amber-700 dark:text-amber-200',
+ swatch: 'bg-amber-500',
+ border: 'border-t-amber-500/70',
+ laneTint: 'bg-amber-500/[0.04]'
+ },
+ {
+ id: 'emerald',
+ label: 'Emerald',
+ tone: 'text-emerald-700 dark:text-emerald-200',
+ swatch: 'bg-emerald-500',
+ border: 'border-t-emerald-500/70',
+ laneTint: 'bg-emerald-500/[0.04]'
+ },
+ {
+ id: 'rose',
+ label: 'Rose',
+ tone: 'text-rose-600 dark:text-rose-300',
+ swatch: 'bg-rose-500',
+ border: 'border-t-rose-500/70',
+ laneTint: 'bg-rose-500/[0.04]'
+ },
+ {
+ id: 'zinc',
+ label: 'Zinc',
+ tone: 'text-zinc-600 dark:text-zinc-300',
+ swatch: 'bg-zinc-500',
+ border: 'border-t-zinc-500/70',
+ laneTint: 'bg-zinc-500/[0.04]'
+ }
+]
+
+export const WORKSPACE_STATUS_ICON_OPTIONS: WorkspaceStatusIconOption[] = [
+ { id: 'circle', label: 'Circle', icon: Circle },
+ { id: 'circle-dot', label: 'Dot', icon: CircleDot },
+ { id: 'circle-dashed', label: 'Dashed', icon: CircleDashed },
+ { id: 'circle-ellipsis', label: 'Waiting', icon: CircleEllipsis },
+ { id: 'git-pull-request', label: 'Review', icon: GitPullRequest },
+ { id: 'timer', label: 'Timer', icon: Timer },
+ { id: 'flag', label: 'Flag', icon: Flag },
+ { id: 'circle-alert', label: 'Alert', icon: CircleAlert },
+ { id: 'circle-pause', label: 'Paused', icon: CirclePause },
+ { id: 'circle-play', label: 'Play', icon: CirclePlay },
+ { id: 'circle-check', label: 'Done', icon: CircleCheckBig },
+ { id: 'ban', label: 'Blocked', icon: Ban }
+]
+
+const FALLBACK_COLOR_OPTION: WorkspaceStatusColorOption = WORKSPACE_STATUS_COLOR_OPTIONS[0] ?? {
+ id: DEFAULT_WORKSPACE_STATUS_COLOR_ID,
+ label: 'Neutral',
+ tone: 'text-muted-foreground',
+ swatch: 'bg-muted-foreground',
+ border: 'border-t-muted-foreground/45',
+ laneTint: 'bg-background/55'
+}
+
+const FALLBACK_ICON_OPTION: WorkspaceStatusIconOption = WORKSPACE_STATUS_ICON_OPTIONS[1] ?? {
+ id: DEFAULT_WORKSPACE_STATUS_ICON_ID,
+ label: 'Dot',
+ icon: CircleDot
+}
+
+const DEFAULT_STATUS_VISUALS: Record<
+ string,
+ {
+ color: string
+ icon: string
+ }
+> = {
+ todo: {
+ color: 'neutral',
+ icon: 'circle'
+ },
+ 'in-progress': {
+ color: 'blue',
+ icon: 'circle-dot'
+ },
+ 'in-review': {
+ color: 'violet',
+ icon: 'git-pull-request'
+ },
+ completed: {
+ color: 'emerald',
+ icon: 'circle-check'
+ }
+}
+
+export function getWorkspaceStatusVisualMeta(status: WorkspaceStatus | WorkspaceStatusDefinition): {
+ tone: string
+ swatch: string
+ border: string
+ laneTint: string
+ icon: React.ComponentType<{ className?: string }>
+} {
+ const statusId = typeof status === 'string' ? status : status.id
+ const visual = typeof status === 'string' ? DEFAULT_STATUS_VISUALS[status] : status
+ const colorId = visual?.color ?? DEFAULT_STATUS_VISUALS[statusId]?.color
+ const iconId = visual?.icon ?? DEFAULT_STATUS_VISUALS[statusId]?.icon
+ const color =
+ WORKSPACE_STATUS_COLOR_OPTIONS.find((option) => option.id === colorId) ??
+ WORKSPACE_STATUS_COLOR_OPTIONS.find(
+ (option) => option.id === DEFAULT_WORKSPACE_STATUS_COLOR_ID
+ ) ??
+ FALLBACK_COLOR_OPTION
+ const icon =
+ WORKSPACE_STATUS_ICON_OPTIONS.find((option) => option.id === iconId) ??
+ WORKSPACE_STATUS_ICON_OPTIONS.find(
+ (option) => option.id === DEFAULT_WORKSPACE_STATUS_ICON_ID
+ ) ??
+ FALLBACK_ICON_OPTION
+
+ return {
+ tone: color.tone,
+ swatch: color.swatch,
+ border: color.border,
+ laneTint: color.laneTint,
+ icon: icon.icon
+ }
+}
+
+export function writeWorkspaceDragData(dataTransfer: DataTransfer, worktreeId: string): void {
+ dataTransfer.effectAllowed = 'move'
+ dataTransfer.setData(WORKSPACE_STATUS_DRAG_TYPE, worktreeId)
+ dataTransfer.setData('text/plain', worktreeId)
+}
+
+export function readWorkspaceDragData(dataTransfer: DataTransfer): string | null {
+ const typed = dataTransfer.getData(WORKSPACE_STATUS_DRAG_TYPE)
+ if (typed) {
+ return typed
+ }
+ return dataTransfer.getData('text/plain') || null
+}
+
+export function hasWorkspaceDragData(dataTransfer: DataTransfer): boolean {
+ const types = Array.from(dataTransfer.types)
+ return types.includes(WORKSPACE_STATUS_DRAG_TYPE) || types.includes('text/plain')
+}
diff --git a/src/renderer/src/components/sidebar/worktree-card-status.ts b/src/renderer/src/components/sidebar/worktree-card-status.ts
new file mode 100644
index 00000000000..61daeb74c9a
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-status.ts
@@ -0,0 +1,121 @@
+import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
+import type { WorktreeStatus } from '@/lib/worktree-status'
+import {
+ AGENT_STATUS_STALE_AFTER_MS,
+ type AgentStatusEntry
+} from '../../../../shared/agent-status-types'
+import type { TerminalTab } from '../../../../shared/types'
+
+type WorktreeCardStatusInput = {
+ tabs: Pick[]
+ browserTabs: { id: string }[]
+ worktreeAgentEntries: AgentStatusEntry[]
+ runtimePaneTitlesByTabId: Record>
+ now?: number
+}
+
+export function deriveWorktreeCardStatus({
+ tabs,
+ browserTabs,
+ worktreeAgentEntries,
+ runtimePaneTitlesByTabId,
+ now = Date.now()
+}: WorktreeCardStatusInput): WorktreeStatus {
+ const liveTabs = tabs.filter((tab) => tab.ptyId)
+ // Why: browser-only worktrees are still active from the user's point of
+ // view even when they have no PTY-backed terminal. The sidebar filter
+ // already treats them as active, so every navigation surface must reuse
+ // that rule instead of showing a misleading inactive dot.
+ const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0
+ if (!hasTerminals) {
+ return 'inactive'
+ }
+
+ const freshByTabId = new Map()
+ const explicitPaneIdsByTabId = new Map>()
+ for (const entry of worktreeAgentEntries) {
+ if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
+ continue
+ }
+ const colonIdx = entry.paneKey.indexOf(':')
+ // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no
+ // colon or leading colon) rather than bucketing under "".
+ if (colonIdx <= 0) {
+ continue
+ }
+ const tabId = entry.paneKey.slice(0, colonIdx)
+ const paneId = entry.paneKey.slice(colonIdx + 1)
+ const bucket = freshByTabId.get(tabId)
+ if (bucket) {
+ bucket.push(entry)
+ } else {
+ freshByTabId.set(tabId, [entry])
+ }
+ const explicitPaneIds = explicitPaneIdsByTabId.get(tabId) ?? new Set()
+ explicitPaneIds.add(paneId)
+ explicitPaneIdsByTabId.set(tabId, explicitPaneIds)
+ }
+
+ let hasPermission = false
+ let hasWorking = false
+ let hasDone = false
+ for (const tab of liveTabs) {
+ const fresh = freshByTabId.get(tab.id)
+ if (fresh && fresh.length > 0) {
+ if (fresh.some((e) => e.state === 'blocked' || e.state === 'waiting')) {
+ hasPermission = true
+ } else if (fresh.some((e) => e.state === 'working')) {
+ hasWorking = true
+ } else if (fresh.some((e) => e.state === 'done')) {
+ hasDone = true
+ }
+ }
+
+ const explicitPaneIds = explicitPaneIdsByTabId.get(tab.id)
+ const paneTitles = runtimePaneTitlesByTabId[tab.id]
+ if (paneTitles && Object.keys(paneTitles).length > 0) {
+ for (const [paneId, title] of Object.entries(paneTitles)) {
+ // Why: explicit hook status only supersedes the matching pane. Other
+ // split panes still need the title heuristic when hooks are absent.
+ if (explicitPaneIds?.has(paneId)) {
+ continue
+ }
+ const heuristic = detectAgentStatusFromTitle(title)
+ if (heuristic === 'permission') {
+ hasPermission = true
+ break
+ }
+ if (heuristic === 'working') {
+ hasWorking = true
+ }
+ }
+ continue
+ }
+
+ if (fresh && fresh.length > 0) {
+ continue
+ }
+
+ const heuristic = detectAgentStatusFromTitle(tab.title)
+ if (heuristic === 'permission') {
+ hasPermission = true
+ } else if (heuristic === 'working') {
+ hasWorking = true
+ }
+ }
+
+ if (hasPermission) {
+ return 'permission'
+ }
+ if (hasWorking) {
+ return 'working'
+ }
+ // Why: surface 'done' as its own status so the sidebar dot turns blue
+ // (sky-500/80) — matching the dashboard's done color. A completed agent
+ // still has a live terminal, so 'inactive' would be misleading; calling
+ // it 'done' keeps the two surfaces in agreement on what the agent is.
+ if (hasDone) {
+ return 'done'
+ }
+ return 'active'
+}
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
index 6fa62a6fcfc..861e874abe6 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
@@ -6,7 +6,8 @@ import {
buildRows,
getLineageGroupKey,
getLineageRenderInfo,
- getPRGroupKey
+ getPRGroupKey,
+ getRepoGroupOrdering
} from './worktree-list-groups'
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
@@ -63,9 +64,14 @@ describe('buildRows with pinned worktrees', () => {
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
})
- it('emits an All header between pinned and unpinned in groupBy none', () => {
+ it('emits status headers for unpinned worktrees in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
- expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 })
+ expect(rows[2]).toMatchObject({
+ type: 'header',
+ key: 'workspace-status:in-progress',
+ label: 'In progress',
+ count: 2
+ })
expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } })
})
@@ -82,22 +88,36 @@ describe('buildRows with pinned worktrees', () => {
}
})
- it('does not emit pinned section when no worktrees are pinned', () => {
+ it('keeps an empty pinned drop section above statuses in groupBy none', () => {
const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set())
- expect(rows.every((r) => r.type === 'item')).toBe(true)
+ expect(rows[0]).toMatchObject({
+ type: 'header',
+ key: 'pinned',
+ label: 'Pinned',
+ count: 0
+ })
+ expect(rows[1]).toMatchObject({
+ type: 'header',
+ key: 'workspace-status:in-progress',
+ label: 'In progress',
+ count: 2
+ })
+ expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
+ expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } })
})
it('collapses pinned group when in collapsedGroups', () => {
const rows = buildRows('none', [pinned, unpinned1], repoMap, null, new Set(['pinned']))
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' })
- expect(rows[1]).toMatchObject({ type: 'header', key: 'all' })
+ expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' })
expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
})
- it('does not emit All header when all worktrees are pinned', () => {
+ it('does not emit empty status sections when all worktrees are pinned', () => {
const allPinned = { ...unpinned1, isPinned: true }
const rows = buildRows('none', [pinned, allPinned], repoMap, null, new Set())
- expect(rows.some((r) => r.type === 'header' && r.key === 'all')).toBe(false)
+ expect(rows.filter((r) => r.type === 'header')).toHaveLength(1)
+ expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 })
})
it('preserves repo display casing in group labels', () => {
@@ -141,6 +161,49 @@ describe('buildRows with pinned worktrees', () => {
})
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: folderWorktree.id } })
})
+
+ it('emits assigned workspace statuses as sections in groupBy none', () => {
+ const review = { ...worktree, id: 'wt-review', workspaceStatus: 'in-review' as const }
+ const rows = buildRows('none', [review], repoMap, null, new Set())
+
+ expect(
+ rows
+ .filter((r) => r.type === 'header')
+ .map((r) => ({ key: r.key, label: r.label, count: r.count }))
+ ).toEqual([
+ { key: 'pinned', label: 'Pinned', count: 0 },
+ { key: 'workspace-status:in-review', label: 'In review', count: 1 }
+ ])
+ })
+
+ it('uses customized workspace status labels and order', () => {
+ const customStatuses = [
+ { id: 'blocked', label: 'Blocked' },
+ { id: 'todo', label: 'Ready' },
+ { id: 'in-progress', label: 'Doing' }
+ ]
+ const blocked = { ...worktree, id: 'wt-blocked', workspaceStatus: 'blocked' }
+ const doing = { ...worktree, id: 'wt-doing', workspaceStatus: 'in-progress' }
+ const rows = buildRows(
+ 'none',
+ [doing, blocked],
+ repoMap,
+ null,
+ new Set(),
+ undefined,
+ customStatuses
+ )
+
+ expect(
+ rows
+ .filter((r) => r.type === 'header')
+ .map((r) => ({ key: r.key, label: r.label, count: r.count }))
+ ).toEqual([
+ { key: 'pinned', label: 'Pinned', count: 0 },
+ { key: 'workspace-status:blocked', label: 'Blocked', count: 1 },
+ { key: 'workspace-status:in-progress', label: 'Doing', count: 1 }
+ ])
+ })
})
describe('buildRows repo grouping order', () => {
@@ -153,6 +216,7 @@ describe('buildRows repo grouping order', () => {
[repoC.id, repoC]
])
const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' }
+ const wAStale: Worktree = { ...worktree, id: 'wt-a-stale', repoId: repoA.id, displayName: 'a2' }
const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' }
const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' }
@@ -175,6 +239,82 @@ describe('buildRows repo grouping order', () => {
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
})
+
+ it('orders repo headers by first encounter when caller uses visible worktree order', () => {
+ // Caller already sorted worktrees by recency: C is freshest, then A, then B.
+ // Even though repoOrder pins B, A, C, dynamic sorts must follow the freshest
+ // worktree out of each repo so a just-active worktree's parent group
+ // bubbles to the top of the sidebar.
+ const repoOrder = new Map([
+ [repoB.id, 0],
+ [repoA.id, 1],
+ [repoC.id, 2]
+ ])
+ const rows = buildRows(
+ 'repo',
+ [wC, wA, wB],
+ map,
+ null,
+ new Set(),
+ repoOrder,
+ undefined,
+ 'visible-worktree-order'
+ )
+ const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
+ expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b'])
+ })
+
+ it('orders repo headers by each repo highest-ranked visible child', () => {
+ const repoOrder = new Map([
+ [repoB.id, 0],
+ [repoA.id, 1],
+ [repoC.id, 2]
+ ])
+ const rows = buildRows(
+ 'repo',
+ [wA, wB, wAStale, wC],
+ map,
+ null,
+ new Set(),
+ repoOrder,
+ undefined,
+ 'visible-worktree-order'
+ )
+
+ expect(rows).toMatchObject([
+ { type: 'header', key: 'repo:repo-a' },
+ { type: 'item', worktree: { id: 'wt-a' } },
+ { type: 'item', worktree: { id: 'wt-a-stale' } },
+ { type: 'header', key: 'repo:repo-b' },
+ { type: 'item', worktree: { id: 'wt-b' } },
+ { type: 'header', key: 'repo:repo-c' },
+ { type: 'item', worktree: { id: 'wt-c' } }
+ ])
+ })
+
+ it('keeps repoOrder for manual repo group ordering', () => {
+ const repoOrder = new Map([
+ [repoB.id, 0],
+ [repoA.id, 1],
+ [repoC.id, 2]
+ ])
+ const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder)
+ const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
+ expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
+ })
+})
+
+describe('getRepoGroupOrdering', () => {
+ it.each([
+ ['repo', 'recent', 'visible-worktree-order'],
+ ['repo', 'smart', 'visible-worktree-order'],
+ ['repo', 'name', 'manual'],
+ ['repo', 'repo', 'manual'],
+ ['none', 'recent', 'manual'],
+ ['pr-status', 'recent', 'manual']
+ ] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {
+ expect(getRepoGroupOrdering(groupBy, sortBy)).toBe(expected)
+ })
})
describe('buildRows workspace lineage nesting', () => {
@@ -223,6 +363,8 @@ describe('buildRows workspace lineage nesting', () => {
null,
new Set(),
undefined,
+ undefined,
+ undefined,
{ [child.id]: lineage },
new Map([
[parent.id, parent],
@@ -230,9 +372,10 @@ describe('buildRows workspace lineage nesting', () => {
])
)
- expect(rows[0]).toMatchObject({ type: 'item', worktree: { id: child.id } })
- expect(rows[0]).not.toHaveProperty('parentLabel')
- expect(rows[1]).toMatchObject({
+ const items = rows.filter((row) => row.type === 'item')
+ expect(items[0]).toMatchObject({ type: 'item', worktree: { id: child.id } })
+ expect(items[0]).not.toHaveProperty('parentLabel')
+ expect(items[1]).toMatchObject({
type: 'item',
worktree: { id: parent.id }
})
@@ -246,6 +389,8 @@ describe('buildRows workspace lineage nesting', () => {
null,
new Set(),
undefined,
+ undefined,
+ undefined,
{ [child.id]: lineage },
new Map([
[parent.id, parent],
@@ -254,8 +399,9 @@ describe('buildRows workspace lineage nesting', () => {
true
)
- expect(rows[0]).toMatchObject({ type: 'item', worktree: { id: parent.id } })
- expect(rows[1]).toMatchObject({
+ const items = rows.filter((row) => row.type === 'item')
+ expect(items[0]).toMatchObject({ type: 'item', worktree: { id: parent.id } })
+ expect(items[1]).toMatchObject({
type: 'item',
worktree: { id: child.id },
depth: 1,
@@ -271,6 +417,8 @@ describe('buildRows workspace lineage nesting', () => {
null,
new Set(),
undefined,
+ undefined,
+ undefined,
{ [child.id]: lineage, [grandchild.id]: grandchildLineage },
new Map([
[parent.id, parent],
@@ -280,24 +428,21 @@ describe('buildRows workspace lineage nesting', () => {
true
)
- expect(rows.map((row) => (row.type === 'item' ? row.worktree.id : row.key))).toEqual([
- parent.id,
- child.id,
- grandchild.id
- ])
- expect(rows[0]).toMatchObject({
+ const items = rows.filter((row) => row.type === 'item')
+ expect(items.map((row) => row.worktree.id)).toEqual([parent.id, child.id, grandchild.id])
+ expect(items[0]).toMatchObject({
type: 'item',
depth: 0,
lineageChildCount: 1,
lineageCollapsed: false
})
- expect(rows[1]).toMatchObject({
+ expect(items[1]).toMatchObject({
type: 'item',
worktree: { id: child.id },
depth: 1,
lineageChildCount: 1
})
- expect(rows[2]).toMatchObject({
+ expect(items[2]).toMatchObject({
type: 'item',
worktree: { id: grandchild.id },
depth: 2,
@@ -313,6 +458,8 @@ describe('buildRows workspace lineage nesting', () => {
null,
new Set([getLineageGroupKey(parent.id)]),
undefined,
+ undefined,
+ undefined,
{ [child.id]: lineage, [grandchild.id]: grandchildLineage },
new Map([
[parent.id, parent],
@@ -322,8 +469,9 @@ describe('buildRows workspace lineage nesting', () => {
true
)
- expect(rows).toHaveLength(1)
- expect(rows[0]).toMatchObject({
+ const items = rows.filter((row) => row.type === 'item')
+ expect(items).toHaveLength(1)
+ expect(items[0]).toMatchObject({
type: 'item',
worktree: { id: parent.id },
lineageChildCount: 1,
@@ -340,6 +488,8 @@ describe('buildRows workspace lineage nesting', () => {
null,
new Set(),
undefined,
+ undefined,
+ undefined,
{ [child.id]: staleLineage },
new Map([
[parent.id, parent],
@@ -348,7 +498,8 @@ describe('buildRows workspace lineage nesting', () => {
true
)
- expect(rows[0]).toMatchObject({
+ const items = rows.filter((row) => row.type === 'item')
+ expect(items[0]).toMatchObject({
type: 'item',
worktree: { id: child.id },
lineageState: 'missing'
@@ -378,6 +529,8 @@ describe('buildRows workspace lineage nesting', () => {
null,
new Set(),
undefined,
+ undefined,
+ undefined,
{ [child.id]: lineage },
new Map([
[parent.id, parent],
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts
index 689b23159aa..0bbb191ba8c 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts
@@ -1,19 +1,33 @@
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
-import {
- CircleCheckBig,
- CircleDot,
- CircleX,
- Folder,
- GitPullRequest,
- LayoutList,
- Pin
-} from 'lucide-react'
+import { CircleCheckBig, CircleDot, CircleX, Folder, GitPullRequest, Pin } from 'lucide-react'
import type React from 'react'
-import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
+import type {
+ Repo,
+ Worktree,
+ WorktreeLineage,
+ WorkspaceStatusDefinition
+} from '../../../../shared/types'
import { branchName } from '@/lib/git-utils'
+import {
+ getWorkspaceStatus,
+ getWorkspaceStatusFromGroupKey,
+ getWorkspaceStatusGroupKey,
+ getWorkspaceStatusVisualMeta
+} from './workspace-status'
+import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
+import type { SortBy } from './smart-sort'
export { branchName }
+export type WorktreeGroupBy = 'none' | 'repo' | 'pr-status'
+export type RepoGroupOrdering = 'manual' | 'visible-worktree-order'
+
+export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering {
+ return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart')
+ ? 'visible-worktree-order'
+ : 'manual'
+}
+
export type GroupHeaderRow = {
type: 'header'
key: string
@@ -86,14 +100,6 @@ export const PINNED_GROUP_META = {
icon: Pin
} as const
-export const ALL_GROUP_KEY = 'all'
-
-export const ALL_GROUP_META = {
- label: 'All',
- tone: 'text-foreground',
- icon: LayoutList
-} as const
-
export const MISSING_PARENT_GROUP_META = {
label: 'Missing parent'
} as const
@@ -128,7 +134,6 @@ export function getLineageRenderInfo(
}
return { state: 'valid', lineage, parent }
}
-
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map,
@@ -169,10 +174,11 @@ function emitPinnedGroup(
worktreeMap: Map,
collapsedGroups: Set,
result: Row[],
- showLineageContext: boolean
+ showLineageContext: boolean,
+ force = false
): Set {
const pinned = worktrees.filter((w) => w.isPinned)
- if (pinned.length === 0) {
+ if (pinned.length === 0 && !force) {
return new Set()
}
@@ -335,12 +341,14 @@ function appendWorktreeRows(
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
*/
export function buildRows(
- groupBy: 'none' | 'repo' | 'pr-status',
+ groupBy: WorktreeGroupBy,
worktrees: Worktree[],
repoMap: Map,
prCache: Record | null,
collapsedGroups: Set,
repoOrder?: Map,
+ workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
+ repoGroupOrdering: RepoGroupOrdering = 'manual',
lineageById: Record = {},
worktreeMap: Map = new Map(
worktrees.map((worktree) => [worktree.id, worktree])
@@ -356,35 +364,11 @@ export function buildRows(
worktreeMap,
collapsedGroups,
result,
- nestLineage
+ nestLineage,
+ groupBy === 'none'
)
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
- if (groupBy === 'none') {
- // Without an "All" header, the unpinned block is visually indistinguishable
- // from a continuation of the Pinned section — so when pinned items exist,
- // mark the boundary with a sibling header that mirrors the Pinned one.
- if (pinnedIds.size > 0 && unpinned.length > 0) {
- result.push({
- type: 'header',
- key: ALL_GROUP_KEY,
- label: ALL_GROUP_META.label,
- count: unpinned.length,
- tone: ALL_GROUP_META.tone,
- icon: ALL_GROUP_META.icon
- })
- if (collapsedGroups.has(ALL_GROUP_KEY)) {
- return result
- }
- }
- appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
- nestLineage,
- showLineageContext: nestLineage,
- collapsedGroups
- })
- return result
- }
-
const grouped = new Map()
for (const w of unpinned) {
let key: string
@@ -394,6 +378,11 @@ export function buildRows(
repo = repoMap.get(w.repoId)
key = `repo:${w.repoId}`
label = repo?.displayName ?? 'Unknown'
+ } else if (groupBy === 'none') {
+ const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses)
+ key = getWorkspaceStatusGroupKey(workspaceStatus)
+ label =
+ workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus
} else {
const prGroup = getPRGroupKey(w, repoMap, prCache)
key = `pr:${prGroup}`
@@ -414,14 +403,23 @@ export function buildRows(
orderedGroups.push([key, group])
}
}
+ } else if (groupBy === 'none') {
+ // Why: the old "All" grouping now organizes workspaces by user status.
+ // Keep the sidebar compact by rendering only sections that contain cards;
+ // the board drawer is the wider all-lanes drag target.
+ for (const status of workspaceStatuses) {
+ const key = getWorkspaceStatusGroupKey(status.id)
+ const group = grouped.get(key)
+ if (group) {
+ orderedGroups.push([key, group])
+ }
+ }
} else {
- // Why: header order must follow the canonical state.repos array order, not
- // first-encounter from the smart-sorted worktree stream — otherwise sorting
- // or filtering side effects could shuffle which repo header appears first,
- // and manual reorder would have nothing to bind to. Unknown ids (no entry
- // in repoOrder) sort last by label so they remain deterministic.
+ // Why: dynamic sorts need repo headers to follow their highest-ranked
+ // visible child. Manual ordering still uses the canonical state.repos
+ // order so repo-header drag has a stable source of truth.
const entries = Array.from(grouped.entries())
- if (repoOrder) {
+ if (repoGroupOrdering === 'manual' && repoOrder) {
const rankFor = (key: string): number => {
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
const rank = repoOrder.get(repoId)
@@ -453,18 +451,35 @@ export function buildRows(
icon: REPO_GROUP_META.icon,
repo
}
- : (() => {
- const prGroup = key.replace(/^pr:/, '') as PRGroupKey
- const meta = PR_GROUP_META[prGroup]
- return {
- type: 'header' as const,
- key,
- label: meta.label,
- count: group.items.length,
- tone: meta.tone,
- icon: meta.icon
- }
- })()
+ : groupBy === 'none'
+ ? (() => {
+ const workspaceStatus =
+ getWorkspaceStatusFromGroupKey(key, workspaceStatuses) ??
+ workspaceStatuses[0]?.id ??
+ 'in-progress'
+ const definition = workspaceStatuses.find((status) => status.id === workspaceStatus)
+ const meta = getWorkspaceStatusVisualMeta(definition ?? workspaceStatus)
+ return {
+ type: 'header' as const,
+ key,
+ label: definition?.label ?? workspaceStatus,
+ count: group.items.length,
+ tone: meta.tone,
+ icon: meta.icon
+ }
+ })()
+ : (() => {
+ const prGroup = key.replace(/^pr:/, '') as PRGroupKey
+ const meta = PR_GROUP_META[prGroup]
+ return {
+ type: 'header' as const,
+ key,
+ label: meta.label,
+ count: group.items.length,
+ tone: meta.tone,
+ icon: meta.icon
+ }
+ })()
result.push(header)
if (!isCollapsed) {
@@ -480,13 +495,14 @@ export function buildRows(
}
export function getGroupKeyForWorktree(
- groupBy: 'none' | 'repo' | 'pr-status',
+ groupBy: WorktreeGroupBy,
worktree: Worktree,
repoMap: Map,
- prCache: Record | null
+ prCache: Record | null,
+ workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses()
): string | null {
if (groupBy === 'none') {
- return null
+ return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses))
}
if (groupBy === 'repo') {
return `repo:${worktree.repoId}`
diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx
index 6722489a9fe..266b18a0e9a 100644
--- a/src/renderer/src/components/stats/StatsPane.tsx
+++ b/src/renderer/src/components/stats/StatsPane.tsx
@@ -4,6 +4,7 @@ import { useAppStore } from '../../store'
import { StatCard } from './StatCard'
import { ClaudeUsagePane } from './ClaudeUsagePane'
import { CodexUsagePane } from './CodexUsagePane'
+import { UsageOverviewPane } from './UsageOverviewPane'
import type { SettingsSearchEntry } from '../settings/settings-search'
import { cn } from '@/lib/utils'
@@ -11,7 +12,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Stats & Usage',
description:
- 'Orca stats plus Claude and Codex usage analytics, tokens, cache, models, and sessions.',
+ 'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.',
keywords: [
'stats',
'usage',
@@ -59,7 +60,7 @@ function formatTrackingSince(timestamp: number | null): string {
export function StatsPane(): React.JSX.Element {
const summary = useAppStore((s) => s.statsSummary)
const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary)
- const [activeUsageTab, setActiveUsageTab] = useState<'claude' | 'codex'>('claude')
+ const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview')
useEffect(() => {
void fetchStatsSummary()
@@ -110,7 +111,7 @@ export function StatsPane(): React.JSX.Element {
aria-label="Usage analytics provider"
className="inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground"
>
- {(['claude', 'codex'] as const).map((tab) => (
+ {(['overview', 'claude', 'codex'] as const).map((tab) => (
- {tab === 'claude' ? 'Claude' : 'Codex'}
+ {tab === 'overview' ? 'Overview' : tab === 'claude' ? 'Claude' : 'Codex'}
))}
@@ -132,7 +133,15 @@ export function StatsPane(): React.JSX.Element {
{/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the
active panel mounted avoids hidden tab-content layout/focus churn that produced a visible
vertical jitter below the usage card when switching disabled providers. */}
-
{activeUsageTab === 'claude' ? : }
+
+ {activeUsageTab === 'overview' ? (
+
+ ) : activeUsageTab === 'claude' ? (
+
+ ) : (
+
+ )}
+
)
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx
new file mode 100644
index 00000000000..202fcdf09d4
--- /dev/null
+++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx
@@ -0,0 +1,412 @@
+import { useEffect, useMemo } from 'react'
+import {
+ Activity,
+ AlertCircle,
+ CalendarDays,
+ Coins,
+ DatabaseZap,
+ RefreshCw,
+ Sparkles
+} from 'lucide-react'
+import { useAppStore } from '../../store'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
+import { StatCard } from './StatCard'
+import {
+ buildUsageOverview,
+ formatUsageCost,
+ formatUsageTokens,
+ getRecentUsageDays,
+ type UsageOverviewDailyPoint,
+ type UsageOverviewModel,
+ type UsageProviderOverview
+} from './usage-overview-model'
+
+const RECENT_DAY_COUNT = 42
+
+const INTENSITY_CLASS: Record = {
+ 0: 'border-border/60 bg-muted/40',
+ 1: 'border-border/60 bg-muted-foreground/20',
+ 2: 'border-border/60 bg-muted-foreground/35',
+ 3: 'border-border/60 bg-muted-foreground/55',
+ 4: 'border-border/60 bg-foreground/75'
+}
+
+function formatPercent(value: number | null): string {
+ if (value === null) {
+ return 'n/a'
+ }
+ return `${Math.round(value * 100)}%`
+}
+
+function formatUpdatedAt(timestamp: number | null): string {
+ if (!timestamp) {
+ return 'Not scanned yet'
+ }
+ return `Updated ${new Date(timestamp).toLocaleString()}`
+}
+
+function formatDayLabel(day: string): string {
+ const parsed = new Date(`${day}T12:00:00`)
+ if (Number.isNaN(parsed.getTime())) {
+ return day
+ }
+ return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+}
+
+function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element {
+ const segments = [
+ {
+ key: 'new-input',
+ label: 'New input',
+ value: overview.newInputTokens,
+ className: 'bg-foreground'
+ },
+ {
+ key: 'output',
+ label: 'Output',
+ value: overview.outputTokens,
+ className: 'bg-muted-foreground'
+ },
+ {
+ key: 'cache',
+ label: 'Cache',
+ value: overview.cacheTokens,
+ className: 'bg-border'
+ }
+ ]
+ // Why: Codex cached input is a subset of input. The overview model normalizes
+ // that into new/cache buckets so the visual mix does not double-count it.
+ const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0)
+
+ return (
+
+
+
+
Token mix
+
+ Combined input, output, and cache tokens across enabled providers.
+
+
+ {overview.reasoningTokens > 0 ? (
+
+ {formatUsageTokens(overview.reasoningTokens)} reasoning
+
+ ) : null}
+
+
+ {mixTotal > 0 ? (
+
+ {segments.map((segment) =>
+ segment.value > 0 ? (
+
+ ) : null
+ )}
+
+ ) : (
+
+ )}
+
+
+ {segments.map((segment) => (
+
+
+
+ {segment.label}: {formatUsageTokens(segment.value)}
+
+
+ ))}
+
+
+ )
+}
+
+function DailyIntensityGrid({
+ days,
+ bestDay
+}: {
+ days: UsageOverviewDailyPoint[]
+ bestDay: UsageOverviewDailyPoint | null
+}): React.JSX.Element {
+ return (
+
+
+
+
Daily intensity
+
+ Recent combined Claude and Codex token activity.
+
+
+ {bestDay && bestDay.totalTokens > 0 ? (
+
+ Best: {formatDayLabel(bestDay.day)}
+
+ ) : null}
+
+
+
+ {days.map((day) => (
+
+ ))}
+
+
+
+
{formatDayLabel(days[0]?.day ?? '')}
+
Less
+
+ {[0, 1, 2, 3, 4].map((intensity) => (
+
+ ))}
+
+
More
+
{formatDayLabel(days.at(-1)?.day ?? '')}
+
+
+ )
+}
+
+function ProviderRow({
+ provider,
+ totalTokens,
+ onEnable
+}: {
+ provider: UsageProviderOverview
+ totalTokens: number
+ onEnable: () => void
+}): React.JSX.Element {
+ const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0
+ const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off'
+ const statusVariant = provider.enabled ? 'secondary' : 'outline'
+
+ return (
+
+
+
+
+
{provider.label}
+ {status}
+
+
+ {provider.topModel ?? 'No model yet'}
+ {provider.topProject ? ` - ${provider.topProject}` : ''}
+
+
+ {!provider.enabled ? (
+
+ Enable
+
+ ) : null}
+
+
+
+ {formatUsageTokens(provider.totalTokens)} tokens
+
+ {provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '}
+ {provider.activityLabel}
+
+ {formatUsageCost(provider.estimatedCostUsd)}
+
+
+
0 ? 2 : 0)}%` }}
+ />
+
+ {provider.lastScanError ? (
+
+
+ {provider.lastScanError}
+
+ ) : null}
+
+ )
+}
+
+export function UsageOverviewPane(): React.JSX.Element {
+ const claudeScanState = useAppStore((state) => state.claudeUsageScanState)
+ const claudeSummary = useAppStore((state) => state.claudeUsageSummary)
+ const claudeDaily = useAppStore((state) => state.claudeUsageDaily)
+ const codexScanState = useAppStore((state) => state.codexUsageScanState)
+ const codexSummary = useAppStore((state) => state.codexUsageSummary)
+ const codexDaily = useAppStore((state) => state.codexUsageDaily)
+ const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
+ const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
+ const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
+ const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
+ const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage)
+ const enableCodexUsage = useAppStore((state) => state.enableCodexUsage)
+
+ useEffect(() => {
+ void fetchClaudeUsage()
+ void fetchCodexUsage()
+ }, [fetchClaudeUsage, fetchCodexUsage])
+
+ const overview = useMemo(
+ () =>
+ buildUsageOverview({
+ claude: {
+ scanState: claudeScanState,
+ summary: claudeSummary,
+ daily: claudeDaily
+ },
+ codex: {
+ scanState: codexScanState,
+ summary: codexSummary,
+ daily: codexDaily
+ }
+ }),
+ [claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary]
+ )
+ const recentDays = useMemo(
+ () => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT),
+ [overview.daily]
+ )
+ const isScanning = overview.providers.some((provider) => provider.isScanning)
+
+ const handleRefresh = (): void => {
+ void Promise.all([
+ claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(),
+ codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve()
+ ])
+ }
+
+ return (
+
+
+
+
+
Usage Overview
+
+ {formatUpdatedAt(overview.lastUpdatedAt)}
+ {overview.hasPartialCost ? ' - some model prices are unavailable' : ''}
+
+
+
+
+
+
+
+
+
+ Refresh
+
+
+
+
+ {!overview.hasAnyEnabledProvider ? (
+
+
+
+
Start tracking tokens
+
+ Enable a provider to scan local agent logs and build the combined token ledger.
+
+
+
+ void enableClaudeUsage()}>
+ Enable Claude
+
+ void enableCodexUsage()}>
+ Enable Codex
+
+
+
+
+ ) : (
+ <>
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+ {!overview.hasAnyData ? (
+
+ No local Claude or Codex usage found yet. The overview will populate after the next
+ agent session writes token logs.
+
+ ) : (
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+
Providers
+
+ {overview.enabledProviderCount} enabled - {overview.dataProviderCount} with data
+
+
+
+
+ {overview.sessions.toLocaleString()} sessions
+
+
+
+ {overview.providers.map((provider) => (
+
{
+ if (provider.id === 'claude') {
+ void enableClaudeUsage()
+ } else {
+ void enableCodexUsage()
+ }
+ }}
+ />
+ ))}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/stats/usage-overview-model.test.ts b/src/renderer/src/components/stats/usage-overview-model.test.ts
new file mode 100644
index 00000000000..490bcc1b8f4
--- /dev/null
+++ b/src/renderer/src/components/stats/usage-overview-model.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ ClaudeUsageDailyPoint,
+ ClaudeUsageScanState,
+ ClaudeUsageSummary
+} from '../../../../shared/claude-usage-types'
+import type {
+ CodexUsageDailyPoint,
+ CodexUsageScanState,
+ CodexUsageSummary
+} from '../../../../shared/codex-usage-types'
+import {
+ buildUsageOverview,
+ formatUsageCost,
+ formatUsageTokens,
+ getRecentUsageDays
+} from './usage-overview-model'
+
+function enabledClaudeScanState(): ClaudeUsageScanState {
+ return {
+ enabled: true,
+ isScanning: false,
+ lastScanStartedAt: 100,
+ lastScanCompletedAt: 200,
+ lastScanError: null,
+ hasAnyClaudeData: true
+ }
+}
+
+function enabledCodexScanState(): CodexUsageScanState {
+ return {
+ enabled: true,
+ isScanning: false,
+ lastScanStartedAt: 300,
+ lastScanCompletedAt: 400,
+ lastScanError: null,
+ hasAnyCodexData: true
+ }
+}
+
+describe('usage overview model', () => {
+ it('combines Claude and Codex totals without double-counting Codex cached input', () => {
+ const claudeSummary: ClaudeUsageSummary = {
+ scope: 'orca',
+ range: '30d',
+ sessions: 2,
+ turns: 4,
+ zeroCacheReadTurns: 1,
+ inputTokens: 1_000,
+ outputTokens: 500,
+ cacheReadTokens: 4_000,
+ cacheWriteTokens: 500,
+ cacheReuseRate: 0.8,
+ estimatedCostUsd: 0.04,
+ topModel: 'claude-sonnet-4-5',
+ topProject: 'orca-main',
+ hasAnyClaudeData: true
+ }
+ const codexSummary: CodexUsageSummary = {
+ scope: 'orca',
+ range: '30d',
+ sessions: 1,
+ events: 3,
+ inputTokens: 2_000,
+ cachedInputTokens: 800,
+ outputTokens: 1_200,
+ reasoningOutputTokens: 300,
+ totalTokens: 3_200,
+ estimatedCostUsd: 0.02,
+ topModel: 'gpt-5.4',
+ topProject: 'orca-secondary',
+ hasAnyCodexData: true
+ }
+ const claudeDaily: ClaudeUsageDailyPoint[] = [
+ {
+ day: '2026-05-13',
+ inputTokens: 500,
+ outputTokens: 500,
+ cacheReadTokens: 2_500,
+ cacheWriteTokens: 0
+ },
+ {
+ day: '2026-05-14',
+ inputTokens: 500,
+ outputTokens: 0,
+ cacheReadTokens: 1_500,
+ cacheWriteTokens: 500
+ }
+ ]
+ const codexDaily: CodexUsageDailyPoint[] = [
+ {
+ day: '2026-05-14',
+ inputTokens: 1_200,
+ cachedInputTokens: 400,
+ outputTokens: 800,
+ reasoningOutputTokens: 100,
+ totalTokens: 2_000
+ },
+ {
+ day: '2026-05-15',
+ inputTokens: 800,
+ cachedInputTokens: 400,
+ outputTokens: 400,
+ reasoningOutputTokens: 200,
+ totalTokens: 1_200
+ }
+ ]
+
+ const overview = buildUsageOverview({
+ claude: {
+ scanState: enabledClaudeScanState(),
+ summary: claudeSummary,
+ daily: claudeDaily
+ },
+ codex: {
+ scanState: enabledCodexScanState(),
+ summary: codexSummary,
+ daily: codexDaily
+ }
+ })
+
+ expect(overview.totalTokens).toBe(9_200)
+ expect(overview.newInputTokens).toBe(2_200)
+ expect(overview.cacheTokens).toBe(5_300)
+ expect(overview.outputTokens).toBe(1_700)
+ expect(overview.reasoningTokens).toBe(300)
+ expect(overview.sessions).toBe(3)
+ expect(overview.activityCount).toBe(7)
+ expect(overview.activeDays).toBe(3)
+ expect(overview.estimatedCostUsd).toBeCloseTo(0.06)
+ expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500)
+ expect(overview.bestDay).toMatchObject({
+ day: '2026-05-14',
+ totalTokens: 4_500,
+ claudeTokens: 2_500,
+ codexTokens: 2_000,
+ intensity: 4
+ })
+ expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({
+ newInputTokens: 1_200,
+ cacheTokens: 800,
+ totalTokens: 3_200
+ })
+ })
+
+ it('pads recent usage days with zero-token cells', () => {
+ const recent = getRecentUsageDays(
+ [
+ {
+ day: '2026-05-14',
+ totalTokens: 4_500,
+ claudeTokens: 2_500,
+ codexTokens: 2_000,
+ intensity: 4
+ }
+ ],
+ 3,
+ new Date('2026-05-15T12:00:00')
+ )
+
+ expect(recent).toEqual([
+ {
+ day: '2026-05-13',
+ totalTokens: 0,
+ claudeTokens: 0,
+ codexTokens: 0,
+ intensity: 0
+ },
+ {
+ day: '2026-05-14',
+ totalTokens: 4_500,
+ claudeTokens: 2_500,
+ codexTokens: 2_000,
+ intensity: 4
+ },
+ {
+ day: '2026-05-15',
+ totalTokens: 0,
+ claudeTokens: 0,
+ codexTokens: 0,
+ intensity: 0
+ }
+ ])
+ })
+
+ it('reports disabled providers as an empty overview', () => {
+ const overview = buildUsageOverview({
+ claude: { scanState: null, summary: null, daily: [] },
+ codex: { scanState: null, summary: null, daily: [] }
+ })
+
+ expect(overview.hasAnyEnabledProvider).toBe(false)
+ expect(overview.hasAnyData).toBe(false)
+ expect(overview.totalTokens).toBe(0)
+ expect(overview.estimatedCostUsd).toBeNull()
+ expect(overview.cacheShare).toBeNull()
+ })
+
+ it('formats token and cost values for compact UI labels', () => {
+ expect(formatUsageTokens(999)).toBe('999')
+ expect(formatUsageTokens(1_200)).toBe('1.2k')
+ expect(formatUsageTokens(2_500_000)).toBe('2.5M')
+ expect(formatUsageCost(null)).toBe('n/a')
+ expect(formatUsageCost(0.0042)).toBe('$0.0042')
+ expect(formatUsageCost(1.234)).toBe('$1.23')
+ })
+})
diff --git a/src/renderer/src/components/stats/usage-overview-model.ts b/src/renderer/src/components/stats/usage-overview-model.ts
new file mode 100644
index 00000000000..47104aec739
--- /dev/null
+++ b/src/renderer/src/components/stats/usage-overview-model.ts
@@ -0,0 +1,319 @@
+import type {
+ ClaudeUsageDailyPoint,
+ ClaudeUsageScanState,
+ ClaudeUsageSummary
+} from '../../../../shared/claude-usage-types'
+import type {
+ CodexUsageDailyPoint,
+ CodexUsageScanState,
+ CodexUsageSummary
+} from '../../../../shared/codex-usage-types'
+
+export type UsageProviderId = 'claude' | 'codex'
+
+export type UsageProviderOverview = {
+ id: UsageProviderId
+ label: string
+ enabled: boolean
+ isScanning: boolean
+ hasData: boolean
+ lastScanCompletedAt: number | null
+ lastScanError: string | null
+ sessions: number
+ activityLabel: 'turns' | 'events'
+ activityCount: number
+ totalTokens: number
+ newInputTokens: number
+ outputTokens: number
+ cacheTokens: number
+ reasoningTokens: number
+ estimatedCostUsd: number | null
+ topModel: string | null
+ topProject: string | null
+ activeDays: number
+}
+
+export type UsageOverviewDailyPoint = {
+ day: string
+ totalTokens: number
+ claudeTokens: number
+ codexTokens: number
+ intensity: 0 | 1 | 2 | 3 | 4
+}
+
+export type UsageOverviewModel = {
+ providers: UsageProviderOverview[]
+ enabledProviderCount: number
+ dataProviderCount: number
+ hasAnyEnabledProvider: boolean
+ hasAnyData: boolean
+ totalTokens: number
+ newInputTokens: number
+ outputTokens: number
+ cacheTokens: number
+ reasoningTokens: number
+ sessions: number
+ activityCount: number
+ activeDays: number
+ estimatedCostUsd: number | null
+ hasPartialCost: boolean
+ cacheShare: number | null
+ daily: UsageOverviewDailyPoint[]
+ bestDay: UsageOverviewDailyPoint | null
+ lastUpdatedAt: number | null
+}
+
+export type UsageOverviewInput = {
+ claude: {
+ scanState: ClaudeUsageScanState | null
+ summary: ClaudeUsageSummary | null
+ daily: ClaudeUsageDailyPoint[]
+ }
+ codex: {
+ scanState: CodexUsageScanState | null
+ summary: CodexUsageSummary | null
+ daily: CodexUsageDailyPoint[]
+ }
+}
+
+function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number {
+ return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens
+}
+
+function getCodexNewInputTokens(summary: CodexUsageSummary | null): number {
+ if (!summary) {
+ return 0
+ }
+ return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
+}
+
+function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 {
+ if (totalTokens <= 0 || maxTokens <= 0) {
+ return 0
+ }
+ const ratio = totalTokens / maxTokens
+ if (ratio <= 0.25) {
+ return 1
+ }
+ if (ratio <= 0.5) {
+ return 2
+ }
+ if (ratio <= 0.75) {
+ return 3
+ }
+ return 4
+}
+
+function countActiveDays(days: string[]): number {
+ return new Set(days).size
+}
+
+function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview {
+ const summary = input.summary
+ const dailyActiveDays = input.daily
+ .filter((entry) => getClaudeDailyTotal(entry) > 0)
+ .map((entry) => entry.day)
+ return {
+ id: 'claude',
+ label: 'Claude',
+ enabled: input.scanState?.enabled ?? false,
+ isScanning: input.scanState?.isScanning ?? false,
+ hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false,
+ lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
+ lastScanError: input.scanState?.lastScanError ?? null,
+ sessions: summary?.sessions ?? 0,
+ activityLabel: 'turns',
+ activityCount: summary?.turns ?? 0,
+ totalTokens: summary
+ ? summary.inputTokens +
+ summary.outputTokens +
+ summary.cacheReadTokens +
+ summary.cacheWriteTokens
+ : 0,
+ newInputTokens: summary?.inputTokens ?? 0,
+ outputTokens: summary?.outputTokens ?? 0,
+ cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0,
+ reasoningTokens: 0,
+ estimatedCostUsd: summary?.estimatedCostUsd ?? null,
+ topModel: summary?.topModel ?? null,
+ topProject: summary?.topProject ?? null,
+ activeDays: countActiveDays(dailyActiveDays)
+ }
+}
+
+function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview {
+ const summary = input.summary
+ const dailyActiveDays = input.daily
+ .filter((entry) => entry.totalTokens > 0)
+ .map((entry) => entry.day)
+ return {
+ id: 'codex',
+ label: 'Codex',
+ enabled: input.scanState?.enabled ?? false,
+ isScanning: input.scanState?.isScanning ?? false,
+ hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false,
+ lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
+ lastScanError: input.scanState?.lastScanError ?? null,
+ sessions: summary?.sessions ?? 0,
+ activityLabel: 'events',
+ activityCount: summary?.events ?? 0,
+ totalTokens: summary?.totalTokens ?? 0,
+ newInputTokens: getCodexNewInputTokens(summary),
+ outputTokens: summary?.outputTokens ?? 0,
+ cacheTokens: summary?.cachedInputTokens ?? 0,
+ reasoningTokens: summary?.reasoningOutputTokens ?? 0,
+ estimatedCostUsd: summary?.estimatedCostUsd ?? null,
+ topModel: summary?.topModel ?? null,
+ topProject: summary?.topProject ?? null,
+ activeDays: countActiveDays(dailyActiveDays)
+ }
+}
+
+function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] {
+ const byDay = new Map
>()
+
+ for (const entry of input.claude.daily) {
+ const current = byDay.get(entry.day) ?? {
+ day: entry.day,
+ totalTokens: 0,
+ claudeTokens: 0,
+ codexTokens: 0
+ }
+ const total = getClaudeDailyTotal(entry)
+ current.totalTokens += total
+ current.claudeTokens += total
+ byDay.set(entry.day, current)
+ }
+
+ for (const entry of input.codex.daily) {
+ const current = byDay.get(entry.day) ?? {
+ day: entry.day,
+ totalTokens: 0,
+ claudeTokens: 0,
+ codexTokens: 0
+ }
+ current.totalTokens += entry.totalTokens
+ current.codexTokens += entry.totalTokens
+ byDay.set(entry.day, current)
+ }
+
+ const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens))
+ return [...byDay.values()]
+ .sort((left, right) => left.day.localeCompare(right.day))
+ .map((entry) => ({
+ ...entry,
+ intensity: getIntensity(entry.totalTokens, maxTokens)
+ }))
+}
+
+function formatLocalDay(date: Date): string {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+export function getRecentUsageDays(
+ daily: UsageOverviewDailyPoint[],
+ dayCount: number,
+ anchorDate = new Date()
+): UsageOverviewDailyPoint[] {
+ const byDay = new Map(daily.map((entry) => [entry.day, entry]))
+ const count = Math.max(1, Math.floor(dayCount))
+ const end = new Date(anchorDate)
+ end.setHours(0, 0, 0, 0)
+
+ const result: UsageOverviewDailyPoint[] = []
+ for (let offset = count - 1; offset >= 0; offset--) {
+ const date = new Date(end)
+ date.setDate(end.getDate() - offset)
+ const day = formatLocalDay(date)
+ result.push(
+ byDay.get(day) ?? {
+ day,
+ totalTokens: 0,
+ claudeTokens: 0,
+ codexTokens: 0,
+ intensity: 0
+ }
+ )
+ }
+ return result
+}
+
+export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel {
+ const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)]
+ const daily = buildDailyOverview(input)
+ const bestDay =
+ daily.length === 0
+ ? null
+ : daily.reduce(
+ (best, entry) => (!best || entry.totalTokens > best.totalTokens ? entry : best),
+ null
+ )
+ const totalTokens = providers.reduce((sum, provider) => sum + provider.totalTokens, 0)
+ const newInputTokens = providers.reduce((sum, provider) => sum + provider.newInputTokens, 0)
+ const outputTokens = providers.reduce((sum, provider) => sum + provider.outputTokens, 0)
+ const cacheTokens = providers.reduce((sum, provider) => sum + provider.cacheTokens, 0)
+ const reasoningTokens = providers.reduce((sum, provider) => sum + provider.reasoningTokens, 0)
+ const sessions = providers.reduce((sum, provider) => sum + provider.sessions, 0)
+ const activityCount = providers.reduce((sum, provider) => sum + provider.activityCount, 0)
+ const knownCost = providers.reduce((sum, provider) => sum + (provider.estimatedCostUsd ?? 0), 0)
+ const hasKnownCost = providers.some((provider) => provider.estimatedCostUsd !== null)
+ const hasPartialCost = providers.some(
+ (provider) => provider.hasData && provider.estimatedCostUsd === null
+ )
+ const lastUpdatedAt =
+ providers.reduce(
+ (latest, provider) =>
+ provider.lastScanCompletedAt && (!latest || provider.lastScanCompletedAt > latest)
+ ? provider.lastScanCompletedAt
+ : latest,
+ null
+ ) ?? null
+
+ return {
+ providers,
+ enabledProviderCount: providers.filter((provider) => provider.enabled).length,
+ dataProviderCount: providers.filter((provider) => provider.hasData).length,
+ hasAnyEnabledProvider: providers.some((provider) => provider.enabled),
+ hasAnyData: providers.some((provider) => provider.hasData),
+ totalTokens,
+ newInputTokens,
+ outputTokens,
+ cacheTokens,
+ reasoningTokens,
+ sessions,
+ activityCount,
+ activeDays: countActiveDays(
+ daily.filter((entry) => entry.totalTokens > 0).map((entry) => entry.day)
+ ),
+ estimatedCostUsd: hasKnownCost ? knownCost : null,
+ hasPartialCost,
+ cacheShare:
+ newInputTokens + cacheTokens > 0 ? cacheTokens / (newInputTokens + cacheTokens) : null,
+ daily,
+ bestDay,
+ lastUpdatedAt
+ }
+}
+
+export function formatUsageTokens(value: number): string {
+ if (value >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(1)}B`
+ }
+ if (value >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(1)}M`
+ }
+ if (value >= 1_000) {
+ return `${(value / 1_000).toFixed(1)}k`
+ }
+ return value.toLocaleString()
+}
+
+export function formatUsageCost(value: number | null): string {
+ if (value === null) {
+ return 'n/a'
+ }
+ return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
+}
diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
index 2908994204b..a904e9796db 100644
--- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
@@ -7,6 +7,7 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AlertTriangle,
+ ArchiveX,
ChevronDown,
ChevronRight,
LoaderCircle,
@@ -32,12 +33,15 @@ import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { useAppStore } from '../../store'
-import { useWorktreeMap } from '../../store/selectors'
+import { useAllWorktrees, useWorktreeMap } from '../../store/selectors'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { runSleepWorktree } from '../sidebar/sleep-worktree-flow'
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types'
import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
+import { isFolderRepo } from '../../../../shared/repo-kind'
+import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup'
+import { parsePaneKey } from '../../../../shared/stable-pane-id'
import {
mergeSnapshotAndSessions,
UNATTRIBUTED_REPO_ID,
@@ -47,6 +51,7 @@ import {
type UnifiedSessionRow,
type UnifiedWorktreeRow
} from './mergeSnapshotAndSessions'
+import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel'
const POLL_MS = 2_000
const SESSIONS_POLL_MS = 10_000
@@ -654,7 +659,14 @@ export function ResourceUsageStatusSegment({
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const setActiveView = useAppStore((s) => s.setActiveView)
+ const openModal = useAppStore((s) => s.openModal)
+ const openSpacePage = useAppStore((s) => s.openSpacePage)
const repos = useAppStore((s) => s.repos)
+ const allWorktrees = useAllWorktrees()
+ const activeRuntimeEnvironmentId = useAppStore(
+ (s) => s.settings?.activeRuntimeEnvironmentId ?? null
+ )
+ const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
const [open, setOpen] = useState(false)
const [sortOption, setSortOption] = useState('memory')
@@ -665,6 +677,10 @@ export function ResourceUsageStatusSegment({
const [sessionsError, setSessionsError] = useState(false)
const [killConfirm, setKillConfirm] = useState(null)
const [killing, setKilling] = useState(false)
+ // Why: this segment only understands the local Electron PTY/resource daemon.
+ // While a runtime server is active, hiding local samples avoids showing or
+ // killing sessions from the wrong machine.
+ const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot
// Why: after a kill confirms and the session unmounts, focus would otherwise
// fall to . We park a ref on the popover body so we can restore focus
@@ -672,6 +688,11 @@ export function ResourceUsageStatusSegment({
const popoverBodyRef = useRef(null)
const refreshSessions = useCallback(async () => {
+ if (runtimeEnvironmentActive) {
+ setSessions([])
+ setSessionsError(false)
+ return
+ }
try {
const result = await window.api.pty.listSessions()
setSessions(result)
@@ -679,7 +700,7 @@ export function ResourceUsageStatusSegment({
} catch {
setSessionsError(true)
}
- }, [])
+ }, [runtimeEnvironmentActive])
const daemonActions = useDaemonActions({
onRestartSettled: () => {
@@ -696,7 +717,7 @@ export function ResourceUsageStatusSegment({
// background at a slower rate so the badge count stays reasonably fresh
// without keeping the Memory IPC hot.
useEffect(() => {
- if (!open) {
+ if (!open || runtimeEnvironmentActive) {
return
}
void fetchSnapshot()
@@ -711,9 +732,14 @@ export function ResourceUsageStatusSegment({
return () => {
window.clearInterval(memTimer)
}
- }, [open, fetchSnapshot, refreshSessions])
+ }, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions])
useEffect(() => {
+ if (runtimeEnvironmentActive) {
+ setSessions([])
+ setSessionsError(false)
+ return
+ }
const refreshIfVisible = (): void => {
if (document.visibilityState === 'visible' && document.hasFocus()) {
void refreshSessions()
@@ -731,7 +757,7 @@ export function ResourceUsageStatusSegment({
window.removeEventListener('focus', refreshIfVisible)
document.removeEventListener('visibilitychange', refreshIfVisible)
}
- }, [refreshSessions])
+ }, [runtimeEnvironmentActive, refreshSessions])
const repoDisplayNameById = useMemo(() => {
const map = new Map()
@@ -757,6 +783,23 @@ export function ResourceUsageStatusSegment({
return map
}, [repos])
+ const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
+
+ const oldWorkspaceCount = useMemo(() => {
+ const now = Date.now()
+ let count = 0
+ for (const worktree of allWorktrees) {
+ const repo = repoById.get(worktree.repoId)
+ if (!repo || isFolderRepo(repo) || worktree.isMainWorktree) {
+ continue
+ }
+ if (isWorkspaceOldForCleanup(worktree, now)) {
+ count += 1
+ }
+ }
+ return count
+ }, [allWorktrees, repoById])
+
// Why: skip the merge entirely when the popover is closed. The merged
// tree is only ever displayed inside ; computing it on
// every store mutation (e.g. runtimePaneTitlesByTabId, which changes on
@@ -764,8 +807,8 @@ export function ResourceUsageStatusSegment({
// feel laggy because the segment is always mounted in the status bar.
const unifiedRepos = useMemo(
() =>
- open
- ? mergeSnapshotAndSessions(snapshot, sessions, {
+ open && !runtimeEnvironmentActive
+ ? mergeSnapshotAndSessions(resourceSnapshot, sessions, {
tabsByWorktree,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
@@ -776,7 +819,8 @@ export function ResourceUsageStatusSegment({
: [],
[
open,
- snapshot,
+ runtimeEnvironmentActive,
+ resourceSnapshot,
sessions,
tabsByWorktree,
ptyIdsByTabId,
@@ -792,7 +836,7 @@ export function ResourceUsageStatusSegment({
// Build the bound set with a single flat walk instead of nested Object
// iterations to keep this light on every store update.
const orphanCount = useMemo(() => {
- if (!workspaceSessionReady) {
+ if (!workspaceSessionReady || runtimeEnvironmentActive) {
return 0
}
const bound = new Set()
@@ -810,32 +854,36 @@ export function ResourceUsageStatusSegment({
}
}
return n
- }, [sessions, ptyIdsByTabId, workspaceSessionReady])
+ }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive])
const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => {
- const memory = snapshot?.totalMemory ?? 0
- const cpu = snapshot?.totalCpu ?? 0
- const hostTotal = snapshot?.host.totalMemory ?? 0
+ const memory = resourceSnapshot?.totalMemory ?? 0
+ const cpu = resourceSnapshot?.totalCpu ?? 0
+ const hostTotal = resourceSnapshot?.host.totalMemory ?? 0
return {
totalMemory: memory,
totalCpu: cpu,
hostShare: hostTotal > 0 ? (memory / hostTotal) * 100 : 0,
- memBadgeLabel: snapshot ? formatMemory(memory) : '—'
+ memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—'
}
- }, [snapshot])
+ }, [resourceSnapshot])
// Why: memorySnapshotError is null both for "last fetch succeeded" and
// "never fetched". When the segment is mounted but the popover hasn't
// been opened, fetchMemorySnapshot has never run, so a sessions IPC
// failure on the always-on poll would otherwise be silent. Treat the
// absence of any snapshot plus a sessions error as unreachable too.
- const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null)
+ const daemonUnreachable =
+ !runtimeEnvironmentActive &&
+ sessionsError &&
+ (memorySnapshotError !== null || snapshot === null)
// Why: a partial failure where the sessions IPC fails but the snapshot
// IPC still works was silently invisible after the merge — the old
// SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show
// a slim inline notice so the user understands why the session list is
// empty/stale even though the resource numbers look fine.
- const sessionsOnlyError = sessionsError && memorySnapshotError === null
+ const sessionsOnlyError =
+ !runtimeEnvironmentActive && sessionsError && memorySnapshotError === null
const toggleRepo = useCallback((repoId: string): void => {
setCollapsedRepos((prev) => {
@@ -883,17 +931,10 @@ export function ResourceUsageStatusSegment({
}
}
setActiveView('terminal')
- // Why: snapshot-derived rows carry a `${tabId}:${paneId}` paneKey from
- // the main-process pty registry — parse the paneId tail so split-tab
- // clicks land focus on the *clicked* pane rather than whichever pane
- // was last active. Daemon-only rows have paneKey=null and degrade to
- // tab-only activation.
- const colon = paneKey ? paneKey.indexOf(':') : -1
- const tail = colon > 0 && paneKey ? paneKey.slice(colon + 1) : ''
- const parsed = /^\d+$/.test(tail) ? Number.parseInt(tail, 10) : NaN
- const paneId =
- Number.isFinite(parsed) && parsed > 0 && paneKey?.slice(0, colon) === tabId ? parsed : null
- activateTabAndFocusPane(tabId, paneId)
+ // Why: paneKey suffixes are stable UUID leaf ids after replay/reload.
+ // Legacy numeric keys degrade to tab-only activation instead of guessing.
+ const parsed = paneKey ? parsePaneKey(paneKey) : null
+ activateTabAndFocusPane(tabId, parsed?.tabId === tabId ? parsed.leafId : null)
},
[tabsByWorktree, setActiveView]
)
@@ -907,6 +948,11 @@ export function ResourceUsageStatusSegment({
void runSleepWorktree(id)
}, [])
+ const handleOpenWorkspaceCleanup = useCallback((): void => {
+ setOpen(false)
+ queueMicrotask(() => openModal('workspace-cleanup'))
+ }, [openModal])
+
const handleKillSession = useCallback(
(session: UnifiedSessionRow): void => {
// Why: orphan sessions have no tab in this Orca instance, so there's
@@ -985,6 +1031,11 @@ export function ResourceUsageStatusSegment({
}
}, [killConfirm, refreshSessions])
+ const openSpaceResults = useCallback((): void => {
+ setOpen(false)
+ openSpacePage()
+ }, [openSpacePage])
+
return (
@@ -993,7 +1044,7 @@ export function ResourceUsageStatusSegment({
{!iconOnly && (
@@ -1023,7 +1074,7 @@ export function ResourceUsageStatusSegment({
- Resource usage — {memBadgeLabel} · {sessions.length} session
+ Resource Manager — {memBadgeLabel} · {sessions.length} session
{sessions.length === 1 ? '' : 's'}
@@ -1032,7 +1083,7 @@ export function ResourceUsageStatusSegment({
side="top"
align="end"
sideOffset={8}
- className="w-[26rem] p-0"
+ className="w-[26rem] max-w-[calc(100vw-2rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
// Why: clicking a terminal row activates a tab, which causes xterm
// to programmatically focus the terminal DOM node. Radix would
@@ -1041,13 +1092,10 @@ export function ResourceUsageStatusSegment({
// outside-click (onPointerDownOutside default) and Escape.
onFocusOutside={(event) => event.preventDefault()}
>
- {/* Why: header strip — title on the left, daemon-control icons on
- the right. The tab switcher was removed because a single unified
- list now covers what the two tabs used to show. */}
-
-
-
Resource Usage
+
+
+ Resource Manager
@@ -1056,7 +1104,7 @@ export function ResourceUsageStatusSegment({
daemonActions.setPending('restart')}
- disabled={daemonActions.isBusy}
+ disabled={daemonActions.isBusy || runtimeEnvironmentActive}
aria-label="Restart daemon"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
>
@@ -1064,7 +1112,7 @@ export function ResourceUsageStatusSegment({
- Restart daemon
+ {runtimeEnvironmentActive ? 'Unavailable for runtime servers' : 'Restart daemon'}
@@ -1072,7 +1120,7 @@ export function ResourceUsageStatusSegment({
daemonActions.setPending('killAll')}
- disabled={daemonActions.isBusy}
+ disabled={daemonActions.isBusy || runtimeEnvironmentActive}
aria-label="Kill all sessions"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
>
@@ -1080,7 +1128,7 @@ export function ResourceUsageStatusSegment({
- Kill all sessions
+ {runtimeEnvironmentActive ? 'Unavailable for runtime servers' : 'Kill all sessions'}
@@ -1118,7 +1166,7 @@ export function ResourceUsageStatusSegment({
)}
- {snapshot && (
+ {resourceSnapshot && (
@@ -1178,7 +1226,7 @@ export function ResourceUsageStatusSegment({
inner tree owns its own scroll. The footer renders below this
shell when orphan-bulk-kill is available. */}
- {(unifiedRepos.length > 0 || snapshot) && (
+ {(unifiedRepos.length > 0 || resourceSnapshot) && (
)}
- {unifiedRepos.length === 0 && snapshot && (
+ {unifiedRepos.length === 0 && resourceSnapshot && (
Nothing running right now
)}
- {snapshot && (
+ {resourceSnapshot && (
setAppCollapsed((v) => !v)}
/>
)}
- {!snapshot && !daemonUnreachable && (
- Loading…
+ {!resourceSnapshot && !daemonUnreachable && (
+
+ {runtimeEnvironmentActive
+ ? 'Local resource usage hidden for runtime servers.'
+ : 'Loading…'}
+
)}
- {orphanCount > 0 && (
-
+
+
+
+
+ Clean up old workspaces ({oldWorkspaceCount})
+
+
+
+ {orphanCount > 0 ? (
void handleKillOrphans()}
- className="inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
+ className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
>
Kill {orphanCount} orphan terminal{orphanCount === 1 ? '' : 's'}
-
- )}
+ ) : null}
+
+
+
{/* Why: Radix Dialog must not be a descendant of PopoverContent — when
the popover unmounts (e.g. clicking outside, focus moving to the
@@ -1340,7 +1405,7 @@ export function ResourceUsageStatusSegment({
-
+ {!runtimeEnvironmentActive && }
)
}
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
index 8796375b8fb..1d7c365f011 100644
--- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react'
-import { Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react'
+import { AlertTriangle, Cloud, Loader2, MonitorSmartphone, Server, ServerOff } from 'lucide-react'
import { toast } from 'sonner'
import {
DropdownMenu,
@@ -11,6 +11,7 @@ import {
import { useAppStore } from '../../store'
import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
+import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh'
function isConnecting(status: SshConnectionStatus): boolean {
return ['connecting', 'deploying-relay', 'reconnecting'].includes(status)
@@ -64,14 +65,54 @@ function overallLabel(status: 'connected' | 'partial' | 'disconnected' | 'connec
}
}
+function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string {
+ switch (status?.phase) {
+ case 'pulling':
+ return 'Sync pulling'
+ case 'pushing':
+ return 'Sync pushing'
+ case 'synced':
+ return status.direction === 'pull' ? 'Sync pulled' : 'Sync uploaded'
+ case 'conflict':
+ return 'Sync conflict'
+ case 'error':
+ return 'Sync error'
+ case 'offline':
+ return 'Sync unavailable'
+ default:
+ return 'Sync idle'
+ }
+}
+
+function syncStatusTone(status: RemoteWorkspaceSyncStatus | undefined): string {
+ switch (status?.phase) {
+ case 'conflict':
+ case 'error':
+ return 'text-destructive'
+ case 'offline':
+ return 'text-muted-foreground'
+ case 'pulling':
+ case 'pushing':
+ return 'text-yellow-500'
+ case 'synced':
+ return 'text-emerald-500'
+ default:
+ return 'text-muted-foreground'
+ }
+}
+
function TargetRow({
targetId,
label,
- status
+ status,
+ syncEnabled,
+ syncStatus
}: {
targetId: string
label: string
status: SshConnectionStatus
+ syncEnabled: boolean
+ syncStatus: RemoteWorkspaceSyncStatus | undefined
}): React.JSX.Element {
const [busy, setBusy] = useState(false)
@@ -102,7 +143,26 @@ function TargetRow({
{label}
-
{STATUS_LABELS[status]}
+
+
{STATUS_LABELS[status]}
+ {syncEnabled && (
+ <>
+
·
+
+ {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? (
+
+ ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? (
+
+ ) : (
+
+ )}
+ {syncStatusLabel(syncStatus)}
+
+ >
+ )}
+
{busy ? (
@@ -136,12 +196,22 @@ export function SshStatusSegment({
}): React.JSX.Element | null {
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
+ const sshTargetRemoteSyncEnabled = useAppStore((s) => s.sshTargetRemoteSyncEnabled)
+ const remoteWorkspaceSyncStatusByTargetId = useAppStore(
+ (s) => s.remoteWorkspaceSyncStatusByTargetId
+ )
const setActiveView = useAppStore((s) => s.setActiveView)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const targets = Array.from(sshTargetLabels.entries()).map(([id, label]) => {
const state = sshConnectionStates.get(id)
- return { id, label, status: (state?.status ?? 'disconnected') as SshConnectionStatus }
+ return {
+ id,
+ label,
+ status: (state?.status ?? 'disconnected') as SshConnectionStatus,
+ syncEnabled: sshTargetRemoteSyncEnabled.get(id) === true,
+ syncStatus: remoteWorkspaceSyncStatusByTargetId[id]
+ }
})
if (targets.length === 0) {
@@ -151,6 +221,14 @@ export function SshStatusSegment({
const statuses = targets.map((t) => t.status)
const overall = overallStatus(statuses)
const anyConnecting = overall === 'connecting'
+ const syncProblem = targets.find(
+ (t) => t.syncStatus?.phase === 'conflict' || t.syncStatus?.phase === 'error'
+ )
+ const syncProblemLabel = syncProblem
+ ? syncProblem.syncStatus?.phase === 'conflict'
+ ? 'Workspace conflict'
+ : 'Workspace sync error'
+ : null
return (
@@ -162,8 +240,14 @@ export function SshStatusSegment({
>
{iconOnly ? (
-
- {anyConnecting ? (
+
+ {syncProblem ? (
+
+ ) : anyConnecting ? (
) : (
@@ -171,7 +255,9 @@ export function SshStatusSegment({
) : (
- {anyConnecting ? (
+ {syncProblem ? (
+
+ ) : anyConnecting ? (
) : overall === 'connected' ? (
@@ -182,10 +268,17 @@ export function SshStatusSegment({
)}
{!compact && (
- SSH {overallLabel(overall)}
+ SSH{' '}
+
+ {syncProblemLabel ?? overallLabel(overall)}
+
)}
-
+
)}
@@ -195,7 +288,14 @@ export function SshStatusSegment({
SSH Connections
{targets.map((t) => (
-
+
))}
+
{showClaude &&
}
{showCodex &&
}
{showGemini && (
@@ -908,7 +910,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
{
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
@@ -985,7 +987,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
onCheckedChange={() => toggleStatusBarItem('resource-usage')}
>
- Resource Usage
+ Resource Manager
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx
new file mode 100644
index 00000000000..a324d94fdb4
--- /dev/null
+++ b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx
@@ -0,0 +1,123 @@
+import { useCallback } from 'react'
+import { AlertTriangle, HardDrive, Loader2, RefreshCw, X } from 'lucide-react'
+import { useAppStore } from '../../store'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import {
+ formatBytes,
+ getWorkspaceSpaceProgressLabel,
+ getWorkspaceSpaceScanTimeLabel
+} from './workspace-space-format'
+
+export function WorkspaceSpaceCompactPanel({
+ onOpenFullPage
+}: {
+ onOpenFullPage: () => void
+}): React.JSX.Element {
+ const analysis = useAppStore((state) => state.workspaceSpaceAnalysis)
+ const progress = useAppStore((state) => state.workspaceSpaceScanProgress)
+ const scanError = useAppStore((state) => state.workspaceSpaceScanError)
+ const isScanning = useAppStore((state) => state.workspaceSpaceScanning)
+ const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace)
+ const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan)
+ const progressLabel = getWorkspaceSpaceProgressLabel(progress)
+
+ const scan = useCallback((): void => {
+ void refreshWorkspaceSpace().catch(() => {
+ /* scanError is stored by the slice */
+ })
+ }, [refreshWorkspaceSpace])
+
+ const cancelScan = useCallback((): void => {
+ void cancelWorkspaceSpaceScan()
+ }, [cancelWorkspaceSpaceScan])
+
+ return (
+
+
+
+
+
+
+ Space
+
+ Beta
+
+
+
+ {analysis
+ ? isScanning
+ ? `${progressLabel ?? 'Scanning workspace sizes'} · last result kept`
+ : analysis.unavailableWorktreeCount > 0
+ ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.unavailableWorktreeCount} unavailable`
+ : `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces`
+ : isScanning
+ ? (progressLabel ?? 'Scanning workspace sizes.')
+ : 'Workspace disk usage is not scanned.'}
+
+
+
+
+
+
+ {isScanning ? (
+ progress?.state === 'cancelling' ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ {isScanning
+ ? progress?.state === 'cancelling'
+ ? 'Stopping'
+ : 'Cancel'
+ : analysis
+ ? 'Refresh'
+ : 'Scan'}
+
+
+ Review
+
+
+
+
+ {analysis ? (
+
+
+
Scanned
+
+ {formatBytes(analysis.totalSizeBytes)}
+
+
+
+
Freeable
+
+ {formatBytes(analysis.reclaimableBytes)}
+
+
+
+
Updated
+
+ {getWorkspaceSpaceScanTimeLabel(analysis.scannedAt)}
+
+
+
+ ) : null}
+
+ {scanError ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
new file mode 100644
index 00000000000..f7679049360
--- /dev/null
+++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -0,0 +1,998 @@
+/* eslint-disable max-lines -- Why: the analyzer's private treemap, selection,
+ breakdown, and table pieces share one scan state and should evolve as one resource-manager surface. */
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowUp,
+ Check,
+ Circle,
+ GitBranch,
+ HardDrive,
+ Loader2,
+ Minus,
+ RefreshCw,
+ Search,
+ Server,
+ Trash2,
+ ZoomIn,
+ ZoomOut,
+ X
+} from 'lucide-react'
+import type {
+ WorkspaceSpaceItem,
+ WorkspaceSpaceWorktree
+} from '../../../../shared/workspace-space-types'
+import { cn } from '@/lib/utils'
+import { toast } from 'sonner'
+import { useAppStore } from '../../store'
+import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger
+} from '../ui/context-menu'
+import { Input } from '../ui/input'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import {
+ formatBytes,
+ formatCompactCount,
+ getWorkspaceSpaceBranchLabel,
+ getWorkspaceSpaceProgressLabel,
+ getWorkspaceSpaceScanDateTimeLabel,
+ getWorkspaceSpaceScanTimeLabel,
+ getWorkspaceSpaceStatusLabel
+} from './workspace-space-format'
+import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout'
+import {
+ filterWorkspaceSpaceRows,
+ getSelectedDeletableWorkspaceIds,
+ sortWorkspaceSpaceRows,
+ type WorkspaceSpaceSortDirection,
+ type WorkspaceSpaceSortKey
+} from './workspace-space-presentation'
+
+const TREEMAP_FILLS = [
+ 'color-mix(in srgb, var(--chart-2) 34%, var(--card))',
+ 'color-mix(in srgb, var(--foreground) 20%, var(--card))',
+ 'color-mix(in srgb, var(--chart-4) 28%, var(--card))',
+ 'color-mix(in srgb, var(--primary) 24%, var(--card))',
+ 'color-mix(in srgb, var(--chart-1) 38%, var(--card))'
+]
+
+function getTreemapFill(rect: TreemapRect, selected: boolean): string {
+ if (selected) {
+ return 'color-mix(in srgb, var(--ring) 40%, var(--card))'
+ }
+ return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length]
+}
+
+function Metric({
+ label,
+ value,
+ title
+}: {
+ label: string
+ value: string
+ title?: string
+}): React.JSX.Element {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+function UpdatedMetric({
+ scannedAt,
+ isScanning
+}: {
+ scannedAt: number | null
+ isScanning: boolean
+}): React.JSX.Element {
+ const [now, setNow] = useState(() => Date.now())
+
+ useEffect(() => {
+ if (scannedAt === null) {
+ return
+ }
+ setNow(Date.now())
+ const timer = window.setInterval(() => setNow(Date.now()), 60_000)
+ return () => window.clearInterval(timer)
+ }, [scannedAt])
+
+ return (
+
+ )
+}
+
+function CheckButton({
+ checked,
+ disabled,
+ label,
+ onClick
+}: {
+ checked: boolean | 'mixed'
+ disabled?: boolean
+ label: string
+ onClick: () => void
+}): React.JSX.Element {
+ const isChecked = checked === true
+ const isMixed = checked === 'mixed'
+ return (
+ event.stopPropagation()}
+ onKeyDown={(event) => event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation()
+ onClick()
+ }}
+ className={cn(
+ 'flex size-6 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ disabled && 'cursor-default opacity-35'
+ )}
+ >
+
+ {isChecked ? : null}
+ {isMixed ? : null}
+
+
+ )
+}
+
+function SortIndicator({
+ sortKey,
+ activeKey,
+ direction
+}: {
+ sortKey: WorkspaceSpaceSortKey
+ activeKey: WorkspaceSpaceSortKey
+ direction: WorkspaceSpaceSortDirection
+}): React.JSX.Element {
+ if (sortKey !== activeKey) {
+ return
+ }
+ return direction === 'asc' ? :
+}
+
+function StatusBadge({ worktree }: { worktree: WorkspaceSpaceWorktree }): React.JSX.Element {
+ if (worktree.status !== 'ok') {
+ return (
+
+ {getWorkspaceSpaceStatusLabel(worktree.status)}
+
+ )
+ }
+ if (worktree.isMainWorktree) {
+ return Main
+ }
+ return Deletable
+}
+
+function WorkspaceTreemap({
+ rows,
+ isScanning,
+ selectedWorktreeId,
+ zoomedWorktree,
+ onSelect,
+ onZoomChange
+}: {
+ rows: WorkspaceSpaceWorktree[]
+ isScanning: boolean
+ selectedWorktreeId: string | null
+ zoomedWorktree: WorkspaceSpaceWorktree | null
+ onSelect: (worktreeId: string) => void
+ onZoomChange: (worktreeId: string | null) => void
+}): React.JSX.Element {
+ const selectedWorktree = rows.find((row) => row.worktreeId === selectedWorktreeId) ?? null
+ const canZoomSelected =
+ !!selectedWorktree &&
+ selectedWorktree.status === 'ok' &&
+ selectedWorktree.topLevelItems.length > 0
+ const isZoomed = !!zoomedWorktree
+ const rects = useMemo(
+ () =>
+ buildTreemapLayout(
+ zoomedWorktree
+ ? zoomedWorktree.topLevelItems
+ .filter((item) => item.sizeBytes > 0)
+ .map((item) => ({
+ id: item.path,
+ label: item.name,
+ sizeBytes: item.sizeBytes
+ }))
+ : rows
+ .filter((row) => row.status === 'ok' && row.sizeBytes > 0)
+ .map((row) => ({
+ id: row.worktreeId,
+ label: row.displayName,
+ sizeBytes: row.sizeBytes
+ }))
+ ),
+ [rows, zoomedWorktree]
+ )
+
+ if (rects.length === 0) {
+ return (
+
+ {zoomedWorktree ? (
+ onZoomChange(null)}
+ className="absolute right-2 top-2 gap-1.5 bg-background/90 px-2.5 backdrop-blur"
+ >
+
+ All
+
+ ) : null}
+
+ {isScanning ? : null}
+ {isScanning
+ ? 'Scanning workspace sizes. You can leave this page.'
+ : isZoomed
+ ? 'No top-level items to show.'
+ : 'No scanned workspace sizes yet.'}
+
+
+ )
+ }
+
+ return (
+
+
+ {zoomedWorktree ? (
+ <>
+
+ {zoomedWorktree.displayName}
+
+
onZoomChange(null)}
+ className="gap-1.5 bg-background/90 px-2.5 backdrop-blur"
+ >
+
+ All
+
+ >
+ ) : canZoomSelected ? (
+
onZoomChange(selectedWorktree.worktreeId)}
+ className="gap-1.5 bg-background/90 px-2.5 backdrop-blur"
+ >
+
+ Zoom
+
+ ) : null}
+
+ {rects.map((rect) => {
+ const area = rect.width * rect.height
+ const selected = !isZoomed && rect.id === selectedWorktreeId
+ const rectStyle = {
+ left: `${rect.x}%`,
+ top: `${rect.y}%`,
+ width: `${rect.width}%`,
+ height: `${rect.height}%`,
+ background: getTreemapFill(rect, selected)
+ }
+ const rectContent =
+ area >= 80 ? (
+
+ {rect.label}
+ {area >= 180 ? (
+
+ {formatBytes(rect.sizeBytes)}
+
+ ) : null}
+
+ ) : null
+
+ if (isZoomed) {
+ return (
+
+ {rectContent}
+
+ )
+ }
+
+ return (
+
onSelect(rect.id)}
+ className={cn(
+ 'absolute overflow-hidden border border-background/80 p-2 text-left transition-[filter,outline] hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ selected && 'ring-2 ring-ring ring-offset-1 ring-offset-background'
+ )}
+ style={rectStyle}
+ >
+ {rectContent}
+
+ )
+ })}
+
+ )
+}
+
+function SizeBar({ value, max }: { value: number; max: number }): React.JSX.Element {
+ const pct = max > 0 ? Math.max(2, Math.min(100, (value / max) * 100)) : 0
+ return (
+
+ )
+}
+
+function BreakdownList({
+ worktree,
+ isScanning
+}: {
+ worktree: WorkspaceSpaceWorktree | null
+ isScanning: boolean
+}): React.JSX.Element {
+ if (!worktree) {
+ return (
+
+
+ {isScanning ? : null}
+ {isScanning
+ ? 'Scanning workspace sizes. You can leave this page.'
+ : 'Select a workspace to inspect.'}
+
+
+ )
+ }
+
+ const maxChildSize = Math.max(...worktree.topLevelItems.map((item) => item.sizeBytes), 0)
+ const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount
+ return (
+
+
+
+
+
{worktree.displayName}
+
+ {worktree.repoDisplayName}
+
+
+
+
+ {formatBytes(worktree.sizeBytes)}
+
+
+ {formatCompactCount(topLevelItemCount)} top-level items
+
+
+
+
+
+ {worktree.status !== 'ok' ? (
+
+
+
{worktree.error ?? 'Scan failed.'}
+
+ ) : worktree.topLevelItems.length === 0 ? (
+
No files found.
+ ) : (
+
+
+ {worktree.topLevelItems.slice(0, 12).map((item) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function BreakdownRow({
+ item,
+ maxSize
+}: {
+ item: WorkspaceSpaceItem
+ maxSize: number
+}): React.JSX.Element {
+ return (
+
+
+ {item.name}
+
+ {formatBytes(item.sizeBytes)}
+
+
+
+
+ )
+}
+
+function WorkspaceRow({
+ worktree,
+ maxSize,
+ selected,
+ inspected,
+ onToggleSelected,
+ onInspect,
+ onDelete
+}: {
+ worktree: WorkspaceSpaceWorktree
+ maxSize: number
+ selected: boolean
+ inspected: boolean
+ onToggleSelected: () => void
+ onInspect: () => void
+ onDelete: () => void
+}): React.JSX.Element {
+ const canDelete = worktree.canDelete && worktree.status === 'ok'
+ const row = (
+ {
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return
+ }
+ event.preventDefault()
+ onInspect()
+ }}
+ className={cn(
+ 'grid w-full cursor-pointer grid-cols-[1.75rem_minmax(0,1.35fr)_minmax(9rem,0.65fr)_8rem_6rem] items-center gap-3 border-b border-border/45 px-3 py-2.5 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ inspected && 'bg-accent/55'
+ )}
+ >
+
+
+
+
+ {worktree.displayName}
+ {worktree.isRemote ? (
+
+ ) : null}
+ {worktree.isSparse ? Sparse : null}
+
+
+
+ {getWorkspaceSpaceBranchLabel(worktree)}
+
+
+ {worktree.path}
+
+
+
+
+
{worktree.repoDisplayName}
+
+ {worktree.repoPath}
+
+
+
+
+
+ {worktree.status === 'ok' ? formatBytes(worktree.sizeBytes) : '—'}
+
+
+
+
+
+
+
+
+ )
+
+ if (!canDelete) {
+ return row
+ }
+
+ return (
+
+ {row}
+
+
+
+ Delete workspace
+
+
+
+ )
+}
+
+export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
+ const analysis = useAppStore((state) => state.workspaceSpaceAnalysis)
+ const progress = useAppStore((state) => state.workspaceSpaceScanProgress)
+ const scanError = useAppStore((state) => state.workspaceSpaceScanError)
+ const isScanning = useAppStore((state) => state.workspaceSpaceScanning)
+ const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace)
+ const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan)
+ const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees)
+ const [query, setQuery] = useState('')
+ const [onlyDeletable, setOnlyDeletable] = useState(false)
+ const [sortKey, setSortKey] = useState('size')
+ const [sortDirection, setSortDirection] = useState('desc')
+ const [selectedIds, setSelectedIds] = useState>(() => new Set())
+ const [inspectedWorktreeId, setInspectedWorktreeId] = useState(null)
+ const [treemapZoomWorktreeId, setTreemapZoomWorktreeId] = useState(null)
+
+ const refresh = useCallback((): void => {
+ void refreshWorkspaceSpace().catch(() => {
+ /* scanError is stored by the slice */
+ })
+ }, [refreshWorkspaceSpace])
+
+ const cancelScan = useCallback((): void => {
+ void cancelWorkspaceSpaceScan()
+ }, [cancelWorkspaceSpaceScan])
+
+ const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees])
+
+ const rows = useMemo(
+ () =>
+ sortWorkspaceSpaceRows(
+ filterWorkspaceSpaceRows(sourceRows, query, onlyDeletable),
+ sortKey,
+ sortDirection
+ ),
+ [onlyDeletable, query, sortDirection, sortKey, sourceRows]
+ )
+
+ const inspectedWorktree =
+ rows.find((row) => row.worktreeId === inspectedWorktreeId) ??
+ rows.find((row) => row.status === 'ok') ??
+ null
+ const zoomedWorktree =
+ sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ??
+ null
+ const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0)
+ const selectedDeletableIds = useMemo(
+ () => getSelectedDeletableWorkspaceIds(rows, selectedIds),
+ [rows, selectedIds]
+ )
+ const selectedDeletableIdSet = useMemo(
+ () => new Set(selectedDeletableIds),
+ [selectedDeletableIds]
+ )
+ const visibleDeletableIds = useMemo(
+ () => rows.filter((row) => row.canDelete && row.status === 'ok').map((row) => row.worktreeId),
+ [rows]
+ )
+ const allVisibleSelected =
+ visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id))
+ const someVisibleSelected = visibleDeletableIds.some((id) => selectedIds.has(id))
+ const visibleSelectionState = allVisibleSelected ? true : someVisibleSelected ? 'mixed' : false
+ const isInitialScan = isScanning && !analysis
+ const hasRows = sourceRows.length > 0
+ const progressLabel = getWorkspaceSpaceProgressLabel(progress)
+ const repoErrors = analysis?.repos.filter((repo) => repo.error !== null) ?? []
+ const selectedReclaimableBytes = useMemo(
+ () =>
+ rows
+ .filter((row) => selectedDeletableIdSet.has(row.worktreeId))
+ .reduce((sum, row) => sum + row.reclaimableBytes, 0),
+ [rows, selectedDeletableIdSet]
+ )
+
+ useEffect(() => {
+ if (!analysis) {
+ setInspectedWorktreeId(null)
+ return
+ }
+ setInspectedWorktreeId((current) =>
+ current && analysis.worktrees.some((worktree) => worktree.worktreeId === current)
+ ? current
+ : (analysis.worktrees.find((worktree) => worktree.status === 'ok')?.worktreeId ?? null)
+ )
+ }, [analysis])
+
+ useEffect(() => {
+ setSelectedIds((current) => {
+ const valid = new Set(sourceRows.map((row) => row.worktreeId))
+ const next = new Set([...current].filter((id) => valid.has(id)))
+ return next.size === current.size ? current : next
+ })
+ }, [sourceRows])
+
+ useEffect(() => {
+ setTreemapZoomWorktreeId((current) =>
+ current && sourceRows.some((row) => row.worktreeId === current && row.status === 'ok')
+ ? current
+ : null
+ )
+ }, [sourceRows])
+
+ const toggleSort = (key: WorkspaceSpaceSortKey): void => {
+ if (sortKey === key) {
+ setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
+ return
+ }
+ setSortKey(key)
+ setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc')
+ }
+
+ const selectSortKey = (key: WorkspaceSpaceSortKey): void => {
+ setSortKey(key)
+ setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc')
+ }
+
+ const toggleSelection = (worktreeId: string): void => {
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ if (next.has(worktreeId)) {
+ next.delete(worktreeId)
+ } else {
+ next.add(worktreeId)
+ }
+ return next
+ })
+ }
+
+ const toggleVisibleSelection = (): void => {
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ if (allVisibleSelected) {
+ for (const id of visibleDeletableIds) {
+ next.delete(id)
+ }
+ } else {
+ for (const id of visibleDeletableIds) {
+ next.add(id)
+ }
+ }
+ return next
+ })
+ }
+
+ const deleteWorktrees = useCallback(
+ (worktreeIds: readonly string[]): void => {
+ if (worktreeIds.length === 0) {
+ return
+ }
+ runWorktreeBatchDelete(worktreeIds, {
+ forceConfirm: true,
+ onDeleted: (deletedIds) => {
+ removeWorkspaceSpaceWorktrees(deletedIds)
+ setInspectedWorktreeId((current) =>
+ current && deletedIds.includes(current) ? null : current
+ )
+ setTreemapZoomWorktreeId((current) =>
+ current && deletedIds.includes(current) ? null : current
+ )
+ setSelectedIds((current) => {
+ if (deletedIds.length === 0) {
+ return current
+ }
+ const next = new Set(current)
+ for (const id of deletedIds) {
+ next.delete(id)
+ }
+ return next
+ })
+ toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', {
+ description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.`
+ })
+ }
+ })
+ },
+ [removeWorkspaceSpaceWorktrees]
+ )
+
+ const deleteSelected = (): void => {
+ if (selectedDeletableIds.length === 0) {
+ return
+ }
+ deleteWorktrees(selectedDeletableIds)
+ }
+
+ return (
+
+
+
+
+ 0
+ ? `${analysis.scannedWorktreeCount}/${analysis.worktreeCount}`
+ : String(analysis.scannedWorktreeCount)
+ : '—'
+ }
+ />
+
+
+
+
+
+ {isScanning ? (
+
+ ) : (
+
+ )}
+
+ {analysis
+ ? isScanning
+ ? `${progressLabel ?? 'Scanning workspace sizes'}. You can leave this page; the last result stays visible.`
+ : `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.`
+ : isScanning
+ ? `${progressLabel ?? 'Scanning workspace sizes'}. You can leave this page.`
+ : 'Run a scan to inspect workspace sizes.'}
+
+
+
+ {isScanning ? (
+ progress?.state === 'cancelling' ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ {isScanning
+ ? progress?.state === 'cancelling'
+ ? 'Stopping'
+ : 'Cancel'
+ : analysis
+ ? 'Refresh'
+ : 'Scan'}
+
+
+
+ {scanError ? (
+
+
+
+ {scanError}
+ {analysis ? ' Last successful results remain visible.' : ''}
+
+
+ ) : null}
+
+ {repoErrors.length > 0 ? (
+
+ {repoErrors.map((repo) => (
+
+
+
+ {repo.displayName}: {repo.error}
+
+
+ ))}
+
+ ) : null}
+
+ {hasRows || isInitialScan ? (
+
+
+
+
+ ) : null}
+
+ {hasRows ? (
+
+
+
+ {selectedDeletableIds.length} selected
+
+ ·
+ {formatBytes(selectedReclaimableBytes)} reclaimable
+
+
+ setSelectedIds(new Set())}
+ disabled={selectedDeletableIds.length === 0}
+ className="!px-3"
+ >
+ Clear
+
+
+
+ Delete selected
+
+
+
+ ) : null}
+
+ {hasRows ? (
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Filter workspaces"
+ className="pl-9"
+ />
+
+
+
selectSortKey(value as WorkspaceSpaceSortKey)}
+ >
+
+
+
+
+ Size
+ Name
+ Repository
+ Activity
+
+
+
+
setOnlyDeletable((current) => !current)}
+ className="w-32"
+ aria-label="Show only deletable workspaces"
+ >
+ {onlyDeletable ? 'Deletable' : 'All'}
+
+
+
+
+ {allVisibleSelected ? 'Clear' : 'Select'}
+
+
+ ) : null}
+
+ {hasRows || isInitialScan ? (
+
+
+
+
+
+
+
toggleSort('name')}
+ className="flex items-center gap-1 text-left"
+ >
+ Workspace
+
+
+
toggleSort('repo')}
+ className="flex items-center gap-1 text-left"
+ >
+ Repository
+
+
+
toggleSort('size')}
+ className="flex items-center justify-end gap-1 text-right"
+ >
+ Size
+
+
+
State
+
+
+
+ {isInitialScan ? (
+
+
+ Scanning workspaces. You can leave this page.
+
+ ) : rows.length === 0 ? (
+
+ No matching workspaces.
+
+ ) : (
+ rows.map((worktree) => (
+
toggleSelection(worktree.worktreeId)}
+ onInspect={() => setInspectedWorktreeId(worktree.worktreeId)}
+ onDelete={() => deleteWorktrees([worktree.worktreeId])}
+ />
+ ))
+ )}
+
+
+
+ ) : (
+
+ {scanError
+ ? 'Scan failed before any workspace sizes were collected.'
+ : analysis
+ ? 'No workspace rows were available from the scan.'
+ : 'Run a scan to inspect workspace sizes.'}
+
+ )}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts
index 665b3b620db..bccc250fe81 100644
--- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts
+++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts
@@ -4,7 +4,7 @@
in ResourceUsageStatusSegment.tsx. Splitting would scatter logic that has
exactly one consumer. See docs/resource-usage-merge-spec.md. */
/**
- * Resource Usage popover merge helper.
+ * Resource Manager popover merge helper.
*
* Produces a single grouped list (repo → worktree → session) by unifying:
*
@@ -28,8 +28,9 @@ import type {
TerminalTab,
WorktreeMemory
} from '../../../../shared/types'
-import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
import { parsePtySessionId } from '../../../../shared/pty-session-id-format'
+import { parsePaneKey as parseStablePaneKey } from '../../../../shared/stable-pane-id'
+import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../../../shared/worktree-id'
// ─── View-model types (renderer-local) ──────────────────────────────
@@ -131,19 +132,12 @@ function shortCwd(cwd: string): string {
return parts.length > 2 ? parts.slice(-2).join(sep) : cwd
}
-function parsePaneKey(paneKey: string | null): { tabId: string; paneRuntimeId: number } | null {
+function parsePaneKey(paneKey: string | null): { tabId: string; leafId: string } | null {
if (!paneKey) {
return null
}
- const sepIdx = paneKey.indexOf(':')
- if (sepIdx <= 0) {
- return null
- }
- const paneRuntimeId = Number(paneKey.slice(sepIdx + 1))
- if (!Number.isFinite(paneRuntimeId)) {
- return null
- }
- return { tabId: paneKey.slice(0, sepIdx), paneRuntimeId }
+ const parsed = parseStablePaneKey(paneKey)
+ return parsed ? { tabId: parsed.tabId, leafId: parsed.leafId } : null
}
function resolveSnapshotSessionLabel(
@@ -161,10 +155,6 @@ function resolveSnapshotSessionLabel(
if (custom) {
return custom
}
- const runtime = ctx.runtimePaneTitlesByTabId[parsed.tabId]?.[parsed.paneRuntimeId]?.trim()
- if (runtime) {
- return runtime
- }
return tab.defaultTitle?.trim() || tab.title?.trim() || `Terminal ${tabIndex + 1}`
}
}
diff --git a/src/renderer/src/components/status-bar/workspace-space-format.test.ts b/src/renderer/src/components/status-bar/workspace-space-format.test.ts
new file mode 100644
index 00000000000..6c43b22136c
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-format.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import {
+ formatBytes,
+ formatCompactCount,
+ getWorkspaceSpaceScanTimeLabel,
+ getWorkspaceSpaceStatusLabel
+} from './workspace-space-format'
+
+describe('workspace space format helpers', () => {
+ it('formats byte values with stable units', () => {
+ expect(formatBytes(0)).toBe('0 B')
+ expect(formatBytes(512)).toBe('512 B')
+ expect(formatBytes(1536)).toBe('1.50 KB')
+ expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB')
+ })
+
+ it('formats counts and statuses for dense table UI', () => {
+ expect(formatCompactCount(1530)).toBe('1.5k')
+ expect(formatCompactCount(25_000)).toBe('25k')
+ expect(getWorkspaceSpaceStatusLabel('permission-denied')).toBe('No access')
+ })
+
+ it('formats scan times as relative age labels', () => {
+ const now = new Date('2026-05-14T22:15:00Z').getTime()
+ const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+
+ expect(getWorkspaceSpaceScanTimeLabel(now - 2 * 60_000, now)).toBe(
+ formatter.format(-2, 'minute')
+ )
+ expect(getWorkspaceSpaceScanTimeLabel(now - 3 * 60 * 60_000, now)).toBe(
+ formatter.format(-3, 'hour')
+ )
+ })
+})
diff --git a/src/renderer/src/components/status-bar/workspace-space-format.ts b/src/renderer/src/components/status-bar/workspace-space-format.ts
new file mode 100644
index 00000000000..ab056ea68e4
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-format.ts
@@ -0,0 +1,102 @@
+import type {
+ WorkspaceSpaceScanProgress,
+ WorkspaceSpaceScanStatus,
+ WorkspaceSpaceWorktree
+} from '../../../../shared/workspace-space-types'
+
+const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
+const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
+const fullDateTimeFormatter = new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short'
+})
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return '0 B'
+ }
+
+ let value = bytes
+ let unitIndex = 0
+ while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
+ value /= 1024
+ unitIndex += 1
+ }
+
+ const precision = value >= 100 || unitIndex === 0 ? 0 : value >= 10 ? 1 : 2
+ return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`
+}
+
+export function formatCompactCount(count: number): string {
+ if (!Number.isFinite(count) || count <= 0) {
+ return '0'
+ }
+ if (count < 1000) {
+ return String(count)
+ }
+ if (count < 1_000_000) {
+ return `${(count / 1000).toFixed(count >= 10_000 ? 0 : 1)}k`
+ }
+ return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}m`
+}
+
+export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string {
+ const diffMs = scannedAt - now
+ const diffMinutes = Math.round(diffMs / 60_000)
+ if (Math.abs(diffMinutes) < 60) {
+ return relativeTimeFormatter.format(diffMinutes, 'minute')
+ }
+
+ const diffHours = Math.round(diffMinutes / 60)
+ if (Math.abs(diffHours) < 24) {
+ return relativeTimeFormatter.format(diffHours, 'hour')
+ }
+
+ const diffDays = Math.round(diffHours / 24)
+ return relativeTimeFormatter.format(diffDays, 'day')
+}
+
+export function getWorkspaceSpaceScanDateTimeLabel(scannedAt: number): string {
+ return fullDateTimeFormatter.format(new Date(scannedAt))
+}
+
+export function getWorkspaceSpaceProgressLabel(
+ progress: WorkspaceSpaceScanProgress | null
+): string | null {
+ if (!progress) {
+ return null
+ }
+ if (progress.state === 'cancelling') {
+ return 'Cancelling scan'
+ }
+
+ const current =
+ progress.currentWorktreeDisplayName ?? progress.currentRepoDisplayName ?? 'workspaces'
+ if (progress.totalWorktreeCount > 0) {
+ return `Scanning ${progress.scannedWorktreeCount} of ${progress.totalWorktreeCount} · ${current}`
+ }
+ if (progress.totalRepoCount > 0) {
+ return `Scanning ${progress.scannedRepoCount} of ${progress.totalRepoCount} repos · ${current}`
+ }
+ return 'Scanning workspace sizes'
+}
+
+export function getWorkspaceSpaceStatusLabel(status: WorkspaceSpaceScanStatus): string {
+ switch (status) {
+ case 'ok':
+ return 'Scanned'
+ case 'missing':
+ return 'Missing'
+ case 'permission-denied':
+ return 'No access'
+ case 'unavailable':
+ return 'Unavailable'
+ case 'error':
+ return 'Failed'
+ }
+}
+
+export function getWorkspaceSpaceBranchLabel(worktree: WorkspaceSpaceWorktree): string {
+ const branch = worktree.branch.replace(/^refs\/heads\//, '').trim()
+ return branch || (worktree.isMainWorktree ? 'main worktree' : 'detached')
+}
diff --git a/src/renderer/src/components/status-bar/workspace-space-layout.test.ts b/src/renderer/src/components/status-bar/workspace-space-layout.test.ts
new file mode 100644
index 00000000000..d1857a17e74
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-layout.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { buildTreemapLayout } from './workspace-space-layout'
+
+describe('buildTreemapLayout', () => {
+ it('lays out positive-size items inside a 100x100 treemap', () => {
+ const rects = buildTreemapLayout([
+ { id: 'a', label: 'A', sizeBytes: 60 },
+ { id: 'b', label: 'B', sizeBytes: 30 },
+ { id: 'c', label: 'C', sizeBytes: 10 },
+ { id: 'empty', label: 'Empty', sizeBytes: 0 }
+ ])
+
+ expect(rects.map((rect) => rect.id)).toEqual(['a', 'b', 'c'])
+ for (const rect of rects) {
+ expect(rect.x).toBeGreaterThanOrEqual(0)
+ expect(rect.y).toBeGreaterThanOrEqual(0)
+ expect(rect.x + rect.width).toBeLessThanOrEqual(100)
+ expect(rect.y + rect.height).toBeLessThanOrEqual(100)
+ }
+ const area = rects.reduce((sum, rect) => sum + rect.width * rect.height, 0)
+ expect(area).toBeCloseTo(10_000, 5)
+ expect(rects[0].width * rects[0].height).toBeGreaterThan(rects[1].width * rects[1].height)
+ })
+})
diff --git a/src/renderer/src/components/status-bar/workspace-space-layout.ts b/src/renderer/src/components/status-bar/workspace-space-layout.ts
new file mode 100644
index 00000000000..18f739ad99f
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-layout.ts
@@ -0,0 +1,124 @@
+export type TreemapInput = {
+ id: string
+ label: string
+ sizeBytes: number
+}
+
+export type TreemapRect = TreemapInput & {
+ x: number
+ y: number
+ width: number
+ height: number
+ depth: number
+ index: number
+}
+
+type Bounds = {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+function sumSizes(items: readonly TreemapInput[]): number {
+ return items.reduce((sum, item) => sum + Math.max(0, item.sizeBytes), 0)
+}
+
+function splitBalanced(items: readonly TreemapInput[]): {
+ first: TreemapInput[]
+ second: TreemapInput[]
+} {
+ const total = sumSizes(items)
+ if (items.length <= 1 || total <= 0) {
+ return { first: [...items], second: [] }
+ }
+
+ const target = total / 2
+ let running = 0
+ let splitIndex = 0
+ for (let index = 0; index < items.length; index += 1) {
+ const next = running + Math.max(0, items[index].sizeBytes)
+ if (index > 0 && Math.abs(target - running) < Math.abs(target - next)) {
+ break
+ }
+ running = next
+ splitIndex = index + 1
+ }
+
+ splitIndex = Math.min(items.length - 1, Math.max(1, splitIndex))
+ return {
+ first: items.slice(0, splitIndex),
+ second: items.slice(splitIndex)
+ }
+}
+
+function layoutTreemapRecursive(
+ items: readonly TreemapInput[],
+ bounds: Bounds,
+ depth: number,
+ output: TreemapRect[]
+): void {
+ if (items.length === 0 || bounds.width <= 0 || bounds.height <= 0) {
+ return
+ }
+
+ if (items.length === 1) {
+ const item = items[0]
+ output.push({
+ ...item,
+ ...bounds,
+ depth,
+ index: output.length
+ })
+ return
+ }
+
+ const total = sumSizes(items)
+ if (total <= 0) {
+ return
+ }
+
+ const { first, second } = splitBalanced(items)
+ const firstSize = sumSizes(first)
+ const ratio = firstSize / total
+
+ if (bounds.width >= bounds.height) {
+ const firstWidth = bounds.width * ratio
+ layoutTreemapRecursive(first, { ...bounds, width: firstWidth }, depth + 1, output)
+ layoutTreemapRecursive(
+ second,
+ {
+ x: bounds.x + firstWidth,
+ y: bounds.y,
+ width: bounds.width - firstWidth,
+ height: bounds.height
+ },
+ depth + 1,
+ output
+ )
+ return
+ }
+
+ const firstHeight = bounds.height * ratio
+ layoutTreemapRecursive(first, { ...bounds, height: firstHeight }, depth + 1, output)
+ layoutTreemapRecursive(
+ second,
+ {
+ x: bounds.x,
+ y: bounds.y + firstHeight,
+ width: bounds.width,
+ height: bounds.height - firstHeight
+ },
+ depth + 1,
+ output
+ )
+}
+
+export function buildTreemapLayout(items: readonly TreemapInput[]): TreemapRect[] {
+ const filtered = items
+ .filter((item) => item.sizeBytes > 0)
+ .sort((a, b) => b.sizeBytes - a.sizeBytes || a.label.localeCompare(b.label))
+ const output: TreemapRect[] = []
+ layoutTreemapRecursive(filtered, { x: 0, y: 0, width: 100, height: 100 }, 0, output)
+ return output
+}
diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts
new file mode 100644
index 00000000000..7c386b07867
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest'
+import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
+import {
+ filterWorkspaceSpaceRows,
+ getSelectedDeletableWorkspaceIds,
+ sortWorkspaceSpaceRows
+} from './workspace-space-presentation'
+
+function row(overrides: Partial): WorkspaceSpaceWorktree {
+ return {
+ worktreeId: 'wt',
+ repoId: 'repo',
+ repoDisplayName: 'repo',
+ repoPath: '/repo',
+ displayName: 'workspace',
+ path: '/workspace',
+ branch: 'refs/heads/main',
+ isMainWorktree: false,
+ isRemote: false,
+ isSparse: false,
+ canDelete: true,
+ lastActivityAt: 0,
+ status: 'ok',
+ error: null,
+ scannedAt: 0,
+ sizeBytes: 0,
+ reclaimableBytes: 0,
+ skippedEntryCount: 0,
+ topLevelItems: [],
+ omittedTopLevelItemCount: 0,
+ omittedTopLevelSizeBytes: 0,
+ ...overrides
+ }
+}
+
+describe('workspace space presentation helpers', () => {
+ it('sorts rows by the selected key and direction', () => {
+ const rows = [
+ row({ worktreeId: 'small', displayName: 'Small', sizeBytes: 10 }),
+ row({ worktreeId: 'large', displayName: 'Large', sizeBytes: 100 }),
+ row({ worktreeId: 'mid', displayName: 'Mid', sizeBytes: 50 })
+ ]
+
+ expect(sortWorkspaceSpaceRows(rows, 'size', 'desc').map((item) => item.worktreeId)).toEqual([
+ 'large',
+ 'mid',
+ 'small'
+ ])
+ expect(sortWorkspaceSpaceRows(rows, 'name', 'asc').map((item) => item.worktreeId)).toEqual([
+ 'large',
+ 'mid',
+ 'small'
+ ])
+ })
+
+ it('filters by search text and deletable status', () => {
+ const rows = [
+ row({ worktreeId: 'a', displayName: 'Frontend Cache', repoDisplayName: 'app' }),
+ row({ worktreeId: 'b', displayName: 'Main', repoDisplayName: 'api', canDelete: false })
+ ]
+
+ expect(filterWorkspaceSpaceRows(rows, 'cache', false).map((item) => item.worktreeId)).toEqual([
+ 'a'
+ ])
+ expect(filterWorkspaceSpaceRows(rows, '', true).map((item) => item.worktreeId)).toEqual(['a'])
+ })
+
+ it('returns only selected worktrees that can be deleted', () => {
+ const rows = [
+ row({ worktreeId: 'ok', canDelete: true, status: 'ok' }),
+ row({ worktreeId: 'main', canDelete: false, status: 'ok' }),
+ row({ worktreeId: 'failed', canDelete: true, status: 'error' })
+ ]
+
+ expect(getSelectedDeletableWorkspaceIds(rows, new Set(['ok', 'main', 'failed']))).toEqual([
+ 'ok'
+ ])
+ })
+})
diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts
new file mode 100644
index 00000000000..4dd3c1d5059
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts
@@ -0,0 +1,78 @@
+import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
+
+export type WorkspaceSpaceSortKey = 'size' | 'name' | 'repo' | 'activity'
+export type WorkspaceSpaceSortDirection = 'asc' | 'desc'
+
+export function getWorkspaceSpaceSearchText(worktree: WorkspaceSpaceWorktree): string {
+ return [
+ worktree.displayName,
+ worktree.repoDisplayName,
+ worktree.path,
+ worktree.branch,
+ worktree.status
+ ]
+ .join(' ')
+ .toLowerCase()
+}
+
+function compareRows(
+ left: WorkspaceSpaceWorktree,
+ right: WorkspaceSpaceWorktree,
+ sortKey: WorkspaceSpaceSortKey
+): number {
+ switch (sortKey) {
+ case 'size':
+ return left.sizeBytes - right.sizeBytes
+ case 'name':
+ return left.displayName.localeCompare(right.displayName)
+ case 'repo':
+ return (
+ left.repoDisplayName.localeCompare(right.repoDisplayName) ||
+ left.displayName.localeCompare(right.displayName)
+ )
+ case 'activity':
+ return left.lastActivityAt - right.lastActivityAt
+ }
+}
+
+export function sortWorkspaceSpaceRows(
+ rows: readonly WorkspaceSpaceWorktree[],
+ sortKey: WorkspaceSpaceSortKey,
+ direction: WorkspaceSpaceSortDirection
+): WorkspaceSpaceWorktree[] {
+ const multiplier = direction === 'asc' ? 1 : -1
+ return [...rows].sort((left, right) => {
+ const primary = compareRows(left, right, sortKey) * multiplier
+ return (
+ primary ||
+ right.sizeBytes - left.sizeBytes ||
+ left.displayName.localeCompare(right.displayName)
+ )
+ })
+}
+
+export function filterWorkspaceSpaceRows(
+ rows: readonly WorkspaceSpaceWorktree[],
+ query: string,
+ onlyDeletable: boolean
+): WorkspaceSpaceWorktree[] {
+ const normalizedQuery = query.trim().toLowerCase()
+ return rows.filter((row) => {
+ if (onlyDeletable && !row.canDelete) {
+ return false
+ }
+ if (!normalizedQuery) {
+ return true
+ }
+ return getWorkspaceSpaceSearchText(row).includes(normalizedQuery)
+ })
+}
+
+export function getSelectedDeletableWorkspaceIds(
+ rows: readonly WorkspaceSpaceWorktree[],
+ selectedIds: ReadonlySet
+): string[] {
+ return rows
+ .filter((row) => row.canDelete && row.status === 'ok' && selectedIds.has(row.worktreeId))
+ .map((row) => row.worktreeId)
+}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx
index a33cc025318..1e24243f01b 100644
--- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx
+++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx
@@ -22,7 +22,7 @@ import { basename, normalizeRelativePath } from '@/lib/path'
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import { renameFileOnDisk } from '@/lib/rename-file'
import { detectLanguage } from '@/lib/language-detect'
-import { useWorktreeById } from '@/store/selectors'
+import { useRepoById, useWorktreeById } from '@/store/selectors'
import { useAppStore } from '@/store'
import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
import type { GitFileStatus } from '../../../../shared/types'
@@ -35,6 +35,8 @@ import {
type DropIndicator
} from './drop-indicator'
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
+import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
+import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@@ -74,6 +76,7 @@ export default function EditorFileTab({
dropIndicator?: DropIndicator
}): React.JSX.Element {
const worktree = useWorktreeById(file.worktreeId)
+ const repo = useRepoById(worktree?.repoId ?? null)
// Why: no transform/transition/isDragging styling — the drag design is
// that tabs stay visually anchored; only the blue insertion bar moves.
const { attributes, listeners, setNodeRef } = useSortable({
@@ -379,6 +382,7 @@ export default function EditorFileTab({
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
+ runtimeEnvironmentId: file.runtimeEnvironmentId,
language: resolvedLanguage
})
}}
@@ -407,6 +411,16 @@ export default function EditorFileTab({
{
+ if (
+ shouldBlockEditorTabLocalOpen(
+ useAppStore.getState().settings,
+ file.runtimeEnvironmentId,
+ repo?.connectionId ?? null
+ )
+ ) {
+ showLocalPathOpenBlockedToast()
+ return
+ }
window.api.shell.openPath(file.filePath)
}}
>
diff --git a/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx b/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx
deleted file mode 100644
index 415bf6d85ad..00000000000
--- a/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useEffect, useState } from 'react'
-import { useSortable } from '@dnd-kit/sortable'
-import { Columns2, FileText, Rows2, X } from 'lucide-react'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
-import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
-import type { TabDragItemData } from '../tab-group/useTabDragSplit'
-import {
- ACTIVE_TAB_INDICATOR_CLASSES,
- getDropIndicatorClasses,
- type DropIndicator
-} from './drop-indicator'
-
-export type ProjectNotesTabState = {
- id: string
- label: string
- isDirty: boolean
-}
-
-export function ProjectNotesTab({
- tab,
- isActive,
- hasTabsToRight,
- onActivate,
- onClose,
- onCloseToRight,
- onSplitGroup,
- dragData,
- dropIndicator
-}: {
- tab: ProjectNotesTabState
- isActive: boolean
- hasTabsToRight: boolean
- onActivate: () => void
- onClose: () => void
- onCloseToRight: () => void
- onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
- dragData: TabDragItemData
- dropIndicator?: DropIndicator
-}): React.JSX.Element {
- const { attributes, listeners, setNodeRef } = useSortable({
- id: tab.id,
- data: dragData
- })
- const [menuOpen, setMenuOpen] = useState(false)
- const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
-
- useEffect(() => {
- const closeMenu = (): void => setMenuOpen(false)
- window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
- return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
- }, [])
-
- return (
- <>
- {
- event.preventDefault()
- window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
- setMenuPoint({ x: event.clientX, y: event.clientY })
- setMenuOpen(true)
- }}
- className={`group relative flex h-full min-w-[120px] max-w-[220px] items-center gap-1.5 border-l border-border px-2 text-xs ${
- hasTabsToRight ? 'border-r border-r-border/70' : ''
- } ${isActive ? 'bg-background text-foreground' : 'bg-card text-muted-foreground hover:bg-muted/50 hover:text-foreground'}`}
- role="tab"
- aria-selected={isActive}
- >
- {dropIndicator ?
: null}
-
-
{tab.label}
-
- {tab.isDirty ? (
-
- ) : null}
- {
- event.stopPropagation()
- onClose()
- }}
- aria-label="Close Project Notes"
- >
-
-
-
- {isActive ?
: null}
-
-
-
-
-
-
- onSplitGroup('right', tab.id)}>
-
- Split Right
-
- onSplitGroup('down', tab.id)}>
-
- Split Down
-
-
- Close Tabs to Right
-
- Close
-
-
-
- >
- )
-}
diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts
index e76a2c69ecc..1170506a349 100644
--- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts
+++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts
@@ -26,7 +26,6 @@ vi.mock('react', async () => {
memo: (component: T) => component,
useEffect: () => {},
useLayoutEffect: () => {},
- useCallback: (callback: T) => callback,
useMemo: (factory: () => T) => factory(),
useRef: (current: T) => ({ current }),
useState: (initial: T) => [initial, vi.fn()] as const
@@ -37,9 +36,6 @@ vi.mock('lucide-react', () => ({
FilePlus: function FilePlus() {
return null
},
- FileText: function FileText() {
- return null
- },
Globe: function Globe() {
return null
},
@@ -127,15 +123,6 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},
- DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
- return { type: 'DropdownMenuSub', props }
- },
- DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
- return { type: 'DropdownMenuSubContent', props }
- },
- DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
- return { type: 'DropdownMenuSubTrigger', props }
- },
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
return { type: 'DropdownMenuTrigger', props }
}
diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx
index 7f15947cfc6..7d969a810b1 100644
--- a/src/renderer/src/components/tab-bar/TabBar.tsx
+++ b/src/renderer/src/components/tab-bar/TabBar.tsx
@@ -5,7 +5,7 @@
* more clarity than the ~5 lines of bloat is worth. */
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SortableContext } from '@dnd-kit/sortable'
-import { FilePlus, Globe, Plus, TerminalSquare, FileText } from 'lucide-react'
+import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react'
import type {
BrowserTab as BrowserTabState,
TerminalTab,
@@ -17,7 +17,6 @@ import type { OpenFile } from '../../store/slices/editor'
import SortableTab from './SortableTab'
import EditorFileTab from './EditorFileTab'
import BrowserTab, { getBrowserTabLabel } from './BrowserTab'
-import { ProjectNotesTab, type ProjectNotesTabState } from './ProjectNotesTab'
import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
import type { DropIndicator } from './drop-indicator'
import { reconcileTabOrder } from './reconcile-order'
@@ -33,12 +32,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
-import type { NoteSummary } from '../../../../shared/notes-types'
const isMac = navigator.userAgent.includes('Mac')
const isWindows = navigator.userAgent.includes('Windows')
@@ -63,8 +58,6 @@ type TabBarProps = {
terminalOnly?: boolean
showAgentLaunchItems?: boolean
onNewFileTab?: () => void
- onNewNotesTab?: (noteId?: string) => void
- notesWorktreeId?: string | null
/** Whether WSL is installed on this Windows machine. When true, the "+"
* dropdown shows a WSL option under the terminal submenu. */
wslAvailable?: boolean
@@ -73,17 +66,13 @@ type TabBarProps = {
onTogglePaneExpand: (tabId: string) => void
editorFiles?: (OpenFile & { tabId?: string })[]
browserTabs?: (BrowserTabState & { tabId?: string })[]
- notesTabs?: ProjectNotesTabState[]
activeFileId?: string | null
activeBrowserTabId?: string | null
- activeNotesTabId?: string | null
activeTabType?: WorkspaceVisibleTabType
onActivateFile?: (fileId: string) => void
onCloseFile?: (fileId: string) => void
onActivateBrowserTab?: (tabId: string) => void
onCloseBrowserTab?: (tabId: string) => void
- onActivateNotesTab?: (tabId: string) => void
- onCloseNotesTab?: (tabId: string) => void
onDuplicateBrowserTab?: (tabId: string) => void
onCloseAllFiles?: () => void
onPinFile?: (fileId: string, tabId?: string) => void
@@ -109,7 +98,6 @@ type TabItem =
unifiedTabId: string
data: BrowserTabState & { tabId?: string }
}
- | { type: 'notes'; id: string; unifiedTabId: string; data: ProjectNotesTabState }
function getTabDragLabel(item: TabItem): string {
if (item.type === 'terminal') {
@@ -118,9 +106,6 @@ function getTabDragLabel(item: TabItem): string {
if (item.type === 'browser') {
return getBrowserTabLabel(item.data)
}
- if (item.type === 'notes') {
- return item.data.label
- }
return getEditorDisplayLabel(item.data)
}
@@ -140,24 +125,18 @@ function TabBarInner({
terminalOnly = false,
showAgentLaunchItems = true,
onNewFileTab,
- onNewNotesTab,
- notesWorktreeId,
onSetCustomTitle,
onSetTabColor,
onTogglePaneExpand,
editorFiles,
browserTabs,
- notesTabs,
activeFileId,
activeBrowserTabId,
- activeNotesTabId,
activeTabType,
onActivateFile,
onCloseFile,
onActivateBrowserTab,
onCloseBrowserTab,
- onActivateNotesTab,
- onCloseNotesTab,
onDuplicateBrowserTab,
onCloseAllFiles,
onPinFile,
@@ -174,10 +153,6 @@ function TabBarInner({
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
)
const [pwshAvailable, setPwshAvailable] = useState(false)
- const [projectNotes, setProjectNotes] = useState([])
- const [projectNotesLoading, setProjectNotesLoading] = useState(false)
- const [projectNotesError, setProjectNotesError] = useState(null)
- const [projectNotesMenuOpen, setProjectNotesMenuOpen] = useState(false)
useEffect(() => {
if (!isWindows) {
setPwshAvailable(false)
@@ -187,7 +162,6 @@ function TabBarInner({
void window.api.pwsh.isAvailable().then(setPwshAvailable)
}, [])
const resolvedGroupId = groupId ?? worktreeId
- const targetNotesWorktreeId = notesWorktreeId ?? worktreeId
const statusByRelativePath = useMemo(
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
@@ -209,49 +183,6 @@ function TabBarInner({
return () => window.removeEventListener('blur', dismiss)
}, [newTabMenuOpen])
- useEffect(() => {
- if (!newTabMenuOpen) {
- setProjectNotesMenuOpen(false)
- return
- }
- const refreshProjectNotes = async (): Promise => {
- if (!onNewNotesTab) {
- return
- }
- let context: { projectId: string; worktreeId: string } | null = null
- if (targetNotesWorktreeId) {
- const state = useAppStore.getState()
- const worktree = Object.values(state.worktreesByRepo)
- .flat()
- .find((candidate) => candidate.id === targetNotesWorktreeId)
- if (worktree) {
- const repo = state.repos.find((candidate) => candidate.id === worktree.repoId)
- context = { projectId: repo?.id ?? worktree.repoId, worktreeId: targetNotesWorktreeId }
- }
- }
- if (!context) {
- setProjectNotes([])
- setProjectNotesError(null)
- return
- }
- setProjectNotesLoading(true)
- setProjectNotesError(null)
- try {
- const result = await window.api.notes.list({
- projectId: context.projectId,
- worktreeId: context.worktreeId,
- limit: 100
- })
- setProjectNotes(result.notes)
- } catch (err) {
- setProjectNotesError(err instanceof Error ? err.message : String(err))
- } finally {
- setProjectNotesLoading(false)
- }
- }
- void refreshProjectNotes()
- }, [newTabMenuOpen, onNewNotesTab, targetNotesWorktreeId])
-
const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs])
const editorMap = useMemo(
() => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])),
@@ -261,22 +192,14 @@ function TabBarInner({
() => new Map((browserTabs ?? []).map((t) => [t.id, t])),
[browserTabs]
)
- const notesMap = useMemo(() => new Map((notesTabs ?? []).map((t) => [t.id, t])), [notesTabs])
const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs])
const editorFileIds = useMemo(() => editorFiles?.map((f) => f.tabId ?? f.id) ?? [], [editorFiles])
const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs])
- const notesTabIds = useMemo(() => notesTabs?.map((tab) => tab.id) ?? [], [notesTabs])
// Build the unified ordered list, reconciling stored order with current items
const orderedItems = useMemo(() => {
- const ids = reconcileTabOrder(
- tabBarOrder,
- terminalIds,
- editorFileIds,
- browserTabIds,
- notesTabIds
- )
+ const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds, browserTabIds)
const items: TabItem[] = []
for (const id of ids) {
const terminal = terminalMap.get(id)
@@ -304,23 +227,9 @@ function TabBarInner({
})
continue
}
- const notesTab = notesMap.get(id)
- if (notesTab) {
- items.push({ type: 'notes', id, unifiedTabId: notesTab.id, data: notesTab })
- }
}
return items
- }, [
- tabBarOrder,
- terminalIds,
- editorFileIds,
- browserTabIds,
- notesTabIds,
- terminalMap,
- editorMap,
- browserMap,
- notesMap
- ])
+ }, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
@@ -519,24 +428,6 @@ function TabBarInner({
/>
)
}
- if (item.type === 'notes') {
- return (
- onActivateNotesTab?.(item.id)}
- onClose={() => onCloseNotesTab?.(item.id)}
- onCloseToRight={() => onCloseToRight(item.id)}
- onSplitGroup={(direction, sourceVisibleTabId) =>
- onCreateSplitGroup?.(direction, sourceVisibleTabId)
- }
- dragData={dragData}
- dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
- />
- )
- }
return (
{NEW_FILE_SHORTCUT}
)}
- {onNewNotesTab && (
-
- setProjectNotesMenuOpen(true)}
- onFocus={() => setProjectNotesMenuOpen(true)}
- onClick={() => {
- // Why: this row looks like the other creation commands in
- // the + menu. Keep hover-to-pick-saved-note, but make a
- // direct click create a fresh note instead of only opening
- // the submenu.
- onNewNotesTab()
- setProjectNotesMenuOpen(false)
- setNewTabMenuOpen(false)
- }}
- onKeyDown={(event) => {
- if (event.key !== 'Enter' && event.key !== ' ') {
- return
- }
- event.preventDefault()
- onNewNotesTab()
- setProjectNotesMenuOpen(false)
- setNewTabMenuOpen(false)
- }}
- >
-
- Project Notes
-
-
- onNewNotesTab()}
- className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
- >
-
- New Project Note
-
-
- {projectNotesLoading ? (
-
- Loading notes...
-
- ) : projectNotesError ? (
-
- Failed to load notes
-
- ) : projectNotes.length === 0 ? (
-
- No saved notes
-
- ) : (
-
- {projectNotes.map((note) => (
- onNewNotesTab(note.id)}
- className="items-start gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5"
- >
-
-
- {note.title}
-
- {note.preview || note.relativePath}
-
-
-
- ))}
-
- )}
-
-
- )}
{showAgentLaunchItems ? (
<>
diff --git a/src/renderer/src/components/tab-bar/TabDragPreview.tsx b/src/renderer/src/components/tab-bar/TabDragPreview.tsx
index 314dad9859b..ad0e51591f1 100644
--- a/src/renderer/src/components/tab-bar/TabDragPreview.tsx
+++ b/src/renderer/src/components/tab-bar/TabDragPreview.tsx
@@ -1,4 +1,4 @@
-import { FileCode, Globe, Terminal as TerminalIcon, FileText } from 'lucide-react'
+import { FileCode, Globe, Terminal as TerminalIcon } from 'lucide-react'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
// Why: rendered inside dnd-kit's DragOverlay (a document-level portal), so
@@ -9,13 +9,7 @@ import type { TabDragItemData } from '../tab-group/useTabDragSplit'
// the wrapper's top-left.
export default function TabDragPreview({ drag }: { drag: TabDragItemData }): React.JSX.Element {
const Icon =
- drag.tabType === 'browser'
- ? Globe
- : drag.tabType === 'editor'
- ? FileCode
- : drag.tabType === 'notes'
- ? FileText
- : TerminalIcon
+ drag.tabType === 'browser' ? Globe : drag.tabType === 'editor' ? FileCode : TerminalIcon
return (
diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts
new file mode 100644
index 00000000000..21b850b21fa
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
+
+describe('shouldBlockEditorTabLocalOpen', () => {
+ it('blocks remote-owned editor tabs even after the active runtime switches local', () => {
+ expect(shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, 'env-1', null)).toBe(
+ true
+ )
+ })
+
+ it('blocks SSH editor tabs without a runtime owner', () => {
+ expect(
+ shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, 'ssh-1')
+ ).toBe(true)
+ })
+
+ it('allows local editor tabs without runtime or SSH ownership', () => {
+ expect(
+ shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, null)
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts
new file mode 100644
index 00000000000..37302887f6d
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts
@@ -0,0 +1,13 @@
+import type { GlobalSettings } from '../../../../shared/types'
+import { isLocalPathOpenBlocked } from '@/lib/local-path-open-guard'
+import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
+
+export function shouldBlockEditorTabLocalOpen(
+ settings: Pick
| null | undefined,
+ fileRuntimeEnvironmentId: string | null | undefined,
+ connectionId: string | null | undefined
+): boolean {
+ return isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, fileRuntimeEnvironmentId), {
+ connectionId
+ })
+}
diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts
index 983e51d085c..5ca2dfa81a8 100644
--- a/src/renderer/src/components/tab-bar/group-tab-order.ts
+++ b/src/renderer/src/components/tab-bar/group-tab-order.ts
@@ -3,11 +3,17 @@ import type { AppState } from '../../store/types'
import { reconcileTabOrder } from './reconcile-order'
export type VisibleTabRef = {
- type: 'terminal' | 'editor' | 'browser' | 'notes'
+ type: 'terminal' | 'editor' | 'browser'
id: string
tabId?: string
}
+export type ActiveTabNavOrderIds = {
+ terminalIds?: string[]
+ editorIds?: string[]
+ browserIds?: string[]
+}
+
/**
* Compute the visible tab-strip order for a single group.
*
@@ -56,7 +62,6 @@ export function getGroupVisibleTabOrder(
const seenTerminals = new Set()
const seenBrowsers = new Set()
const seenEditors = new Set()
- const seenNotes = new Set()
for (const unifiedId of group.tabOrder) {
const tab = tabsById.get(unifiedId)
if (!tab) {
@@ -74,12 +79,6 @@ export function getGroupVisibleTabOrder(
}
seenBrowsers.add(tab.entityId)
result.push({ type: 'browser', id: tab.entityId, tabId: tab.id })
- } else if (tab.contentType === 'notes') {
- if (seenNotes.has(tab.id)) {
- continue
- }
- seenNotes.add(tab.id)
- result.push({ type: 'notes', id: tab.id, tabId: tab.id })
} else {
if (!editorEntityIds.has(tab.entityId) || seenEditors.has(tab.id)) {
continue
@@ -116,14 +115,14 @@ export function getActiveTabNavOrder(
| 'openFiles'
| 'browserTabsByWorktree'
>,
- worktreeId: string
+ worktreeId: string,
+ ids: ActiveTabNavOrderIds = {}
): VisibleTabRef[] {
- const terminalIds = (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
- const editorIds = state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
- const browserIds = (state.browserTabsByWorktree[worktreeId] ?? []).map((t) => t.id)
- const notesIds = (state.unifiedTabsByWorktree[worktreeId] ?? [])
- .filter((tab) => tab.contentType === 'notes')
- .map((tab) => tab.id)
+ const terminalIds = ids.terminalIds ?? (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id)
+ const editorIds =
+ ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
+ const browserIds =
+ ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id)
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
const group = activeGroupId
@@ -148,13 +147,11 @@ export function getActiveTabNavOrder(
state.tabBarOrderByWorktree[worktreeId],
terminalIds,
editorIds,
- browserIds,
- notesIds
+ browserIds
)
const terminalIdSet = new Set(terminalIds)
const editorIdSet = new Set(editorIds)
const browserIdSet = new Set(browserIds)
- const notesIdSet = new Set(notesIds)
const result: VisibleTabRef[] = []
for (const id of visibleIds) {
if (terminalIdSet.has(id)) {
@@ -163,8 +160,6 @@ export function getActiveTabNavOrder(
result.push({ type: 'editor', id })
} else if (browserIdSet.has(id)) {
result.push({ type: 'browser', id })
- } else if (notesIdSet.has(id)) {
- result.push({ type: 'notes', id, tabId: id })
}
}
return result
diff --git a/src/renderer/src/components/tab-bar/reconcile-order.ts b/src/renderer/src/components/tab-bar/reconcile-order.ts
index af2b4629ae3..6fce831d986 100644
--- a/src/renderer/src/components/tab-bar/reconcile-order.ts
+++ b/src/renderer/src/components/tab-bar/reconcile-order.ts
@@ -7,10 +7,9 @@ export function reconcileTabOrder(
storedOrder: string[] | undefined,
terminalIds: string[],
editorIds: string[],
- browserIds: string[] = [],
- notesIds: string[] = []
+ browserIds: string[] = []
): string[] {
- const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...notesIds])
+ const validIds = new Set([...terminalIds, ...editorIds, ...browserIds])
// Why: storedOrder is persisted group tab order and is mutated by many
// codepaths (drop/move/reorder/hydrate). A stale or racey write can leave
// the same tab id twice in the list, which surfaces as React's "two
@@ -25,7 +24,7 @@ export function reconcileTabOrder(
inResult.add(id)
}
}
- for (const id of [...terminalIds, ...editorIds, ...browserIds, ...notesIds]) {
+ for (const id of [...terminalIds, ...editorIds, ...browserIds]) {
if (!inResult.has(id)) {
result.push(id)
inResult.add(id)
diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
index ec92e9748a2..bd86341ad33 100644
--- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx
+++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx
@@ -1,4 +1,3 @@
-/* eslint-disable max-lines -- Why: this pane shell coordinates terminals, editor tabs, browser slots, and notes tabs so split-group routing stays in one place. */
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
@@ -20,13 +19,8 @@ import {
type TabDropZone
} from './useTabDragSplit'
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
-import {
- getProjectNoteIdFromEntityId,
- isNewProjectNoteEntityId
-} from '@/lib/open-project-notes-tab'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
-const ProjectNotesTabContent = lazy(() => import('../notes/ProjectNotesTabContent'))
export default function TabGroupPanel({
groupId,
@@ -62,8 +56,7 @@ export default function TabGroupPanel({
}, [])
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
- const { activeTab, browserItems, commands, editorItems, notesItems, tabBarOrder, terminalTabs } =
- model
+ const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model
const { setNodeRef: setBodyDropRef } = useDroppable({
id: getTabPaneBodyDroppableId(groupId),
data: {
@@ -126,30 +119,23 @@ export default function TabGroupPanel({
wslAvailable={wslAvailable}
onNewBrowserTab={commands.newBrowserTab}
onNewFileTab={commands.newFileTab}
- onNewNotesTab={commands.newNotesTab}
onSetCustomTitle={commands.setTabCustomTitle}
onSetTabColor={commands.setTabColor}
onTogglePaneExpand={() => {}}
editorFiles={editorItems}
browserTabs={browserItems}
- notesTabs={notesItems}
activeFileId={
- activeTab?.contentType === 'terminal' ||
- activeTab?.contentType === 'browser' ||
- activeTab?.contentType === 'notes'
+ activeTab?.contentType === 'terminal' || activeTab?.contentType === 'browser'
? null
: activeTab?.id
}
activeBrowserTabId={activeTab?.contentType === 'browser' ? activeTab.entityId : null}
- activeNotesTabId={activeTab?.contentType === 'notes' ? activeTab.id : null}
activeTabType={
activeTab?.contentType === 'terminal'
? 'terminal'
: activeTab?.contentType === 'browser'
? 'browser'
- : activeTab?.contentType === 'notes'
- ? 'notes'
- : 'editor'
+ : 'editor'
}
onActivateFile={commands.activateEditor}
onCloseFile={commands.closeItem}
@@ -162,15 +148,6 @@ export default function TabGroupPanel({
commands.closeItem(item.id)
}
}}
- onActivateNotesTab={commands.activateNotes}
- onCloseNotesTab={(notesTabId) => {
- const item = model.groupTabs.find(
- (candidate) => candidate.id === notesTabId && candidate.contentType === 'notes'
- )
- if (item) {
- commands.closeItem(item.id)
- }
- }}
onDuplicateBrowserTab={commands.duplicateBrowserTab}
onCloseAllFiles={commands.closeAllEditorTabsInGroup}
onPinFile={(_fileId, tabId) => {
@@ -361,36 +338,9 @@ export default function TabGroupPanel({
style={bodyAnchorStyle}
>
{activeDropZone ? : null}
- {model.groupTabs
- .filter((tab) => tab.contentType === 'notes')
- .map((notesTab) => (
-
-
- Loading notes...
-
- }
- >
-
-
-
- ))}
-
{activeTab &&
activeTab.contentType !== 'terminal' &&
- activeTab.contentType !== 'browser' &&
- activeTab.contentType !== 'notes' && (
+ activeTab.contentType !== 'browser' && (
{/* Why: split groups render editor/browser content inside a
plain relative pane body instead of the legacy flex column in
diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts
index 838e9deac94..22c7f5d4d99 100644
--- a/src/renderer/src/components/tab-group/useTabDragSplit.ts
+++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts
@@ -34,7 +34,7 @@ export type TabDragItemData = {
groupId: string
unifiedTabId: string
visibleTabId: string
- tabType: 'terminal' | 'editor' | 'browser' | 'notes'
+ tabType: 'terminal' | 'editor' | 'browser'
/** Rendered by the DragOverlay ghost that follows the cursor across
* groups. Source tab strips use overflow-hidden, so without the overlay
* the dragged tab would be invisible once the cursor leaves its own
diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
index dabf845257f..6059902b579 100644
--- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
+++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
@@ -14,12 +14,9 @@ import { extractIpcErrorMessage } from '../../lib/ipc-error'
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
import { requestEditorFileClose } from '../editor/editor-autosave'
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
-import { getProjectNotesEntityId } from '../../lib/open-project-notes-tab'
-import { requestProjectNotesTabClose } from '../../lib/project-notes-close-request'
export type GroupEditorItem = OpenFile & { tabId: string }
export type GroupBrowserItem = BrowserTabState & { tabId: string }
-export type GroupNotesItem = { id: string; label: string; entityId: string; isDirty: boolean }
const EMPTY_GROUPS: readonly TabGroup[] = []
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
@@ -143,19 +140,6 @@ export function useTabGroupWorkspaceModel({
[groupTabs, worktreeState.browserTabs]
)
- const notesItems = useMemo
(
- () =>
- groupTabs
- .filter((item) => item.contentType === 'notes')
- .map((item) => ({
- id: item.id,
- label: item.label,
- entityId: item.entityId,
- isDirty: item.isDirty === true
- })),
- [groupTabs]
- )
-
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
@@ -209,14 +193,6 @@ export function useTabGroupWorkspaceModel({
} else if (item.contentType === 'browser') {
destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
- } else if (item.contentType === 'notes') {
- requestProjectNotesTabClose(item.id, () => {
- closeUnifiedTab(item.id)
- if (!opts?.skipEmptyCheck) {
- leaveWorktreeIfEmpty()
- }
- })
- return
} else {
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
if (!canCloseTab) {
@@ -250,10 +226,6 @@ export function useTabGroupWorkspaceModel({
} else if (item.contentType === 'browser') {
destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
- } else if (item.contentType === 'notes') {
- requestProjectNotesTabClose(item.id, () => {
- closeUnifiedTab(item.id)
- })
} else {
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
if (canCloseTab) {
@@ -311,21 +283,6 @@ export function useTabGroupWorkspaceModel({
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
)
- const activateNotes = useCallback(
- (tabId: string) => {
- const item = groupTabs.find(
- (candidate) => candidate.id === tabId && candidate.contentType === 'notes'
- )
- if (!item) {
- return
- }
- focusGroup(worktreeId, groupId)
- activateTab(item.id)
- setActiveTabType('notes')
- },
- [activateTab, focusGroup, groupId, groupTabs, setActiveTabType, worktreeId]
- )
-
const createSplitGroup = useCallback(
(direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => {
const sourceTab =
@@ -464,7 +421,6 @@ export function useTabGroupWorkspaceModel({
activeTab,
browserItems,
editorItems,
- notesItems,
terminalTabs,
tabBarOrder,
groupTabs,
@@ -475,7 +431,6 @@ export function useTabGroupWorkspaceModel({
},
activateBrowser,
activateEditor,
- activateNotes,
activateTerminal,
closeAllEditorTabsInGroup,
closeGroup,
@@ -484,7 +439,8 @@ export function useTabGroupWorkspaceModel({
closeToRight,
createSplitGroup,
newBrowserTab: () => {
- const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
+ const state = useAppStore.getState()
+ const defaultUrl = state.browserDefaultUrl ?? 'about:blank'
createBrowserTab(worktreeId, defaultUrl, {
title: 'New Browser Tab',
focusAddressBar: true
@@ -513,36 +469,18 @@ export function useTabGroupWorkspaceModel({
}
try {
const connectionId = getConnectionId(worktreeId) ?? undefined
- const fileInfo = await createUntitledMarkdownFile(path, worktreeId, connectionId)
+ const settings = useAppStore.getState().settings
+ const fileInfo = await createUntitledMarkdownFile(
+ path,
+ worktreeId,
+ connectionId,
+ settings
+ )
openFile(fileInfo, { preview: false, targetGroupId: groupId })
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
}
},
- newNotesTab: async (noteId?: string) => {
- const projectId = worktree?.repoId ?? worktreeId
- let label = 'Project Notes'
- if (noteId) {
- try {
- const result = await window.api.notes.show({ projectId, worktreeId, note: noteId })
- label = result.note.title
- await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' })
- } catch {
- label = 'Project Notes'
- }
- }
- const tab = useAppStore.getState().createUnifiedTab(worktreeId, 'notes', {
- targetGroupId: groupId,
- label,
- // Why: each Project Notes tab is an editor surface, not the note
- // identity itself. Give it a unique entity id so users can keep
- // more than one notes tab open in the same pane.
- entityId: getProjectNotesEntityId(projectId, noteId)
- })
- focusGroup(worktreeId, groupId)
- activateTab(tab.id)
- setActiveTabType('notes')
- },
newTerminalTab: () => {
const terminal = createTab(worktreeId, groupId)
setActiveTab(terminal.id)
diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts
index 70789977467..977c974b7a6 100644
--- a/src/renderer/src/components/task-page-cache-selectors.ts
+++ b/src/renderer/src/components/task-page-cache-selectors.ts
@@ -70,22 +70,23 @@ export function findTaskPageDialogWorkItem(
return null
}
-export function findTaskPageLinearDrawerIssue(
+export function findTaskPageLinearIssue(
linearIssueCache: LinearIssueCache,
linearSearchCache: LinearSearchCache,
- drawerLinearIssueId: string | null
+ linearIssueId: string | null
): LinearIssue | null {
- if (!drawerLinearIssueId) {
+ if (!linearIssueId) {
return null
}
- const cachedIssue = linearIssueCache[drawerLinearIssueId]?.data
- if (cachedIssue) {
- return cachedIssue
+ for (const entry of Object.values(linearIssueCache)) {
+ if (entry?.data?.id === linearIssueId) {
+ return entry.data
+ }
}
for (const entry of Object.values(linearSearchCache)) {
- const found = entry?.data?.find((issue) => issue.id === drawerLinearIssueId)
+ const found = entry?.data?.find((issue) => issue.id === linearIssueId)
if (found) {
return found
}
@@ -93,3 +94,5 @@ export function findTaskPageLinearDrawerIssue(
return null
}
+
+export const findTaskPageLinearDrawerIssue = findTaskPageLinearIssue
diff --git a/src/renderer/src/components/task-page-linear-team-selection.test.ts b/src/renderer/src/components/task-page-linear-team-selection.test.ts
new file mode 100644
index 00000000000..a3d1a207b12
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-team-selection.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import type { LinearTeam } from '../../../shared/types'
+import { reconcileLinearTeamSelection } from './task-page-linear-team-selection'
+
+function team(id: string): LinearTeam {
+ return {
+ id,
+ name: id,
+ key: id.toUpperCase()
+ }
+}
+
+describe('reconcileLinearTeamSelection', () => {
+ it('selects every available team when the saved selection is sticky-all', () => {
+ expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], null))).toEqual([
+ 'a',
+ 'b'
+ ])
+ })
+
+ it('preserves saved teams that still exist', () => {
+ expect(Array.from(reconcileLinearTeamSelection([team('a'), team('b')], ['b']))).toEqual(['b'])
+ })
+
+ it('drops stale saved teams after switching workspaces', () => {
+ expect(Array.from(reconcileLinearTeamSelection([team('c'), team('d')], ['a', 'd']))).toEqual([
+ 'd'
+ ])
+ })
+
+ it('falls back to all current teams when every saved team is stale', () => {
+ expect(Array.from(reconcileLinearTeamSelection([team('c'), team('d')], ['a', 'b']))).toEqual([
+ 'c',
+ 'd'
+ ])
+ })
+})
diff --git a/src/renderer/src/components/task-page-linear-team-selection.ts b/src/renderer/src/components/task-page-linear-team-selection.ts
new file mode 100644
index 00000000000..f6d7e52fa1f
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-team-selection.ts
@@ -0,0 +1,19 @@
+import type { LinearTeam } from '../../../shared/types'
+
+export function reconcileLinearTeamSelection(
+ availableTeams: LinearTeam[],
+ storedSelection: readonly string[] | null | undefined
+): ReadonlySet {
+ const availableIds = availableTeams.map((team) => team.id)
+ if (availableIds.length === 0) {
+ return new Set()
+ }
+
+ const availableIdSet = new Set(availableIds)
+ const validStoredSelection = (storedSelection ?? []).filter((id) => availableIdSet.has(id))
+ if (validStoredSelection.length > 0) {
+ return new Set(validStoredSelection)
+ }
+
+ return new Set(availableIds)
+}
diff --git a/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx
new file mode 100644
index 00000000000..6b8c5e807e6
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx
@@ -0,0 +1,210 @@
+import { useEffect, useId, useRef, useState, type ReactElement } from 'react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import type { DriverState } from '@/lib/pane-manager/mobile-driver-state'
+
+type Props = {
+ driver: DriverState
+ hasFitOverride: boolean
+ onAction: () => void | Promise
+ /** Identifier class on the rendered root, used by e2e selectors. */
+ rootClassName?: string
+}
+
+// Why: see docs/mobile-presence-lock.md. Driving state preserves output streaming
+// so the chip mode lets users keep watching; held-fit state has no live output to
+// preserve, so it stays loud until Restore.
+export function MobileDriverOverlay({
+ driver,
+ hasFitOverride,
+ onAction,
+ rootClassName
+}: Props): ReactElement | null {
+ const isMobileDriving = driver.kind === 'mobile'
+ const isHeldAtPhoneFit = !isMobileDriving && hasFitOverride
+ const driverClientId = driver.kind === 'mobile' ? driver.clientId : null
+
+ const [collapsed, setCollapsed] = useState(false)
+ const [actionPending, setActionPending] = useState(false)
+ const mountedRef = useRef(true)
+
+ useEffect(
+ () => () => {
+ mountedRef.current = false
+ },
+ []
+ )
+
+ // Re-expand on driver flip so a new mobile actor is loud, not silent.
+ useEffect(() => {
+ if (!isMobileDriving) {
+ return
+ }
+ setCollapsed(false)
+ }, [isMobileDriving, driverClientId])
+
+ if (!isMobileDriving && !isHeldAtPhoneFit) {
+ return null
+ }
+
+ const handleAction = async (): Promise => {
+ if (actionPending) {
+ return
+ }
+ setActionPending(true)
+ try {
+ await onAction()
+ } finally {
+ if (mountedRef.current) {
+ setActionPending(false)
+ }
+ }
+ }
+
+ if (isHeldAtPhoneFit) {
+ return (
+
+ )
+ }
+
+ if (collapsed) {
+ return (
+ setCollapsed(false)}
+ rootClassName={rootClassName}
+ />
+ )
+ }
+
+ return (
+ setCollapsed(true)}
+ tone="driving"
+ rootClassName={rootClassName}
+ />
+ )
+}
+
+type LoudOverlayProps = {
+ eyebrow: string
+ title: string
+ body: string
+ actionLabel: string
+ actionPending: boolean
+ onAction: () => void | Promise
+ onCollapse?: () => void
+ tone: 'driving' | 'held'
+ rootClassName?: string
+}
+
+function LoudOverlay({
+ eyebrow,
+ title,
+ body,
+ actionLabel,
+ actionPending,
+ onAction,
+ onCollapse,
+ tone,
+ rootClassName
+}: LoudOverlayProps): ReactElement {
+ const titleId = useId()
+ const bodyId = useId()
+ return (
+
+
+
+ ●
+ {eyebrow}
+
+
+ {title}
+
+
+ {body}
+
+
+ {onCollapse && (
+
+ Collapse
+
+ )}
+ {/* autoFocus lands keyboard users on the recovery action when the pane-local lock appears. */}
+
+ {actionLabel}
+
+
+
+
+ )
+}
+
+type ChipProps = {
+ actionPending: boolean
+ onAction: () => void | Promise
+ onExpand: () => void
+ rootClassName?: string
+}
+
+function LockChip({ actionPending, onAction, onExpand, rootClassName }: ChipProps): ReactElement {
+ return (
+
+
+
+ Mobile driving
+
+
+ Take back
+
+
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
index f1946791c53..f4fce7e402a 100644
--- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx
@@ -5,6 +5,7 @@ import {
Maximize2,
Minimize2,
PanelBottomClose,
+ PanelsTopLeft,
PanelRightClose,
Pencil,
SquareTerminal,
@@ -36,6 +37,8 @@ type TerminalContextMenuProps = {
onPaste: () => void
onSplitRight: () => void
onSplitDown: () => void
+ canEqualizePaneSizes: boolean
+ onEqualizePaneSizes: () => void
onClosePane: () => void
onClearScreen: () => void
quickCommands: TerminalQuickCommand[]
@@ -56,6 +59,8 @@ export default function TerminalContextMenu({
onPaste,
onSplitRight,
onSplitDown,
+ canEqualizePaneSizes,
+ onEqualizePaneSizes,
onClosePane,
onClearScreen,
quickCommands,
@@ -154,6 +159,12 @@ export default function TerminalContextMenu({
Ctrl+Shift+D is taken by split-right (#586). */}
{isMac ? `${mod}${shift}D` : `Alt+${shift}D`}
+ {canEqualizePaneSizes && (
+
+
+ Equalize Pane Sizes
+
+ )}
{canExpandPane && (
{menuPaneIsExpanded ? : }
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 3a3469b471a..da162b18ccc 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -14,7 +14,9 @@ import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers'
import { getConnectionId } from '@/lib/connection-context'
-import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization'
+import { resolveTerminalDropTargetShell } from './terminal-drop-handler'
+import { EMPTY_LAYOUT, serializeTerminalLayout } from './layout-serialization'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
import {
applyExpandedLayoutTo,
createExpandCollapseActions,
@@ -25,6 +27,7 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy'
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
import { useTerminalFontZoom } from './useTerminalFontZoom'
import CloseTerminalDialog from './CloseTerminalDialog'
+import { MobileDriverOverlay } from './MobileDriverOverlay'
import { TerminalErrorToast } from './TerminalErrorToast'
import TerminalContextMenu from './TerminalContextMenu'
import { useSystemPrefersDark } from './use-system-prefers-dark'
@@ -34,14 +37,17 @@ import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
import { useNotificationDispatch } from './use-notification-dispatch'
import { connectPanePty } from './pty-connection'
import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers'
-import {
- getFitOverrideForPty,
- getPaneIdsForPty,
- onOverrideChange
-} from '@/lib/pane-manager/mobile-fit-overrides'
+import { getFitOverrideForPty, onOverrideChange } from '@/lib/pane-manager/mobile-fit-overrides'
import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state'
+import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
+import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import {
+ getRemoteRuntimePtyEnvironmentId,
+ getRemoteRuntimeTerminalHandle
+} from '@/runtime/runtime-terminal-stream'
// Why: registry lives in a leaf module so the store slice can import it
// without re-entering the `slice → TerminalPane → store → slice` cycle
@@ -60,7 +66,7 @@ type TerminalPaneProps = {
// override (separate snapshot ref) — does NOT touch expandedPaneId state
// or persist to the layout snapshot, so returning to the workspace shows
// the original split layout unchanged.
- isolatedPaneId?: number | null
+ isolatedPaneKey?: string | null
onPtyExit: (ptyId: string) => void
onCloseTab: () => void
}
@@ -71,7 +77,7 @@ export default function TerminalPane({
cwd,
isActive,
isVisible = true,
- isolatedPaneId = null,
+ isolatedPaneKey = null,
onPtyExit,
onCloseTab
}: TerminalPaneProps): React.JSX.Element {
@@ -137,11 +143,16 @@ export default function TerminalPane({
onOverrideChange((event) => {
setOverrideTick((n) => n + 1)
if (event.mode === 'desktop-fit') {
- const paneIds = getPaneIdsForPty(event.ptyId)
const manager = managerRef.current
if (!manager) {
return
}
+ // Why: pane IDs are per-tab, so resolve the affected PTY through this
+ // tab's live transport bindings instead of global numeric pane IDs.
+ const getAffectedPanes = (): ReturnType =>
+ manager
+ .getPanes()
+ .filter((pane) => paneTransportsRef.current.get(pane.id)?.getPtyId() === event.ptyId)
// Why: fitAddon.fit() measures DOM dimensions, so it must run after
// the browser has settled layout. Running synchronously inside the
// IPC callback can produce stale measurements. rAF ensures the DOM
@@ -151,11 +162,8 @@ export default function TerminalPane({
// dimensions from the runtime. This guarantees xterm exits mobile
// dims even when the DOM-based fit path fails.
const fitAffectedPanes = (): void => {
- for (const paneId of paneIds) {
- const pane = manager.getPanes().find((p) => p.id === paneId)
- if (pane) {
- safeFit(pane)
- }
+ for (const pane of getAffectedPanes()) {
+ safeFit(pane)
}
}
requestAnimationFrame(fitAffectedPanes)
@@ -172,11 +180,7 @@ export default function TerminalPane({
// mobile-fit started, not the current pane geometry — applying
// it would shrink the terminal back to e.g. half-width.
setTimeout(() => {
- for (const paneId of paneIds) {
- const pane = manager.getPanes().find((p) => p.id === paneId)
- if (!pane) {
- continue
- }
+ for (const pane of getAffectedPanes()) {
// Why: skip the fallback for hidden/unmounted panes whose
// container is 0×0. Force-resizing xterm to the server's
// desktop dims while the DOM has no geometry leaves xterm
@@ -317,10 +321,16 @@ export default function TerminalPane({
return
}
const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null
- const layout = serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current)
+ const leafIdByPaneId = manager.getLeafIdMap()
+ const layout = serializeTerminalLayout(
+ container,
+ activePaneId,
+ expandedPaneIdRef.current,
+ leafIdByPaneId
+ )
const existing = useAppStore.getState().terminalLayoutsByTabId[tabId]
const currentPanes = manager.getPanes()
- const currentLeafIds = new Set(currentPanes.map((p) => paneLeafId(p.id)))
+ const currentLeafIds = new Set(currentPanes.map((p) => p.leafId))
// Preserve existing buffersByLeafId so layout-only persists (resize, split,
// reorder) don't clobber previously captured scrollback. Drop entries for
// leaves that no longer exist.
@@ -339,10 +349,11 @@ export default function TerminalPane({
// successive remount (tab moved again before the first rAF) would lose
// the mappings and force fresh PTY spawns.
const livePtyEntries = currentPanes
- .map(
- (p) => [paneLeafId(p.id), paneTransportsRef.current.get(p.id)?.getPtyId() ?? null] as const
+ .map((p) => [p.leafId, paneTransportsRef.current.get(p.id)?.getPtyId() ?? null] as const)
+ .filter(
+ (entry): entry is readonly [(typeof currentPanes)[number]['leafId'], string] =>
+ entry[1] !== null
)
- .filter((entry): entry is readonly [string, string] => entry[1] !== null)
const mergedPtyIds = mergeCapturedLeafState({
prior: existing?.ptyIdsByLeafId,
fresh: Object.fromEntries(livePtyEntries),
@@ -357,7 +368,7 @@ export default function TerminalPane({
const titles = paneTitlesRef.current
const titleEntries = currentPanes
.filter((p) => titles[p.id])
- .map((p) => [paneLeafId(p.id), titles[p.id]] as const)
+ .map((p) => [p.leafId, titles[p.id]] as const)
if (titleEntries.length > 0) {
layout.titlesByLeafId = Object.fromEntries(titleEntries)
}
@@ -370,7 +381,10 @@ export default function TerminalPane({
const { ptyIdsByLeafId: _existingPtyIdsByLeafId, ...layoutWithoutPtyBindings } =
existingLayout
const existingBindings = existingLayout.ptyIdsByLeafId ?? {}
- const leafId = paneLeafId(paneId)
+ const leafId = managerRef.current?.getLeafId(paneId)
+ if (!leafId) {
+ return
+ }
if (ptyId) {
setTabLayout(tabId, {
@@ -427,13 +441,12 @@ export default function TerminalPane({
// so the sidebar doesn't show a stale countdown for a pane that no
// longer exists. The closeTab path handles bulk cleanup, but closing
// a single split pane doesn't go through closeTab.
- useAppStore.getState().setCacheTimerStartedAt(`${tabId}:${paneId}`, null)
+ const leafId = manager.getLeafId(paneId)
+ if (leafId) {
+ useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
+ useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId))
+ }
syncPanePtyLayoutBinding(paneId, null)
- // Why: Cmd+W on a split pane is user-initiated teardown — drop (not
- // remove) so any retained `done` snapshot for this pane is also cleared
- // and a same-frame live→gone transition cannot re-snapshot it via the
- // retention sync.
- useAppStore.getState().dropAgentStatus(`${tabId}:${paneId}`)
manager.closePane(paneId)
}
},
@@ -452,10 +465,10 @@ export default function TerminalPane({
executeClosePane(paneId)
return
}
- void window.api.pty
- .hasChildProcesses(ptyId)
- .then((hasChildren) => {
- if (hasChildren) {
+ const settings = useAppStore.getState().settings
+ void inspectRuntimeTerminalProcess(settings, ptyId)
+ .then((process) => {
+ if (process.hasChildProcesses) {
setCloseConfirmPaneId(paneId)
} else {
executeClosePane(paneId)
@@ -557,20 +570,33 @@ export default function TerminalPane({
safeFit(pane)
}
})
- if (isolatedPaneId === null) {
+ if (isolatedPaneKey === null) {
restoreExpandedLayoutFrom(snapshots)
const frame = scheduleRefit()
return () => {
cancelAnimationFrame(frame)
}
}
- const applied = applyExpandedLayoutTo(isolatedPaneId, {
- managerRef,
- containerRef,
- expandedStyleSnapshotRef: activityIsolationSnapshotRef
- })
+ const manager = managerRef.current
+ const resolution = resolvePaneKeyForManager(tabId, isolatedPaneKey, manager)
+ const resolvedPaneId = resolution.status === 'resolved' ? resolution.numericPaneId : null
+ const applied =
+ resolvedPaneId !== null &&
+ ((manager?.getPanes().length ?? 0) <= 1 ||
+ applyExpandedLayoutTo(resolvedPaneId, {
+ managerRef,
+ containerRef,
+ expandedStyleSnapshotRef: activityIsolationSnapshotRef
+ }))
if (!applied) {
restoreExpandedLayoutFrom(snapshots)
+ const root = containerRef.current?.firstElementChild
+ if (root instanceof HTMLElement) {
+ // Why: Activity requested an exact pane. If it cannot be resolved, fail
+ // closed instead of showing the whole split terminal as a fallback.
+ snapshots.set(root, { display: root.style.display, flex: root.style.flex })
+ root.style.display = 'none'
+ }
const frame = scheduleRefit()
return () => {
cancelAnimationFrame(frame)
@@ -580,7 +606,7 @@ export default function TerminalPane({
return () => {
cancelAnimationFrame(frame)
}
- }, [isolatedPaneId, paneCount])
+ }, [isolatedPaneKey, paneCount, tabId])
// Why: belt-and-suspenders unmount cleanup. If the component unmounts
// while isolation is active (e.g. tab closed mid-Activity-view), restore
@@ -619,7 +645,7 @@ export default function TerminalPane({
syncPanePtyLayoutBinding(paneId, null)
transport?.destroy?.()
paneTransportsRef.current.delete(paneId)
- setCacheTimerStartedAt(`${tabId}:${paneId}`, null)
+ setCacheTimerStartedAt(makePaneKey(tabId, pane.leafId), null)
setTerminalError(null)
const newPaneBinding = connectPanePty(pane, manager, {
@@ -1081,20 +1107,21 @@ export default function TerminalPane({
if (!transport) {
return
}
- // Why: the explorer passes the worktree-absolute path via a DOM
- // MIME, so for SSH worktrees this is a remote POSIX path destined
- // for the remote shell. Quote for the target shell (remote = posix)
- // rather than the client OS; otherwise a Windows client dropping
- // onto an SSH-Linux worktree would emit Windows-style quoting.
- // Why: `typeof === 'string'` (not `!== null`) so an unhydrated
- // store (`undefined`) is treated as local and falls through to
- // client-OS quoting, rather than being misclassified as remote.
- const isRemote = typeof getConnectionId(worktreeId) === 'string'
- const targetShell: 'posix' | 'windows' = isRemote
- ? 'posix'
- : isWindowsUserAgent()
- ? 'windows'
- : 'posix'
+ const state = useAppStore.getState()
+ const worktreePath =
+ Object.values(state.worktreesByRepo ?? {})
+ .flat()
+ .find((worktree) => worktree.id === worktreeId)?.path ??
+ cwd ??
+ filePath
+ const targetShell = resolveTerminalDropTargetShell({
+ activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId,
+ worktreePath,
+ // Why: internal Explorer drags paste a worktree-absolute path
+ // directly into the target shell. Runtime drops use the runtime
+ // worktree's path shape; legacy SSH drops remain POSIX.
+ connectionId: getConnectionId(worktreeId)
+ })
transport.sendInput(shellEscapePath(filePath, targetShell))
// Move focus to the terminal so the user can keep typing where the
// dropped path just landed. Without this, focus stays on the file
@@ -1123,6 +1150,7 @@ export default function TerminalPane({
menuOpenedAtRef={contextMenu.menuOpenedAtRef}
canClosePane={contextMenu.paneCount > 1}
canExpandPane={contextMenu.paneCount > 1}
+ canEqualizePaneSizes={contextMenu.paneCount > 1 && expandedPaneId === null}
menuPaneIsExpanded={
contextMenu.menuPaneId !== null && contextMenu.menuPaneId === expandedPaneId
}
@@ -1130,6 +1158,7 @@ export default function TerminalPane({
onPaste={() => void contextMenu.onPaste()}
onSplitRight={contextMenu.onSplitRight}
onSplitDown={contextMenu.onSplitDown}
+ onEqualizePaneSizes={contextMenu.onEqualizePaneSizes}
onClosePane={contextMenu.onClosePane}
onClearScreen={contextMenu.onClearScreen}
quickCommands={(settings?.terminalQuickCommands ?? []).filter(
@@ -1203,66 +1232,57 @@ export default function TerminalPane({
if (!ptyId) {
return null
}
- // Why: two-state banner. (1) Driver is mobile → presence-lock,
- // input paused (docs/mobile-presence-lock.md). (2) No mobile
- // driver but a phone-fit override is still in place → indefinite
- // hold (docs/mobile-fit-hold.md): the user left mobile, the PTY
- // is held at phone dims, and the banner is the explicit
- // return-to-desktop-size escape hatch.
+ // Why: two-state lock UI. (1) Driver is mobile → presence-lock,
+ // input paused (docs/mobile-presence-lock.md). (2) No mobile driver
+ // but a phone-fit override is still in place → indefinite hold
+ // (docs/mobile-fit-hold.md). MobileDriverOverlay owns the visual
+ // treatment and collapse-to-chip state; both branches share a
+ // single IPC route through restoreTerminalFit.
const driver = getDriverForPty(ptyId)
const isMobileDriving = driver.kind === 'mobile'
- const isHeldAtPhoneFit = !isMobileDriving && getFitOverrideForPty(ptyId) !== null
- if (!isMobileDriving && !isHeldAtPhoneFit) {
+ const hasFitOverride = getFitOverrideForPty(ptyId) !== null
+ if (!isMobileDriving && !hasFitOverride) {
return null
}
return createPortal(
- {
+ // Why: same restore intent has two transports. Remote-runtime PTYs
+ // must call the environment RPC; local PTYs use the Electron IPC
+ // handler. Both resolve active-mobile and held-no-subscriber states.
+ const transport = paneTransportsRef.current.get(pane.id)
+ const id = transport?.getPtyId()
+ if (!id) {
+ return
+ }
+ const remoteHandle = getRemoteRuntimeTerminalHandle(id)
+ const environmentId =
+ getRemoteRuntimePtyEnvironmentId(id) ??
+ settingsRef.current?.activeRuntimeEnvironmentId ??
+ null
+ const result =
+ remoteHandle && environmentId
+ ? await callRuntimeRpc<{ restored: boolean }>(
+ { kind: 'environment', environmentId },
+ 'terminal.restoreFit',
+ { terminal: remoteHandle },
+ { timeoutMs: 15_000 }
+ ).catch(() => ({ restored: false }))
+ : await window.api.runtime
+ .restoreTerminalFit(id)
+ .catch(() => ({ restored: false }))
+ if (result.restored) {
+ // Why: after the overlay unmounts, focus would otherwise stay on
+ // the removed button/body instead of the terminal the user just
+ // reclaimed.
+ pane.terminal.focus()
+ }
}}
- >
-
- {isMobileDriving
- ? 'Mobile is driving this terminal — your input is paused. Click Take back to resume.'
- : 'This terminal is held at phone size for the mobile app. Click Restore to return it to desktop size.'}
-
- {
- const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId()
- if (ptyId) {
- // Why: same IPC route — handler resolves both the
- // active-mobile-subscriber path and the held-no-subscriber
- // path (docs/mobile-fit-hold.md), so the banner unmounts
- // and the PTY returns to desktop dims in either case.
- void window.api.runtime.restoreTerminalFit(ptyId)
- }
- }}
- >
- {isMobileDriving ? 'Take back' : 'Restore'}
-
-
,
+ />,
pane.container,
`mobile-driver-banner-${pane.id}`
)
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
index b734b8b7e18..cf615132616 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
@@ -91,7 +91,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
// TerminalPane mounted here preserves alt-screen TUI state while this
// flag still lets hidden tabs throttle rendering.
isVisible={isVisible || activityTerminalPortal !== null}
- isolatedPaneId={activityTerminalPortal?.paneId ?? null}
+ isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
onPtyExit={(ptyId) => {
if (consumeSuppressedPtyExit(ptyId)) {
return
@@ -205,11 +205,10 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId)
- const activityTerminalPortal = findActivityTerminalPortal(
- activityTerminalPortals,
+ const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
worktreeId,
- terminalTab.id
- )
+ tabId: terminalTab.id
+ })
return (
| null
+ acknowledgeAgents: (paneKeys: string[]) => void
+ surfaceStaleAgentRow: (tabId: string, leafId: string) => void
+}
+
+export function handleFocusTerminalPaneDetail(
+ detail: FocusTerminalPaneDetail | undefined,
+ { tabId, manager, acknowledgeAgents, surfaceStaleAgentRow }: FocusTerminalPaneEventDeps
+): void {
+ if (!detail?.tabId || detail.tabId !== tabId) {
+ return
+ }
+ if (!manager || !detail.leafId) {
+ return
+ }
+ const resolution = resolveLeafIdForManager(
+ tabId,
+ detail.leafId,
+ manager,
+ detail.ackPaneKeyOnSuccess ?? null
+ )
+ if (resolution.status !== 'resolved') {
+ // Why: stale pane keys must fail closed instead of focusing a sibling pane.
+ if (resolution.leafId) {
+ surfaceStaleAgentRow(tabId, resolution.leafId)
+ }
+ return
+ }
+ manager.setActivePane(resolution.numericPaneId, { focus: true })
+ if (detail.ackPaneKeyOnSuccess) {
+ acknowledgeAgents([detail.ackPaneKeyOnSuccess])
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts
index f4fe088ad73..d533f81e04a 100644
--- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts
+++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts
@@ -1,3 +1,5 @@
+/* oxlint-disable max-lines -- Why: this test keeps split layout replay fixtures together so
+ * stable leaf-id migration regressions are visible in one focused suite. */
import { describe, expect, it, beforeAll } from 'vitest'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
@@ -33,7 +35,6 @@ beforeAll(() => {
})
import {
- paneLeafId,
buildFontFamily,
serializePaneTree,
serializeTerminalLayout,
@@ -55,22 +56,10 @@ function mockElement(opts: {
return new MockHTMLElement(opts) as unknown as HTMLElement
}
-// ---------------------------------------------------------------------------
-// paneLeafId
-// ---------------------------------------------------------------------------
-describe('paneLeafId', () => {
- it('returns "pane:0" for paneId 0', () => {
- expect(paneLeafId(0)).toBe('pane:0')
- })
-
- it('returns "pane:1" for paneId 1', () => {
- expect(paneLeafId(1)).toBe('pane:1')
- })
-
- it('returns "pane:42" for paneId 42', () => {
- expect(paneLeafId(42)).toBe('pane:42')
- })
-})
+const LEAF_1 = '11111111-1111-4111-8111-111111111111'
+const LEAF_2 = '22222222-2222-4222-8222-222222222222'
+const LEAF_3 = '33333333-3333-4333-8333-333333333333'
+const LEAF_4 = '44444444-4444-4444-8444-444444444444'
// ---------------------------------------------------------------------------
// buildFontFamily
@@ -132,36 +121,53 @@ describe('serializePaneTree', () => {
})
it('returns a leaf node for a single pane', () => {
- const pane = mockElement({ classList: ['pane'], dataset: { paneId: '1' } })
- expect(serializePaneTree(pane)).toEqual({ type: 'leaf', leafId: 'pane:1' })
+ const pane = mockElement({ classList: ['pane'], dataset: { paneId: '1', leafId: LEAF_1 } })
+ expect(serializePaneTree(pane)).toEqual({ type: 'leaf', leafId: LEAF_1 })
})
- it('returns null for a pane with non-numeric paneId', () => {
+ it('returns null for a pane without a UUID leaf id', () => {
const pane = mockElement({ classList: ['pane'], dataset: { paneId: 'abc' } })
expect(serializePaneTree(pane)).toBeNull()
})
+ it('returns null for a pane with a legacy leaf id', () => {
+ const pane = mockElement({ classList: ['pane'], dataset: { paneId: '1', leafId: 'pane:1' } })
+ expect(serializePaneTree(pane)).toBeNull()
+ })
+
it('returns null for element that is neither pane nor pane-split', () => {
const el = mockElement({ classList: ['random-class'] })
expect(serializePaneTree(el)).toBeNull()
})
it('returns a vertical split node with two pane children', () => {
- const first = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '1' } })
- const second = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '2' } })
+ const first = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '1', leafId: LEAF_1 }
+ })
+ const second = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '2', leafId: LEAF_2 }
+ })
const split = mockElement({ classList: ['pane-split'], children: [first, second] })
expect(serializePaneTree(split)).toEqual({
type: 'split',
direction: 'vertical',
- first: { type: 'leaf', leafId: 'pane:1' },
- second: { type: 'leaf', leafId: 'pane:2' }
+ first: { type: 'leaf', leafId: LEAF_1 },
+ second: { type: 'leaf', leafId: LEAF_2 }
})
})
it('returns horizontal direction when split has is-horizontal class', () => {
- const first = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '3' } })
- const second = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '4' } })
+ const first = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '3', leafId: LEAF_3 }
+ })
+ const second = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '4', leafId: LEAF_4 }
+ })
const split = mockElement({
classList: ['pane-split', 'is-horizontal'],
children: [first, second]
@@ -170,20 +176,20 @@ describe('serializePaneTree', () => {
expect(serializePaneTree(split)).toEqual({
type: 'split',
direction: 'horizontal',
- first: { type: 'leaf', leafId: 'pane:3' },
- second: { type: 'leaf', leafId: 'pane:4' }
+ first: { type: 'leaf', leafId: LEAF_3 },
+ second: { type: 'leaf', leafId: LEAF_4 }
})
})
it('captures flex ratio when children have unequal flex', () => {
const first = new MockHTMLElement({
classList: ['pane'],
- dataset: { paneId: '1' },
+ dataset: { paneId: '1', leafId: LEAF_1 },
style: { flex: '3' }
})
const second = new MockHTMLElement({
classList: ['pane'],
- dataset: { paneId: '2' },
+ dataset: { paneId: '2', leafId: LEAF_2 },
style: { flex: '1' }
})
const split = mockElement({ classList: ['pane-split'], children: [first, second] })
@@ -192,8 +198,8 @@ describe('serializePaneTree', () => {
expect(result).toEqual({
type: 'split',
direction: 'vertical',
- first: { type: 'leaf', leafId: 'pane:1' },
- second: { type: 'leaf', leafId: 'pane:2' },
+ first: { type: 'leaf', leafId: LEAF_1 },
+ second: { type: 'leaf', leafId: LEAF_2 },
ratio: 0.75
})
})
@@ -201,12 +207,12 @@ describe('serializePaneTree', () => {
it('omits ratio when flex values are equal (both 1)', () => {
const first = new MockHTMLElement({
classList: ['pane'],
- dataset: { paneId: '1' },
+ dataset: { paneId: '1', leafId: LEAF_1 },
style: { flex: '1' }
})
const second = new MockHTMLElement({
classList: ['pane'],
- dataset: { paneId: '2' },
+ dataset: { paneId: '2', leafId: LEAF_2 },
style: { flex: '1' }
})
const split = mockElement({ classList: ['pane-split'], children: [first, second] })
@@ -216,9 +222,18 @@ describe('serializePaneTree', () => {
})
it('handles nested splits recursively', () => {
- const leaf1 = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '1' } })
- const leaf2 = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '2' } })
- const leaf3 = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: '3' } })
+ const leaf1 = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '1', leafId: LEAF_1 }
+ })
+ const leaf2 = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '2', leafId: LEAF_2 }
+ })
+ const leaf3 = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '3', leafId: LEAF_3 }
+ })
const innerSplit = new MockHTMLElement({
classList: ['pane-split', 'is-horizontal'],
@@ -232,12 +247,12 @@ describe('serializePaneTree', () => {
expect(serializePaneTree(outerSplit)).toEqual({
type: 'split',
direction: 'vertical',
- first: { type: 'leaf', leafId: 'pane:1' },
+ first: { type: 'leaf', leafId: LEAF_1 },
second: {
type: 'split',
direction: 'horizontal',
- first: { type: 'leaf', leafId: 'pane:2' },
- second: { type: 'leaf', leafId: 'pane:3' }
+ first: { type: 'leaf', leafId: LEAF_2 },
+ second: { type: 'leaf', leafId: LEAF_3 }
}
})
})
@@ -257,7 +272,51 @@ describe('serializeTerminalLayout', () => {
const result = serializeTerminalLayout(root, 5, null)
expect(result).toEqual({
root: null,
- activeLeafId: 'pane:5',
+ activeLeafId: null,
+ expandedLeafId: null
+ })
+ })
+
+ it('uses UUID leaf ids from the live pane map for active and expanded panes', () => {
+ const child = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '5', leafId: LEAF_1 }
+ })
+ const root = mockElement({ firstElementChild: child }) as unknown as HTMLDivElement
+ const result = serializeTerminalLayout(
+ root,
+ 5,
+ 6,
+ new Map([
+ [5, LEAF_1],
+ [6, LEAF_2]
+ ])
+ )
+ expect(result).toEqual({
+ root: { type: 'leaf', leafId: LEAF_1 },
+ activeLeafId: LEAF_1,
+ expandedLeafId: LEAF_2
+ })
+ })
+
+ it('does not serialize legacy active pane ids when the live map is missing UUIDs', () => {
+ const child = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: '5', leafId: LEAF_1 }
+ })
+ const root = mockElement({ firstElementChild: child }) as unknown as HTMLDivElement
+ const result = serializeTerminalLayout(
+ root,
+ 5,
+ 6,
+ new Map([
+ [5, 'pane:5'],
+ [6, 'pane:6']
+ ])
+ )
+ expect(result).toEqual({
+ root: { type: 'leaf', leafId: LEAF_1 },
+ activeLeafId: null,
expandedLeafId: null
})
})
diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts
index 6127da76526..7441256fa24 100644
--- a/src/renderer/src/components/terminal-pane/layout-serialization.ts
+++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts
@@ -3,8 +3,16 @@ import type {
TerminalPaneLayoutNode,
TerminalPaneSplitDirection
} from '../../../../shared/types'
+import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
+import { getLeftmostLeafId, normalizeTerminalLayoutSnapshot } from './terminal-layout-leaf-ids'
+
+export {
+ collectLeafIdsInOrder,
+ collectLeafIdsInReplayCreationOrder,
+ normalizeTerminalLayoutSnapshot
+} from './terminal-layout-leaf-ids'
export const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
root: null,
@@ -49,54 +57,6 @@ export const POST_REPLAY_MODE_RESET =
// as unbound key input).
export const POST_REPLAY_FOCUS_REPORTING_RESET = '\x1b[?25h\x1b[?1004l'
-export function paneLeafId(paneId: number): string {
- return `pane:${paneId}`
-}
-
-export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] {
- if (!node) {
- return []
- }
- if (node.type === 'leaf') {
- return [node.leafId]
- }
- return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)]
-}
-
-function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
- return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
-}
-
-function collectReplayCreatedPaneLeafIds(
- node: Extract,
- leafIdsInReplayCreationOrder: string[]
-): void {
- // Why: replayTerminalLayout() creates one new pane per split and assigns it
- // to the split's second subtree before recursing, so the new pane maps to
- // the leftmost leaf reachable within that second subtree.
- leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second))
-
- if (node.first.type === 'split') {
- collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder)
- }
- if (node.second.type === 'split') {
- collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder)
- }
-}
-
-export function collectLeafIdsInReplayCreationOrder(
- node: TerminalPaneLayoutNode | null | undefined
-): string[] {
- if (!node) {
- return []
- }
- const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)]
- if (node.type === 'split') {
- collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder)
- }
- return leafIdsInReplayCreationOrder
-}
-
// Cross-platform monospace fallback chain ensures the terminal always has a
// usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are
// harmless on other platforms (the browser skips them), while Cascadia Mono /
@@ -156,11 +116,11 @@ export function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutN
}
if (node.classList.contains('pane')) {
- const paneId = Number(node.dataset.paneId ?? '')
- if (!Number.isFinite(paneId)) {
+ const leafId = node.dataset.leafId
+ if (!leafId || !isTerminalLeafId(leafId)) {
return null
}
- return { type: 'leaf', leafId: paneLeafId(paneId) }
+ return { type: 'leaf', leafId }
}
if (!node.classList.contains('pane-split')) {
@@ -201,31 +161,21 @@ export function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutN
export function serializeTerminalLayout(
root: HTMLDivElement | null,
activePaneId: number | null,
- expandedPaneId: number | null
+ expandedPaneId: number | null,
+ leafIdByPaneId?: ReadonlyMap
): TerminalLayoutSnapshot {
const rootNode = serializePaneTree(
root?.firstElementChild instanceof HTMLElement ? root.firstElementChild : null
)
+ const activeLeafId = activePaneId === null ? null : leafIdByPaneId?.get(activePaneId)
+ const expandedLeafId = expandedPaneId === null ? null : leafIdByPaneId?.get(expandedPaneId)
return {
root: rootNode,
- activeLeafId: activePaneId === null ? null : paneLeafId(activePaneId),
- expandedLeafId: expandedPaneId === null ? null : paneLeafId(expandedPaneId)
+ activeLeafId: activeLeafId && isTerminalLeafId(activeLeafId) ? activeLeafId : null,
+ expandedLeafId: expandedLeafId && isTerminalLeafId(expandedLeafId) ? expandedLeafId : null
}
}
-function collectLeafIds(
- node: TerminalPaneLayoutNode,
- paneByLeafId: Map,
- paneId: number
-): void {
- if (node.type === 'leaf') {
- paneByLeafId.set(node.leafId, paneId)
- return
- }
- collectLeafIds(node.first, paneByLeafId, paneId)
- collectLeafIds(node.second, paneByLeafId, paneId)
-}
-
/**
* Write saved scrollback buffers into the restored panes so the user sees
* their previous terminal output after an app restart. If a buffer was
@@ -289,9 +239,12 @@ export function replayTerminalLayout(
): Map {
const paneByLeafId = new Map()
- const initialPane = manager.createInitialPane({ focus: focusInitialPane })
+ const normalized = normalizeTerminalLayoutSnapshot(snapshot)
+ snapshot = normalized.snapshot
+ const initialLeafId = snapshot.root ? getLeftmostLeafId(snapshot.root) : undefined
+ const initialPane = manager.createInitialPane({ focus: focusInitialPane, leafId: initialLeafId })
if (!snapshot?.root) {
- paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id)
+ paneByLeafId.set(initialPane.leafId, initialPane.id)
return paneByLeafId
}
@@ -302,10 +255,11 @@ export function replayTerminalLayout(
}
const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection, {
- ratio: node.ratio
+ ratio: node.ratio,
+ leafId: getLeftmostLeafId(node.second)
})
if (!createdPane) {
- collectLeafIds(node, paneByLeafId, paneId)
+ restoreNode(node.first, paneId)
return
}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index f907807c632..b6752f8581d 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -3,6 +3,7 @@ import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { POST_REPLAY_FOCUS_REPORTING_RESET, POST_REPLAY_MODE_RESET } from './layout-serialization'
import type * as UseNotificationDispatchModule from './use-notification-dispatch'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
// Why: the fresh-spawn and reattach paths now chain pre-signal → spawn →
// register/settle through multiple microtasks. Tests that previously flushed
@@ -15,6 +16,12 @@ async function flushAsyncTicks(count = 6): Promise {
}
const toastInfo = vi.fn()
+const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const
+const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const
+
+function leafIdForPane(paneId: number): string {
+ return paneId === 2 ? LEAF_2 : LEAF_1
+}
type StoreState = {
tabsByWorktree: Record
@@ -24,7 +31,7 @@ type StoreState = {
repos: { id: string; connectionId?: string | null }[]
sshConnectionStates: Map
cacheTimerByKey: Record
- settings: { promptCacheTimerEnabled?: boolean } | null
+ settings: { promptCacheTimerEnabled?: boolean; activeRuntimeEnvironmentId?: string | null } | null
codexRestartNoticeByPtyId: Record<
string,
{ previousAccountLabel: string; nextAccountLabel: string }
@@ -35,6 +42,9 @@ type StoreState = {
removeDeferredSshSessionId: ReturnType
consumePendingColdRestore: ReturnType
consumePendingSnapshot: ReturnType
+ agentStatusByPaneKey: Record
+ removeAgentStatus: ReturnType
+ dropAgentStatus: ReturnType
}
type ConnectCallbacks = {
@@ -135,6 +145,19 @@ vi.mock('./pty-transport', () => ({
})
}))
+vi.mock('./remote-runtime-pty-transport', () => ({
+ createRemoteRuntimePtyTransport: vi.fn(
+ (_environmentId: string, options: Record) => {
+ createdTransportOptions.push(options)
+ const nextTransport = transportFactoryQueue.shift()
+ if (!nextTransport) {
+ throw new Error('No mock transport queued')
+ }
+ return nextTransport
+ }
+ )
+}))
+
function createMockTransport(initialPtyId: string | null = null): MockTransport {
let ptyId = initialPtyId
return {
@@ -155,15 +178,21 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
}
function createPane(paneId: number) {
+ const leafId = leafIdForPane(paneId)
return {
id: paneId,
+ leafId,
+ stablePaneId: leafId,
terminal: {
cols: 120,
rows: 40,
write: vi.fn(),
onData: vi.fn(() => ({ dispose: vi.fn() })),
onResize: vi.fn(() => ({ dispose: vi.fn() })),
- onTitleChange: vi.fn(() => ({ dispose: vi.fn() }))
+ onTitleChange: vi.fn(() => ({ dispose: vi.fn() })),
+ parser: {
+ registerOscHandler: vi.fn(() => ({ dispose: vi.fn() }))
+ }
},
container: { dataset: {} },
fitAddon: {
@@ -176,7 +205,12 @@ function createManager(paneCount = 1) {
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
- getPanes: vi.fn(() => Array.from({ length: paneCount }, (_, index) => ({ id: index + 1 }))),
+ getPanes: vi.fn(() =>
+ Array.from({ length: paneCount }, (_, index) => ({
+ id: index + 1,
+ leafId: leafIdForPane(index + 1)
+ }))
+ ),
closePane: vi.fn(),
getActivePane: vi.fn<() => { id: number } | null>(() => null)
}
@@ -253,7 +287,9 @@ describe('connectPanePty', () => {
removeDeferredSshSessionId: vi.fn(),
consumePendingColdRestore: vi.fn(() => null),
consumePendingSnapshot: vi.fn(() => null),
- removeAgentStatus: vi.fn()
+ agentStatusByPaneKey: {},
+ removeAgentStatus: vi.fn(),
+ dropAgentStatus: vi.fn()
} as StoreState
;(globalThis as unknown as { window: unknown }).window = {
api: {
@@ -555,6 +591,44 @@ describe('connectPanePty', () => {
}
})
+ it('drops agent status without retaining when OSC 133 reports the command finished', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+
+ const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
+ const transport = createMockTransport()
+ transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-local-1'
+ })
+ transportFactoryQueue.push(transport)
+ const paneKey = makePaneKey('tab-1', LEAF_1)
+ mockStoreState = {
+ ...mockStoreState,
+ agentStatusByPaneKey: {
+ [paneKey]: {
+ paneKey,
+ state: 'done',
+ prompt: 'hi',
+ updatedAt: 1000,
+ stateStartedAt: 1000,
+ agentType: 'codex',
+ stateHistory: []
+ }
+ }
+ }
+
+ const pane = createPane(1)
+ const manager = createManager(1)
+ const deps = createDeps({ isVisibleRef: { current: false } })
+
+ connectPanePty(pane as never, manager as never, deps as never)
+
+ capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
+
+ expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
+ expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
+ })
+
it('reattaches a remounted split pane to its restored leaf PTY instead of the tab-level PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -568,8 +642,8 @@ describe('connectPanePty', () => {
const pane = createPane(2)
const manager = createManager(2)
const deps = createDeps({
- restoredLeafId: 'pane:2',
- restoredPtyIdByLeafId: { 'pane:2': 'leaf-pty-2' }
+ restoredLeafId: LEAF_2,
+ restoredPtyIdByLeafId: { [LEAF_2]: 'leaf-pty-2' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -608,8 +682,8 @@ describe('connectPanePty', () => {
const pane = createPane(2)
const manager = createManager(2)
const deps = createDeps({
- restoredLeafId: 'pane:2',
- restoredPtyIdByLeafId: { 'pane:2': 'stale-pty' }
+ restoredLeafId: LEAF_2,
+ restoredPtyIdByLeafId: { [LEAF_2]: 'stale-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -647,8 +721,8 @@ describe('connectPanePty', () => {
const pane = createPane(2)
const manager = createManager(2)
const deps = createDeps({
- restoredLeafId: 'pane:2',
- restoredPtyIdByLeafId: { 'pane:2': 'restored-session' }
+ restoredLeafId: LEAF_2,
+ restoredPtyIdByLeafId: { [LEAF_2]: 'restored-session' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -686,8 +760,8 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
- restoredLeafId: 'pane:1',
- restoredPtyIdByLeafId: { 'pane:1': 'tab-pty' }
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -737,8 +811,8 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
- restoredLeafId: 'pane:1',
- restoredPtyIdByLeafId: { 'pane:1': 'tab-pty' }
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -772,8 +846,8 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
- restoredLeafId: 'pane:1',
- restoredPtyIdByLeafId: { 'pane:1': 'tab-pty' }
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -912,6 +986,65 @@ describe('connectPanePty', () => {
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached')
})
+ it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-1', ptyId: 'remote:terminal-1' }]
+ },
+ settings: {
+ ...mockStoreState.settings,
+ activeRuntimeEnvironmentId: 'env-1'
+ }
+ } as StoreState
+
+ const pane = createPane(2)
+ const manager = createManager(2)
+ const deps = createDeps()
+
+ connectPanePty(pane as never, manager as never, deps as never)
+
+ expect(transport.connect).not.toHaveBeenCalled()
+ expect(transport.attach).toHaveBeenCalledWith(
+ expect.objectContaining({ existingPtyId: 'remote:terminal-1' })
+ )
+ expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:terminal-1')
+ })
+
+ it('constructs restored encoded remote PTYs with their owning runtime environment', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-1', ptyId: 'remote:env-1@@terminal-1' }]
+ },
+ settings: {
+ ...mockStoreState.settings,
+ activeRuntimeEnvironmentId: 'env-2'
+ }
+ } as StoreState
+
+ const pane = createPane(2)
+ const manager = createManager(2)
+ const deps = createDeps()
+
+ connectPanePty(pane as never, manager as never, deps as never)
+
+ expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object))
+ expect(transport.attach).toHaveBeenCalledWith(
+ expect.objectContaining({ existingPtyId: 'remote:env-1@@terminal-1' })
+ )
+ expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'remote:env-1@@terminal-1')
+ })
+
it('persists a restarted pane PTY id and uses it on the next remount', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -952,8 +1085,8 @@ describe('connectPanePty', () => {
const remountPane = createPane(1)
const remountManager = createManager(1)
const remountDeps = createDeps({
- restoredLeafId: 'pane:1',
- restoredPtyIdByLeafId: { 'pane:1': 'pty-restarted' }
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'pty-restarted' }
})
connectPanePty(remountPane as never, remountManager as never, remountDeps as never)
@@ -1122,8 +1255,8 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
- restoredLeafId: 'leaf-1',
- restoredPtyIdByLeafId: { 'leaf-1': 'leaf-session' }
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'leaf-session' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -1226,8 +1359,8 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
- restoredLeafId: 'leaf-1',
- restoredPtyIdByLeafId: { 'leaf-1': 'leaf-session' }
+ restoredLeafId: LEAF_1,
+ restoredPtyIdByLeafId: { [LEAF_1]: 'leaf-session' }
})
connectPanePty(pane as never, manager as never, deps as never)
@@ -1317,6 +1450,6 @@ describe('connectPanePty', () => {
agentExitedHandler()
- expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith('tab-1:1', null)
+ expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null)
})
})
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 821888a3ac3..221ad4aa796 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -6,6 +6,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import type { PtyConnectResult } from './pty-transport'
import { createIpcPtyTransport } from './pty-transport'
+import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
import type { PtyConnectionDeps } from './pty-connection-types'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
@@ -13,22 +14,22 @@ import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-f
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal } from './replay-guard'
import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script'
-import {
- paneLeafId,
- POST_REPLAY_MODE_RESET,
- POST_REPLAY_FOCUS_REPORTING_RESET
-} from './layout-serialization'
+import { POST_REPLAY_MODE_RESET, POST_REPLAY_FOCUS_REPORTING_RESET } from './layout-serialization'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
import { registerPtySerializer, registerPtyTitleSource } from './pty-buffer-serializer'
+import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import {
discardTerminalOutput,
flushTerminalOutput,
waitForTerminalOutputParsed,
writeTerminalOutput
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
const pendingSpawnByPaneKey = new Map>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
+const REMOTE_PTY_ID_PREFIX = 'remote:'
// Why: when multiple panes/tabs need the same deferred SSH connection,
// the first one calls ssh.connect() and subsequent ones must wait for it
@@ -47,6 +48,10 @@ function formatSshSessionExpiredMessage(): string {
return 'Previous SSH session expired. Start a new terminal to continue.'
}
+function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
+ return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
+}
+
function sshPromptConnectOutcomeForStatus(
status: string | undefined,
sawNonDisconnected: boolean
@@ -145,10 +150,23 @@ export function connectPanePty(
const paneStartup = deps.startup ?? null
deps.startup = undefined
- // Why: cache timer state is keyed per-pane (not per-tab) so split-pane tabs
- // can track each Claude session independently without overwriting each other.
- const cacheKey = `${deps.tabId}:${pane.id}`
- const pendingSpawnKey = `${deps.tabId}:${paneLeafId(pane.id)}`
+ // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay.
+ // Use the stable layout leaf UUID, not the renderer-local numeric pane id.
+ const cacheKey = makePaneKey(deps.tabId, pane.leafId)
+ const pendingSpawnKey = cacheKey
+ const commandLifecycle = createTerminalCommandLifecycle({
+ onCommandFinished: () => {
+ const state = useAppStore.getState()
+ const entry = state.agentStatusByPaneKey[cacheKey]
+ // Why: OSC 133 D marks the foreground shell command exiting. Remove the
+ // row without retaining a done snapshot; this section represents a live
+ // agent process, and the shell prompt means that process is gone.
+ if (entry) {
+ state.dropAgentStatus(cacheKey)
+ }
+ }
+ })
+ commandLifecycle.attachXtermConsumer(pane.terminal)
const onExit = (ptyId: string): void => {
deps.syncPanePtyLayoutBinding(pane.id, null)
@@ -299,23 +317,15 @@ export function connectPanePty(
// the agent has exited. Clear any running cache timer so the sidebar doesn't
// show a stale countdown for a tab that no longer has an active Claude session.
deps.setCacheTimerStartedAt(cacheKey, null)
- // Why: the agent process is gone, so its explicit status is no longer meaningful.
- // Remove the entry so the hover UI does not show stale "working" for a dead agent.
- //
- // TODO(#1167): this path only fires on idle→shell title transitions, which
- // means Ctrl+C'd `working` rows (Codex, Gemini, OpenCode — agents with no
- // interrupt hook) linger until the 30-min AGENT_STATUS_STALE_AFTER_MS TTL
- // decays them to idle or the pane/tab is closed. PR #1167 replaces this
- // heuristic with authoritative foreground-process tracking in main so the
- // row drops within 2s of the CLI process exiting. See branch
- // brennanb2025/foreground-process-agent-exit.
- useAppStore.getState().removeAgentStatus(cacheKey)
+ // Why: do not let terminal-title reversion own agent-status lifecycle.
+ // Explicit hooks and OSC 133 command-finished marks are the reliable
+ // signals; title changes can race normal "done" states and make agents
+ // look like they vanished as soon as they finished responding.
}
// Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their
// callbacks to the correct Orca pane without resolving worktrees from cwd.
- // The key matches the `${tabId}:${paneId}` composite used for cacheTimerByKey.
- // ORCA_TAB_ID / ORCA_WORKTREE_ID are exposed separately so the receiver has
- // routing context without having to split paneKey back into its parts.
+ // The key matches the `${tabId}:${leafId}` composite used for cacheTimerByKey
+ // and agentStatusByPaneKey. Treat it as opaque outside Orca.
const paneEnv = {
...paneStartup?.env,
ORCA_PANE_KEY: cacheKey,
@@ -333,7 +343,17 @@ export function connectPanePty(
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find((t) => t.id === deps.tabId)
const shellOverride = tab?.shellOverride
- const transport = createIpcPtyTransport({
+ const restoredPtyIdForTransport =
+ deps.restoredLeafId && deps.restoredPtyIdByLeafId
+ ? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null)
+ : null
+ const remoteRuntimeOwnerForTransport =
+ (restoredPtyIdForTransport
+ ? getRemoteRuntimePtyEnvironmentId(restoredPtyIdForTransport)
+ : null) ?? (tab?.ptyId ? getRemoteRuntimePtyEnvironmentId(tab.ptyId) : null)
+ const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null
+ const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId
+ const transportOptions = {
cwd: deps.cwd,
env: paneEnv,
command: paneStartup?.command,
@@ -344,7 +364,7 @@ export function connectPanePty(
// pty:spawn returns. Daemon-host-only: SSH path leaves these undefined
// and the main-side guard short-circuits.
tabId: deps.tabId,
- leafId: paneLeafId(pane.id),
+ leafId: pane.leafId,
...(shellOverride ? { shellOverride } : {}),
...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}),
onPtyExit: onExit,
@@ -367,7 +387,10 @@ export function connectPanePty(
const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
currentState.setAgentStatus(cacheKey, payload, title)
}
- })
+ }
+ const transport = runtimeEnvironmentId
+ ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
+ : createIpcPtyTransport(transportOptions)
const hasExistingPaneTransport = deps.paneTransportsRef.current.size > 0
deps.paneTransportsRef.current.set(pane.id, transport)
@@ -459,7 +482,9 @@ export function connectPanePty(
if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) {
return
}
- window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows)
+ if (!isRemoteRuntimePtyId(currentPtyId)) {
+ window.api.pty.reportGeometry(currentPtyId, proposed.cols, proposed.rows)
+ }
}
const geometryReportObserver =
typeof ResizeObserver === 'undefined'
@@ -573,9 +598,9 @@ export function connectPanePty(
// calls from the same renderer. The cooperation gate at pty:spawn time
// sees pendingByPaneKey populated. Settle/clear later echoes the gen
// token captured here. See docs/mobile-prefer-renderer-scrollback.md.
- const preSignalPromise = window.api.pty
- .declarePendingPaneSerializer(cacheKey)
- .catch(() => null)
+ const preSignalPromise = runtimeEnvironmentId
+ ? Promise.resolve(null)
+ : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
const spawnedRaw = transport.connect({
url: '',
@@ -594,8 +619,10 @@ export function connectPanePty(
typeof spawnedPtyId === 'string' ? spawnedPtyId : transport.getPtyId()
const gen = await preSignalPromise
if (typeof gen === 'number' && resolvedPtyId) {
- registerPaneSerializerFor(resolvedPtyId)
- void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
+ if (!isRemoteRuntimePtyId(resolvedPtyId)) {
+ registerPaneSerializerFor(resolvedPtyId)
+ void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
+ }
} else if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
}
@@ -641,6 +668,7 @@ export function connectPanePty(
}
const dataCallback = (data: string): void => {
+ commandLifecycle.handlePtyData(data)
if (terminalOutputPrefersDomRenderer(data)) {
manager.markPaneHasComplexScriptOutput(pane.id)
}
@@ -682,7 +710,7 @@ export function connectPanePty(
warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
- leafId: deps.restoredLeafId ?? paneLeafId(pane.id),
+ leafId: deps.restoredLeafId ?? pane.leafId,
paneId: pane.id,
ptyId: staleSessionId ?? null
})
@@ -734,7 +762,9 @@ export function connectPanePty(
if (connectResult.coldRestore) {
// Snapshot superseded the cold-restore payload — ack it so the
// daemon does not redeliver it on the next reattach.
- window.api.pty.ackColdRestore(ptyId)
+ if (!isRemoteRuntimePtyId(ptyId)) {
+ window.api.pty.ackColdRestore(ptyId)
+ }
}
} else if (connectResult?.replay) {
// Relay replay holds the last 100 KB of raw output. The xterm may
@@ -745,7 +775,9 @@ export function connectPanePty(
writeReplayData(connectResult.replay)
writeReplayData(POST_REPLAY_FOCUS_REPORTING_RESET)
if (connectResult.coldRestore) {
- window.api.pty.ackColdRestore(ptyId)
+ if (!isRemoteRuntimePtyId(ptyId)) {
+ window.api.pty.ackColdRestore(ptyId)
+ }
}
} else if (connectResult?.coldRestore) {
// restoreScrollbackBuffers() already wrote the saved xterm buffer
@@ -763,7 +795,9 @@ export function connectPanePty(
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
// reset them to match the fresh shell's expectations.
writeReplayData(POST_REPLAY_MODE_RESET)
- window.api.pty.ackColdRestore(ptyId)
+ if (!isRemoteRuntimePtyId(ptyId)) {
+ window.api.pty.ackColdRestore(ptyId)
+ }
}
// Why: when a mobile-fit override is active, skip sending desktop dims
// to the PTY — the PTY is already at phone dimensions and must stay there.
@@ -774,7 +808,9 @@ export function connectPanePty(
// Why: POSIX only delivers SIGWINCH when terminal dimensions actually
// change. Sending it explicitly guarantees restored TUIs repaint at
// the correct cursor position after snapshot replay.
- window.api.pty.signal(ptyId, 'SIGWINCH')
+ if (!isRemoteRuntimePtyId(ptyId)) {
+ window.api.pty.signal(ptyId, 'SIGWINCH')
+ }
scheduleRuntimeGraphSync()
}
@@ -938,9 +974,10 @@ export function connectPanePty(
// cooperation gate uniformly applies to remote sessions. Issue
// declare and connect back-to-back; Electron preserves order. See
// docs/mobile-prefer-renderer-scrollback.md.
- const preSignalPromise = window.api.pty
- .declarePendingPaneSerializer(cacheKey)
- .catch(() => null)
+ const preSignalPromise =
+ runtimeEnvironmentId || isRemoteRuntimePtyId(pendingSessionId)
+ ? Promise.resolve(null)
+ : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
let expiredReattachError = false
const reattachPromise = transport.connect({
url: '',
@@ -983,7 +1020,9 @@ export function connectPanePty(
handleReattachResult(result, pendingSessionId)
const gen = await preSignalPromise
if (typeof gen === 'number') {
- void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
+ if (!isRemoteRuntimePtyId(pendingSessionId)) {
+ void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
+ }
}
})
.catch(async (err) => {
@@ -1043,6 +1082,7 @@ export function connectPanePty(
// the reattach and spawn a fresh session instead.
const deferredReattachSessionId =
candidateReattachSessionId &&
+ !isRemoteRuntimePtyId(candidateReattachSessionId) &&
isSessionOwnedByWorktree(candidateReattachSessionId, deps.worktreeId)
? candidateReattachSessionId
: null
@@ -1066,9 +1106,10 @@ export function connectPanePty(
// channel preserves order. See
// docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite
// requirement #4).
- const preSignalPromise = window.api.pty
- .declarePendingPaneSerializer(cacheKey)
- .catch(() => null)
+ const preSignalPromise =
+ runtimeEnvironmentId || isRemoteRuntimePtyId(deferredReattachSessionId)
+ ? Promise.resolve(null)
+ : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
let expiredReattachError = false
const reattachPromise = transport.connect({
@@ -1104,7 +1145,9 @@ export function connectPanePty(
handleReattachResult(result, deferredReattachSessionId)
const gen = await preSignalPromise
if (typeof gen === 'number') {
- void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
+ if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
+ void window.api.pty.settlePaneSerializer(cacheKey, gen).catch(() => {})
+ }
}
})
.catch(async (err) => {
@@ -1116,7 +1159,7 @@ export function connectPanePty(
warnTerminalLifecycleAnomaly('restored PTY reattach threw', {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
- leafId: deps.restoredLeafId ?? paneLeafId(pane.id),
+ leafId: deps.restoredLeafId ?? pane.leafId,
paneId: pane.id,
ptyId: deferredReattachSessionId,
reason: message
@@ -1165,11 +1208,9 @@ export function connectPanePty(
}
} else {
allowInitialIdleCacheSeed = false
- const pendingSpawn = hasExistingPaneTransport
- ? undefined
- : pendingSpawnByPaneKey.get(pendingSpawnKey)
+ const pendingSpawn = pendingSpawnByPaneKey.get(pendingSpawnKey)
if (pendingSpawn) {
- console.log(`[pty-connect] pane=${pane.id} → PENDING SPAWN (waiting on sibling)`)
+ console.log(`[pty-connect] pane=${pane.id} → PENDING SPAWN (waiting on same leaf)`)
;((globalThis as Record).__ptyConnectDiag as string[])?.push(
`pane=${pane.id} → PENDING SPAWN`
)
@@ -1253,6 +1294,7 @@ export function connectPanePty(
cancelAnimationFrame(pendingGeometryReportRaf)
pendingGeometryReportRaf = null
}
+ commandLifecycle.dispose()
}
}
}
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
index dd6c0737429..1164b439282 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
@@ -1,5 +1,13 @@
/* oxlint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ TerminalStreamOpcode,
+ decodeTerminalStreamFrame,
+ decodeTerminalStreamJson,
+ encodeTerminalStreamFrame,
+ encodeTerminalStreamJson,
+ encodeTerminalStreamText
+} from '../../../../shared/terminal-stream-protocol'
describe('createIpcPtyTransport', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
@@ -553,3 +561,217 @@ describe('createIpcPtyTransport', () => {
expect(transport.getPtyId()).toBeNull()
})
})
+
+describe('createRemoteRuntimePtyTransport', () => {
+ const originalWindow = (globalThis as { window?: typeof window }).window
+ const runtimeCall = vi.fn()
+ const runtimeSubscribe = vi.fn()
+ let subscriptionCallbacks: {
+ onResponse: (response: unknown) => void
+ onBinary?: (bytes: Uint8Array) => void
+ onError?: (error: { code: string; message: string }) => void
+ onClose?: () => void
+ } | null = null
+ let unsubscribe: {
+ unsubscribe: () => void
+ sendBinary: (bytes: Uint8Array) => void
+ } | null = null
+ let unsubscribeFn: ReturnType void>> | null = null
+
+ beforeEach(() => {
+ vi.resetModules()
+ runtimeCall.mockReset()
+ runtimeSubscribe.mockReset()
+ subscriptionCallbacks = null
+ unsubscribeFn = vi.fn<() => void>()
+ unsubscribe = {
+ unsubscribe: unsubscribeFn,
+ sendBinary: vi.fn()
+ }
+ runtimeCall.mockResolvedValue({
+ id: 'rpc-create',
+ ok: true,
+ result: {
+ terminal: {
+ handle: 'term-remote',
+ worktreeId: 'repo1::/remote/wt',
+ title: null,
+ surface: 'background'
+ }
+ },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ runtimeSubscribe.mockImplementation(
+ async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
+ subscriptionCallbacks = callbacks
+ queueMicrotask(() => {
+ subscriptionCallbacks?.onResponse({
+ id: 'rpc-multiplex',
+ ok: true,
+ result: { type: 'ready' },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ })
+ return unsubscribe
+ }
+ )
+
+ ;(globalThis as { window: typeof window }).window = {
+ ...originalWindow,
+ api: {
+ ...originalWindow?.api,
+ runtimeEnvironments: {
+ ...originalWindow?.api?.runtimeEnvironments,
+ call: runtimeCall,
+ subscribe: runtimeSubscribe
+ }
+ }
+ } as unknown as typeof window
+ })
+
+ afterEach(() => {
+ if (originalWindow) {
+ ;(globalThis as { window: typeof window }).window = originalWindow
+ } else {
+ delete (globalThis as { window?: typeof window }).window
+ }
+ })
+
+ function latestRemoteSubscribePayload(): { streamId: number } {
+ const send = unsubscribe?.sendBinary as unknown as
+ | { mock: { calls: [Uint8Array][] } }
+ | undefined
+ const frames =
+ send?.mock.calls
+ .map((call) => decodeTerminalStreamFrame(call[0]))
+ .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) ?? []
+ const frame = frames.at(-1)
+ if (!frame) {
+ throw new Error('missing remote terminal subscribe frame')
+ }
+ const payload = decodeTerminalStreamJson<{ streamId: number }>(frame.payload)
+ if (!payload) {
+ throw new Error('invalid remote terminal subscribe frame')
+ }
+ return payload
+ }
+
+ it('creates and subscribes to a terminal on the active remote runtime', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onReplayData = vi.fn()
+ const onData = vi.fn()
+ const onConnect = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'repo1::/remote/wt',
+ command: 'claude',
+ env: { ORCA_TAB_ID: 'tab-1' },
+ tabId: 'tab-1',
+ leafId: '11111111-1111-4111-8111-111111111111'
+ })
+
+ const result = await transport.connect({
+ url: '',
+ callbacks: { onReplayData, onData, onConnect }
+ })
+
+ expect(result).toEqual({ id: 'remote:env-1@@term-remote', replay: '' })
+ expect(runtimeCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.create',
+ params: {
+ worktree: 'repo1::/remote/wt',
+ command: 'claude',
+ env: { ORCA_TAB_ID: 'tab-1' },
+ tabId: 'tab-1',
+ leafId: '11111111-1111-4111-8111-111111111111',
+ focus: false
+ },
+ timeoutMs: 15_000
+ })
+ expect(runtimeSubscribe).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selector: 'env-1',
+ method: 'terminal.multiplex',
+ params: {}
+ }),
+ expect.any(Object)
+ )
+ const { streamId } = latestRemoteSubscribePayload()
+
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.SnapshotStart,
+ streamId,
+ seq: 1,
+ payload: encodeTerminalStreamJson({ kind: 'scrollback' })
+ })
+ )
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.SnapshotChunk,
+ streamId,
+ seq: 2,
+ payload: encodeTerminalStreamText('hello')
+ })
+ )
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.SnapshotEnd,
+ streamId,
+ seq: 3,
+ payload: new Uint8Array()
+ })
+ )
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Output,
+ streamId,
+ seq: 4,
+ payload: encodeTerminalStreamText(' world')
+ })
+ )
+
+ expect(onReplayData).toHaveBeenCalledWith('hello')
+ expect(onConnect).toHaveBeenCalled()
+ expect(onData).toHaveBeenCalledWith(' world')
+ })
+
+ it('forwards input and cleanup through runtime RPC', async () => {
+ vi.useFakeTimers()
+ try {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'repo1::/remote/wt',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ await transport.connect({ url: '', callbacks: {} })
+ const { streamId } = latestRemoteSubscribePayload()
+ runtimeCall.mockClear()
+ const send = unsubscribe?.sendBinary as unknown as {
+ mockClear: () => void
+ mock: { calls: [Uint8Array][] }
+ }
+ send.mockClear()
+
+ expect(transport.sendInput('ls\r')).toBe(true)
+ await vi.runOnlyPendingTimersAsync()
+ expect(runtimeCall).not.toHaveBeenCalled()
+ const inputFrame = decodeTerminalStreamFrame(send.mock.calls[0][0])
+ expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input)
+ expect(inputFrame?.streamId).toBe(streamId)
+
+ transport.disconnect()
+ expect(unsubscribeFn).toHaveBeenCalled()
+ expect(runtimeCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.close',
+ params: { terminal: 'term-remote' },
+ timeoutMs: 15_000
+ })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
index 98aeff0354c..8ccc6e8a300 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -38,10 +38,165 @@ export type {
export { extractLastOscTitle } from '../../../../shared/agent-detection'
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
+const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared
// Why: onAgentStatus callback added to IpcPtyTransportOptions in pty-dispatcher
// so the OSC 9999 status payloads can be forwarded to the store.
+type PtyOutputCallbacks = Parameters[0]['callbacks']
+
+type PtyOutputProcessorOptions = Pick<
+ IpcPtyTransportOptions,
+ | 'onTitleChange'
+ | 'onBell'
+ | 'onAgentBecameIdle'
+ | 'onAgentBecameWorking'
+ | 'onAgentExited'
+ | 'onAgentStatus'
+>
+
+type ProcessPtyOutputOptions = {
+ replayingBufferedData?: boolean
+ suppressAttentionEvents?: boolean
+}
+
+export function createPtyOutputProcessor({
+ onTitleChange,
+ onBell,
+ onAgentBecameIdle,
+ onAgentBecameWorking,
+ onAgentExited,
+ onAgentStatus
+}: PtyOutputProcessorOptions): {
+ processData: (
+ data: string,
+ callbacks: PtyOutputCallbacks,
+ options?: ProcessPtyOutputOptions
+ ) => void
+ clearAccumulatedState: () => void
+ clearStaleTitleTimer: () => void
+ resetBellDetector: () => void
+} {
+ const bellDetector = createBellDetector()
+ const processAgentStatusChunk = createAgentStatusOscProcessor()
+ let lastEmittedTitle: string | null = null
+ let staleTitleTimer: ReturnType | null = null
+ const agentTracker =
+ onAgentBecameIdle || onAgentBecameWorking || onAgentExited
+ ? createAgentStatusTracker(
+ (title) => {
+ onAgentBecameIdle?.(title)
+ },
+ onAgentBecameWorking,
+ onAgentExited
+ )
+ : null
+
+ function applyObservedTerminalTitle(title: string): void {
+ // Why: cursor-agent's native OSC title is the literal string "Cursor Agent"
+ // and it re-emits that title many times per turn (on every internal redraw)
+ // even while it's actively working. Orca drives the cursor spinner/unread
+ // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready"
+ // frames from the hook server (see src/main/index.ts). If we let cursor's
+ // bare title through, it lands in `runtimePaneTitlesByTabId` — where
+ // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid
+ // within a second of the spinner appearing. Dropping the bare title before
+ // it reaches the store leaves the synthesized frame as the last-applied
+ // state until the next hook event overwrites it. Match is literal (trimmed,
+ // case-insensitive) so any task/chat title cursor auto-generates still
+ // passes through unchanged.
+ if (title.trim().toLowerCase() === 'cursor agent') {
+ return
+ }
+ lastEmittedTitle = normalizeTerminalTitle(title)
+ onTitleChange?.(lastEmittedTitle, title)
+ agentTracker?.handleTitle(title)
+ }
+
+ function clearStaleTitleTimer(): void {
+ if (staleTitleTimer) {
+ clearTimeout(staleTitleTimer)
+ staleTitleTimer = null
+ }
+ }
+
+ function processData(
+ data: string,
+ callbacks: PtyOutputCallbacks,
+ options: ProcessPtyOutputOptions = {}
+ ): void {
+ const suppressAttentionEvents = options.suppressAttentionEvents === true
+ // Why: OSC 9999 is a renderer-only control protocol. Parse it before
+ // xterm sees the bytes, and keep parser state across chunks so partial
+ // PTY reads do not drop valid status updates or print escape garbage.
+ const processed = processAgentStatusChunk(data)
+ data = processed.cleanData
+ // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer
+ // replay we must not surface stale agent-status payloads from a prior app
+ // session into the live store. The parser still consumes the bytes so they
+ // do not leak into xterm, we just suppress the callback.
+ if (onAgentStatus && !suppressAttentionEvents) {
+ for (const payload of processed.payloads) {
+ onAgentStatus(payload)
+ }
+ }
+ if (options.replayingBufferedData && callbacks.onReplayData) {
+ callbacks.onReplayData(data)
+ } else {
+ callbacks.onData?.(data)
+ }
+ if (onTitleChange) {
+ // Why: feed EVERY OSC title in the chunk through the observer, not just
+ // the last one. node-pty + the main-process 8ms batch window commonly
+ // coalesce multiple title updates into a single IPC payload — for Pi's
+ // 80ms spinner + agent_end idle cycle, the last title in the chunk is
+ // the idle one and the intermediate working frames were silently
+ // dropped, so the worktree card never observed the working state.
+ // Processing titles in order preserves the working→idle transition
+ // that detectAgentStatusFromTitle and agentTracker both key off.
+ const titles = extractAllOscTitles(data)
+ if (titles.length > 0) {
+ clearStaleTitleTimer()
+ for (const title of titles) {
+ applyObservedTerminalTitle(title)
+ }
+ } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') {
+ clearStaleTitleTimer()
+ staleTitleTimer = setTimeout(() => {
+ staleTitleTimer = null
+ if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') {
+ const cleared = clearWorkingIndicators(lastEmittedTitle)
+ lastEmittedTitle = cleared
+ onTitleChange(cleared, cleared)
+ agentTracker?.handleTitle(cleared)
+ }
+ }, STALE_TITLE_TIMEOUT)
+ }
+ }
+ // Why: BEL is the attention signal. The detector is stateful across
+ // chunks so a BEL sitting inside an OSC sequence (e.g. Claude's
+ // `\e]0;title\a`) is correctly ignored — only true terminal bells raise
+ // attention. suppressAttentionEvents gates this during eager-buffer replay
+ // so historical BELs do not produce fresh alerts on cold reattach.
+ if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) {
+ onBell()
+ }
+ }
+
+ function clearAccumulatedState(): void {
+ clearStaleTitleTimer()
+ agentTracker?.reset()
+ bellDetector.reset()
+ }
+
+ return {
+ processData,
+ clearAccumulatedState,
+ clearStaleTitleTimer,
+ resetBellDetector: () => bellDetector.reset()
+ }
+}
+
export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport {
const {
cwd,
@@ -65,30 +220,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
let connected = false
let destroyed = false
let ptyId: string | null = null
- const bellDetector = createBellDetector()
// Why: eager PTY buffers contain output produced before the pane attached —
// often from the previous app session. We still replay that data so titles
// and scrollback restore correctly, but it must not produce fresh bells,
// unread marks, or notifications for unrelated worktrees just because Orca
// is reconnecting background terminals on launch.
let suppressAttentionEvents = false
- const processAgentStatusChunk = createAgentStatusOscProcessor()
- let lastEmittedTitle: string | null = null
- let staleTitleTimer: ReturnType | null = null
- const agentTracker =
- onAgentBecameIdle || onAgentBecameWorking || onAgentExited
- ? createAgentStatusTracker(
- (title) => {
- if (!suppressAttentionEvents) {
- onAgentBecameIdle?.(title)
- }
- },
- onAgentBecameWorking,
- onAgentExited
- )
- : null
-
- const STALE_TITLE_TIMEOUT = 3000 // ms before stale working title is cleared
+ const outputProcessor = createPtyOutputProcessor({
+ onTitleChange,
+ onBell,
+ onAgentBecameIdle: (title) => {
+ if (!suppressAttentionEvents) {
+ onAgentBecameIdle?.(title)
+ }
+ },
+ onAgentBecameWorking,
+ onAgentExited,
+ onAgentStatus
+ })
let storedCallbacks: Parameters[0]['callbacks'] = {}
function unregisterPtyHandlers(id: string): void {
@@ -103,27 +252,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
ptyReplayHandlers.delete(id)
}
- function applyObservedTerminalTitle(title: string): void {
- // Why: cursor-agent's native OSC title is the literal string "Cursor Agent"
- // and it re-emits that title many times per turn (on every internal redraw)
- // even while it's actively working. Orca drives the cursor spinner/unread
- // path by injecting its own synthesized "⠋ Cursor Agent" and "Cursor ready"
- // frames from the hook server (see src/main/index.ts). If we let cursor's
- // bare title through, it lands in `runtimePaneTitlesByTabId` — where
- // `getWorktreeStatus` reads from — and flips the sidebar dot back to solid
- // within a second of the spinner appearing. Dropping the bare title before
- // it reaches the store leaves the synthesized frame as the last-applied
- // state until the next hook event overwrites it. Match is literal (trimmed,
- // case-insensitive) so any task/chat title cursor auto-generates still
- // passes through unchanged.
- if (title.trim().toLowerCase() === 'cursor agent') {
- return
- }
- lastEmittedTitle = normalizeTerminalTitle(title)
- onTitleChange?.(lastEmittedTitle, title)
- agentTracker?.handleTitle(title)
- }
-
// Why: true while we're replaying buffered/attach-time bytes into the
// terminal. Routes those bytes through onReplayData so the renderer can
// engage the replay guard — otherwise xterm auto-replies to embedded
@@ -144,78 +272,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
}
})
ptyDataHandlers.set(id, (data) => {
- // Why: OSC 9999 is a renderer-only control protocol. Parse it before
- // xterm sees the bytes, and keep parser state across chunks so partial
- // PTY reads do not drop valid status updates or print escape garbage.
- const processed = processAgentStatusChunk(data)
- data = processed.cleanData
- // Why: mirror the onBell / onAgentBecameIdle guard below — during eager-buffer
- // replay we must not surface stale agent-status payloads from a prior app
- // session into the live store. The parser still consumes the bytes so they
- // do not leak into xterm, we just suppress the callback.
- if (onAgentStatus && !suppressAttentionEvents) {
- for (const payload of processed.payloads) {
- onAgentStatus(payload)
- }
- }
- if (replayingBufferedData && storedCallbacks.onReplayData) {
- storedCallbacks.onReplayData(data)
- } else {
- storedCallbacks.onData?.(data)
- }
- if (onTitleChange) {
- // Why: feed EVERY OSC title in the chunk through the observer, not just
- // the last one. node-pty + the main-process 8ms batch window commonly
- // coalesce multiple title updates into a single IPC payload — for Pi's
- // 80ms spinner + agent_end idle cycle, the last title in the chunk is
- // the idle one and the intermediate working frames were silently
- // dropped, so the worktree card never observed the working state.
- // Processing titles in order preserves the working→idle transition
- // that detectAgentStatusFromTitle and agentTracker both key off.
- const titles = extractAllOscTitles(data)
- if (titles.length > 0) {
- if (staleTitleTimer) {
- clearTimeout(staleTitleTimer)
- staleTitleTimer = null
- }
- for (const title of titles) {
- applyObservedTerminalTitle(title)
- }
- } else if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') {
- if (staleTitleTimer) {
- clearTimeout(staleTitleTimer)
- }
- staleTitleTimer = setTimeout(() => {
- staleTitleTimer = null
- if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') {
- const cleared = clearWorkingIndicators(lastEmittedTitle)
- lastEmittedTitle = cleared
- onTitleChange(cleared, cleared)
- agentTracker?.handleTitle(cleared)
- }
- }, STALE_TITLE_TIMEOUT)
- }
- }
- // Why: BEL is the attention signal. The detector is
- // stateful across chunks so a BEL sitting inside an OSC sequence
- // (e.g. Claude's `\e]0;title\a`) is correctly ignored — only true
- // terminal bells raise attention. suppressAttentionEvents gates this
- // during the synchronous eager-buffer replay so a historical BEL
- // captured from the prior session does not produce a fresh alert on
- // cold reattach.
- if (onBell && bellDetector.chunkContainsBell(data) && !suppressAttentionEvents) {
- onBell()
- }
+ outputProcessor.processData(data, storedCallbacks, {
+ replayingBufferedData,
+ suppressAttentionEvents
+ })
})
}
function clearAccumulatedState(): void {
- if (staleTitleTimer) {
- clearTimeout(staleTitleTimer)
- staleTitleTimer = null
- }
- agentTracker?.reset()
- bellDetector.reset()
+ outputProcessor.clearAccumulatedState()
}
function registerPtyExitHandler(id: string): void {
@@ -383,17 +448,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
// working→idle transition (and phantom cache-timer write) for a session
// that was never live in this app instance. Cancel it so the replay has
// no lingering side effects.
- if (staleTitleTimer) {
- clearTimeout(staleTitleTimer)
- staleTitleTimer = null
- }
+ outputProcessor.clearStaleTitleTimer()
// Why: eager-buffered bytes may end mid-OSC (truncated/partial session
// data), leaving bellDetector with inOsc = true. Without resetting, the
// next real BEL in live data would be silently classified as an OSC
// terminator and dropped. BEL is the sole attention signal per the PR
// design, so this reset guards the attention pipeline against a silent
// regression driven by replay state leaking into the live stream.
- bellDetector.reset()
+ outputProcessor.resetBellDetector()
}
}
bufferHandle.dispose()
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts
new file mode 100644
index 00000000000..cc86cd5df80
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts
@@ -0,0 +1,81 @@
+export type RemoteRuntimePtyBatcher = {
+ push: (data: string) => void
+ flush: () => void
+ clear: () => void
+}
+
+export type RemoteRuntimeViewportBatcher = {
+ queue: (cols: number, rows: number) => void
+ flush: () => void
+ clear: () => void
+}
+
+export function createRemoteRuntimePtyTextBatcher(
+ delayMs: number,
+ onFlush: (text: string) => void
+): RemoteRuntimePtyBatcher {
+ let pending = ''
+ let timer: ReturnType | null = null
+
+ const clear = (): void => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+ }
+
+ const flush = (): void => {
+ const text = pending
+ pending = ''
+ clear()
+ if (text) {
+ onFlush(text)
+ }
+ }
+
+ return {
+ push(data: string): void {
+ pending += data
+ if (!timer) {
+ timer = setTimeout(flush, delayMs)
+ }
+ },
+ flush,
+ clear
+ }
+}
+
+export function createRemoteRuntimeViewportBatcher(
+ delayMs: number,
+ onFlush: (cols: number, rows: number) => void
+): RemoteRuntimeViewportBatcher {
+ let pending: { cols: number; rows: number } | null = null
+ let timer: ReturnType | null = null
+
+ const clear = (): void => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+ }
+
+ const flush = (): void => {
+ const viewport = pending
+ pending = null
+ clear()
+ if (viewport) {
+ onFlush(viewport.cols, viewport.rows)
+ }
+ }
+
+ return {
+ queue(cols: number, rows: number): void {
+ pending = { cols, rows }
+ if (!timer) {
+ timer = setTimeout(flush, delayMs)
+ }
+ },
+ flush,
+ clear
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts
new file mode 100644
index 00000000000..be400ee578f
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts
@@ -0,0 +1,47 @@
+import {
+ TerminalStreamOpcode,
+ encodeTerminalStreamFrame,
+ encodeTerminalStreamJson,
+ encodeTerminalStreamText
+} from '../../../../shared/terminal-stream-protocol'
+
+export type RemoteRuntimeBinarySender = (bytes: Uint8Array) => void
+
+export function sendRemoteRuntimeTerminalInputFrame(
+ sendBinary: RemoteRuntimeBinarySender | null,
+ streamId: number | null,
+ text: string
+): boolean {
+ if (!sendBinary || streamId === null) {
+ return false
+ }
+ sendBinary(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Input,
+ streamId,
+ seq: 0,
+ payload: encodeTerminalStreamText(text)
+ })
+ )
+ return true
+}
+
+export function sendRemoteRuntimeTerminalResizeFrame(
+ sendBinary: RemoteRuntimeBinarySender | null,
+ streamId: number | null,
+ cols: number,
+ rows: number
+): boolean {
+ if (!sendBinary || streamId === null) {
+ return false
+ }
+ sendBinary(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Resize,
+ streamId,
+ seq: 0,
+ payload: encodeTerminalStreamJson({ cols, rows })
+ })
+ )
+ return true
+}
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
new file mode 100644
index 00000000000..c880d2c7c78
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
@@ -0,0 +1,512 @@
+/* eslint-disable max-lines -- Why: remote runtime PTY behavior spans JSON fallback, binary stream, lifecycle, and parser coverage; keeping the matrix together catches transport regressions. */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ TerminalStreamOpcode,
+ decodeTerminalStreamFrame,
+ decodeTerminalStreamJson,
+ decodeTerminalStreamText,
+ encodeTerminalStreamFrame,
+ encodeTerminalStreamJson,
+ encodeTerminalStreamText
+} from '../../../../shared/terminal-stream-protocol'
+
+describe('createRemoteRuntimePtyTransport', () => {
+ const runtimeCall = vi.fn()
+ const runtimeSubscribe = vi.fn()
+ const subscriptionSendBinary = vi.fn()
+ let subscriptionCallbacks: {
+ onResponse: (response: unknown) => void
+ onBinary?: (bytes: Uint8Array) => void
+ onError?: (error: { code: string; message: string }) => void
+ onClose?: () => void
+ } | null = null
+
+ function emitMultiplexReady(): void {
+ subscriptionCallbacks?.onResponse({
+ ok: true,
+ result: { type: 'ready' }
+ })
+ }
+
+ function latestSubscribePayload(): {
+ streamId: number
+ terminal: string
+ client: { id: string; type: string }
+ viewport?: { cols: number; rows: number }
+ } {
+ const frames = subscriptionSendBinary.mock.calls
+ .map((call) => decodeTerminalStreamFrame(call[0]))
+ .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe)
+ const frame = frames.at(-1)
+ if (!frame) {
+ throw new Error('missing terminal subscribe frame')
+ }
+ const payload = decodeTerminalStreamJson<{
+ streamId: number
+ terminal: string
+ client: { id: string; type: string }
+ viewport?: { cols: number; rows: number }
+ }>(frame.payload)
+ if (!payload) {
+ throw new Error('invalid terminal subscribe payload')
+ }
+ return payload
+ }
+
+ function emitOutput(streamId: number, data: string): void {
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.Output,
+ streamId,
+ seq: 1,
+ payload: encodeTerminalStreamText(data)
+ })
+ )
+ }
+
+ function emitSnapshot(streamId: number, data: string): void {
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.SnapshotStart,
+ streamId,
+ seq: 1,
+ payload: encodeTerminalStreamJson({ kind: 'scrollback' })
+ })
+ )
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.SnapshotChunk,
+ streamId,
+ seq: 2,
+ payload: encodeTerminalStreamText(data)
+ })
+ )
+ subscriptionCallbacks?.onBinary?.(
+ encodeTerminalStreamFrame({
+ opcode: TerminalStreamOpcode.SnapshotEnd,
+ streamId,
+ seq: 3,
+ payload: new Uint8Array()
+ })
+ )
+ }
+
+ beforeEach(() => {
+ vi.resetModules()
+ vi.clearAllMocks()
+ subscriptionCallbacks = null
+ subscriptionSendBinary.mockReset()
+ runtimeCall.mockResolvedValue({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
+ runtimeSubscribe.mockImplementation(
+ async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
+ subscriptionCallbacks = callbacks
+ queueMicrotask(emitMultiplexReady)
+ return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
+ }
+ )
+ vi.stubGlobal('window', {
+ api: {
+ runtimeEnvironments: {
+ call: runtimeCall,
+ subscribe: runtimeSubscribe
+ }
+ }
+ })
+ })
+
+ it('attaches to an existing remote runtime terminal handle', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onError = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ transport.attach({
+ existingPtyId: 'remote:terminal-1',
+ cols: 120,
+ rows: 40,
+ callbacks: { onError }
+ })
+
+ await vi.waitFor(() => {
+ expect(runtimeSubscribe).toHaveBeenCalled()
+ })
+
+ expect(onError).not.toHaveBeenCalled()
+ expect(transport.getPtyId()).toBe('remote:terminal-1')
+ expect(runtimeSubscribe).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selector: 'env-1',
+ method: 'terminal.multiplex',
+ params: {}
+ }),
+ expect.any(Object)
+ )
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ expect(latestSubscribePayload()).toMatchObject({
+ terminal: 'terminal-1',
+ client: { id: 'desktop:tab-1:pane:1', type: 'desktop' },
+ viewport: { cols: 120, rows: 40 }
+ })
+ })
+
+ it('routes encoded restored terminal ids to their owning runtime environment', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-2', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ transport.attach({
+ existingPtyId: 'remote:env-1@@terminal-1',
+ cols: 120,
+ rows: 40,
+ callbacks: {}
+ })
+
+ await vi.waitFor(() => {
+ expect(runtimeSubscribe).toHaveBeenCalled()
+ })
+
+ expect(runtimeSubscribe).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selector: 'env-1',
+ method: 'terminal.multiplex'
+ }),
+ expect.any(Object)
+ )
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ expect(latestSubscribePayload()).toMatchObject({
+ terminal: 'terminal-1',
+ viewport: { cols: 120, rows: 40 }
+ })
+ })
+
+ it('closes a remote terminal created after the pane was destroyed', async () => {
+ let resolveCreate: (value: unknown) => void = () => {}
+ runtimeCall.mockImplementation((args) => {
+ if (args.method === 'terminal.create') {
+ return new Promise((resolve) => {
+ resolveCreate = resolve
+ })
+ }
+ return Promise.resolve({ ok: true, result: {} })
+ })
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ const connect = transport.connect({ url: '', callbacks: {} })
+ transport.destroy?.()
+ resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } })
+ await connect
+
+ expect(runtimeCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.close',
+ params: { terminal: 'terminal-late' },
+ timeoutMs: 15_000
+ })
+ })
+
+ it('unsubscribes a remote terminal subscription that resolves after destroy', async () => {
+ let resolveSubscribe: (value: {
+ unsubscribe: () => void
+ sendBinary: typeof subscriptionSendBinary
+ }) => void = () => {}
+ const unsubscribe = vi.fn()
+ runtimeSubscribe.mockImplementation(
+ (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
+ subscriptionCallbacks = callbacks
+ return new Promise<{ unsubscribe: () => void; sendBinary: typeof subscriptionSendBinary }>(
+ (resolve) => {
+ resolveSubscribe = (value) => {
+ resolve(value)
+ queueMicrotask(emitMultiplexReady)
+ }
+ }
+ )
+ }
+ )
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ const connect = transport.connect({ url: '', callbacks: {} })
+ await vi.waitFor(() => {
+ expect(runtimeSubscribe).toHaveBeenCalled()
+ })
+ transport.destroy?.()
+ resolveSubscribe({ unsubscribe, sendBinary: subscriptionSendBinary })
+ await connect
+
+ expect(unsubscribe).toHaveBeenCalled()
+ expect(transport.getPtyId()).toBeNull()
+ })
+
+ it('processes remote data chunks through title, bell, and OSC 9999 handlers before onData', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onData = vi.fn()
+ const onTitleChange = vi.fn()
+ const onBell = vi.fn()
+ const onAgentStatus = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ onTitleChange,
+ onBell,
+ onAgentStatus
+ })
+
+ await transport.connect({ url: '', callbacks: { onData } })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ const { streamId } = latestSubscribePayload()
+ emitOutput(
+ streamId,
+ 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07'
+ )
+
+ expect(onAgentStatus).toHaveBeenCalledWith({
+ state: 'working',
+ prompt: 'ship it',
+ agentType: 'codex'
+ })
+ expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07')
+ expect(onTitleChange).toHaveBeenCalledWith('. Claude working', '. Claude working')
+ expect(onBell).toHaveBeenCalledTimes(1)
+ })
+
+ it('processes binary remote data chunks through the terminal parser', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onData = vi.fn()
+ const onTitleChange = vi.fn()
+ const onBell = vi.fn()
+ const onAgentStatus = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ onTitleChange,
+ onBell,
+ onAgentStatus
+ })
+
+ await transport.connect({ url: '', callbacks: { onData } })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ const { streamId } = latestSubscribePayload()
+ emitOutput(
+ streamId,
+ 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after'
+ )
+
+ expect(onAgentStatus).toHaveBeenCalledWith({
+ state: 'working',
+ prompt: 'ship it',
+ agentType: 'codex'
+ })
+ expect(onData).toHaveBeenCalledWith('beforeafter')
+ })
+
+ it('does not report PTY exit when the remote runtime subscription closes', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onExit = vi.fn()
+ const onDisconnect = vi.fn()
+ const onPtyExit = vi.fn()
+ const onError = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1',
+ onPtyExit
+ })
+
+ await transport.connect({ url: '', callbacks: { onExit, onDisconnect, onError } })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ subscriptionCallbacks?.onClose?.()
+
+ expect(onExit).not.toHaveBeenCalled()
+ expect(onDisconnect).not.toHaveBeenCalled()
+ expect(onPtyExit).not.toHaveBeenCalled()
+ expect(onError).toHaveBeenCalledWith('Remote Orca runtime closed the connection.')
+ await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
+ })
+
+ it('resubscribes with the latest pane viewport after the remote stream closes', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ await transport.connect({ url: '', cols: 80, rows: 24, callbacks: {} })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ expect(latestSubscribePayload().viewport).toEqual({ cols: 80, rows: 24 })
+
+ expect(transport.resize(132, 43)).toBe(true)
+ subscriptionCallbacks?.onClose?.()
+
+ await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
+ await vi.waitFor(() => {
+ expect(latestSubscribePayload().viewport).toEqual({ cols: 132, rows: 43 })
+ })
+ })
+
+ it('coalesces rapid remote terminal input before sending it to the runtime', async () => {
+ vi.useFakeTimers()
+ try {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ await transport.connect({ url: '', callbacks: {} })
+ const { streamId } = latestSubscribePayload()
+ runtimeCall.mockClear()
+ subscriptionSendBinary.mockClear()
+
+ expect(transport.sendInput('a')).toBe(true)
+ expect(transport.sendInput('b')).toBe(true)
+ expect(runtimeCall).not.toHaveBeenCalled()
+
+ await vi.runOnlyPendingTimersAsync()
+
+ expect(runtimeCall).not.toHaveBeenCalled()
+ expect(subscriptionSendBinary).toHaveBeenCalledTimes(1)
+ const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0])
+ expect(frame?.opcode).toBe(TerminalStreamOpcode.Input)
+ expect(frame?.streamId).toBe(streamId)
+ expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('ab')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('sends coalesced terminal input as binary frames once the stream is established', async () => {
+ vi.useFakeTimers()
+ try {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ await transport.connect({ url: '', callbacks: {} })
+ const { streamId } = latestSubscribePayload()
+ runtimeCall.mockClear()
+ subscriptionSendBinary.mockClear()
+
+ expect(transport.sendInput('a')).toBe(true)
+ expect(transport.sendInput('b')).toBe(true)
+ await vi.runOnlyPendingTimersAsync()
+
+ expect(runtimeCall).not.toHaveBeenCalled()
+ expect(subscriptionSendBinary).toHaveBeenCalledTimes(1)
+ const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0])
+ expect(frame?.opcode).toBe(TerminalStreamOpcode.Input)
+ expect(frame?.streamId).toBe(streamId)
+ expect(frame ? decodeTerminalStreamText(frame.payload) : '').toBe('ab')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('coalesces rapid remote viewport updates before sending the latest size', async () => {
+ vi.useFakeTimers()
+ try {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ tabId: 'tab-1',
+ leafId: 'pane:1'
+ })
+
+ await transport.connect({ url: '', callbacks: {} })
+ const { streamId } = latestSubscribePayload()
+ runtimeCall.mockClear()
+ subscriptionSendBinary.mockClear()
+
+ expect(transport.resize(80, 24)).toBe(true)
+ expect(transport.resize(120, 40)).toBe(true)
+ expect(runtimeCall).not.toHaveBeenCalled()
+
+ await vi.runOnlyPendingTimersAsync()
+
+ expect(runtimeCall).not.toHaveBeenCalled()
+ expect(subscriptionSendBinary).toHaveBeenCalledTimes(1)
+ const frame = decodeTerminalStreamFrame(subscriptionSendBinary.mock.calls[0][0])
+ expect(frame?.opcode).toBe(TerminalStreamOpcode.Resize)
+ expect(frame?.streamId).toBe(streamId)
+ expect(frame ? decodeTerminalStreamJson(frame.payload) : null).toEqual({
+ cols: 120,
+ rows: 40
+ })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('replays remote scrollback through the parser without firing stale attention events', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onReplayData = vi.fn()
+ const onTitleChange = vi.fn()
+ const onBell = vi.fn()
+ const onAgentStatus = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ onTitleChange,
+ onBell,
+ onAgentStatus
+ })
+
+ await transport.connect({ url: '', callbacks: { onReplayData } })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ const { streamId } = latestSubscribePayload()
+ emitSnapshot(
+ streamId,
+ 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after\x1b]0;Remote title\x07\x07'
+ )
+
+ expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07')
+ expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title')
+ expect(onAgentStatus).not.toHaveBeenCalled()
+ expect(onBell).not.toHaveBeenCalled()
+ })
+
+ it('replays binary snapshot chunks without firing stale attention events', async () => {
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const onReplayData = vi.fn()
+ const onTitleChange = vi.fn()
+ const onBell = vi.fn()
+ const onAgentStatus = vi.fn()
+ const onConnect = vi.fn()
+ const transport = createRemoteRuntimePtyTransport('env-1', {
+ worktreeId: 'wt-1',
+ onTitleChange,
+ onBell,
+ onAgentStatus
+ })
+
+ await transport.connect({ url: '', callbacks: { onReplayData, onConnect } })
+ await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
+ const { streamId } = latestSubscribePayload()
+ emitSnapshot(
+ streamId,
+ 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after'
+ )
+
+ expect(onReplayData).toHaveBeenCalledWith('beforeafter')
+ expect(onAgentStatus).not.toHaveBeenCalled()
+ expect(onBell).not.toHaveBeenCalled()
+ expect(onConnect).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
new file mode 100644
index 00000000000..c42dd65dfca
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
@@ -0,0 +1,326 @@
+/* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */
+import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
+import type { RuntimeTerminalCreate } from '../../../../shared/runtime-types'
+import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher'
+import { createPtyOutputProcessor } from './pty-transport'
+import { unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client'
+import {
+ getRemoteRuntimePtyEnvironmentId,
+ getRemoteRuntimeTerminalHandle,
+ runtimeTerminalErrorMessage,
+ toRemoteRuntimePtyId
+} from '../../runtime/runtime-terminal-stream'
+import {
+ getRemoteRuntimeTerminalMultiplexer,
+ type RemoteRuntimeMultiplexedTerminal
+} from '../../runtime/remote-runtime-terminal-multiplexer'
+import {
+ createRemoteRuntimePtyTextBatcher,
+ createRemoteRuntimeViewportBatcher
+} from './remote-runtime-pty-batching'
+import { setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides'
+import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
+
+const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8
+const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
+
+export function createRemoteRuntimePtyTransport(
+ runtimeEnvironmentId: string,
+ opts: IpcPtyTransportOptions = {}
+): PtyTransport {
+ const {
+ command,
+ env,
+ worktreeId,
+ tabId,
+ leafId,
+ onPtyExit,
+ onPtySpawn,
+ onTitleChange,
+ onBell,
+ onAgentBecameIdle,
+ onAgentBecameWorking,
+ onAgentExited,
+ onAgentStatus
+ } = opts
+ let connected = false
+ let destroyed = false
+ let handle: string | null = null
+ let remotePtyId: string | null = null
+ let currentRuntimeEnvironmentId = runtimeEnvironmentId
+ let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null
+ let desiredViewport: { cols: number; rows: number } | null = null
+ let storedCallbacks: Parameters[0]['callbacks'] = {}
+ let resubscribing = false
+ const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}`
+ const outputProcessor = createPtyOutputProcessor({
+ onTitleChange,
+ onBell,
+ onAgentBecameIdle,
+ onAgentBecameWorking,
+ onAgentExited,
+ onAgentStatus
+ })
+
+ async function callRuntime(method: string, params?: unknown): Promise {
+ const response = await window.api.runtimeEnvironments.call({
+ selector: currentRuntimeEnvironmentId,
+ method,
+ params,
+ timeoutMs: 15_000
+ })
+ return unwrapRuntimeRpcResult(response as RuntimeRpcResponse)
+ }
+
+ async function closeRemoteTerminal(handleOverride?: string): Promise {
+ const targetHandle = handleOverride ?? handle
+ if (!targetHandle) {
+ return
+ }
+ try {
+ await callRuntime('terminal.close', { terminal: targetHandle })
+ } catch {
+ // Best-effort parity with local disconnect/kill.
+ }
+ }
+
+ const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => {
+ const targetHandle = handle
+ if (!connected || !targetHandle) {
+ return
+ }
+ if (multiplexedStream?.sendInput(text)) {
+ return
+ }
+ void callRuntime('terminal.send', {
+ terminal: targetHandle,
+ text,
+ client: { id: clientId, type: 'desktop' }
+ })
+ })
+
+ function sendViewportUpdate(cols: number, rows: number): void {
+ const targetHandle = handle
+ if (!connected || !targetHandle) {
+ return
+ }
+ if (multiplexedStream?.resize(cols, rows)) {
+ return
+ }
+ void callRuntime('terminal.updateViewport', {
+ terminal: targetHandle,
+ client: { id: clientId, type: 'desktop' },
+ viewport: { cols, rows }
+ }).catch(() => {})
+ }
+
+ const viewportBatcher = createRemoteRuntimeViewportBatcher(
+ REMOTE_TERMINAL_VIEWPORT_FLUSH_MS,
+ sendViewportUpdate
+ )
+
+ function rememberViewport(cols: number, rows: number): void {
+ desiredViewport = { cols, rows }
+ }
+
+ async function subscribeToHandle(): Promise {
+ if (!handle) {
+ return
+ }
+ const subscribedHandle = handle
+ const nextStream = await getRemoteRuntimeTerminalMultiplexer(
+ currentRuntimeEnvironmentId
+ ).subscribeTerminal({
+ terminal: subscribedHandle,
+ client: { id: clientId, type: 'desktop' },
+ viewport: desiredViewport ?? undefined,
+ callbacks: {
+ onData: (data) => outputProcessor.processData(data, storedCallbacks),
+ onSnapshot: (data) => {
+ if (data) {
+ outputProcessor.processData(data, storedCallbacks, {
+ replayingBufferedData: true,
+ suppressAttentionEvents: true
+ })
+ }
+ },
+ onSubscribed: () => {
+ storedCallbacks.onConnect?.()
+ storedCallbacks.onStatus?.('shell')
+ },
+ onEnd: () => {
+ outputProcessor.clearAccumulatedState()
+ connected = false
+ storedCallbacks.onExit?.(0)
+ storedCallbacks.onDisconnect?.()
+ if (remotePtyId) {
+ onPtyExit?.(remotePtyId)
+ }
+ },
+ onError: (message) => storedCallbacks.onError?.(message),
+ onFitOverrideChanged: (event) => {
+ if (remotePtyId) {
+ setFitOverride(remotePtyId, event.mode, event.cols, event.rows)
+ }
+ },
+ onDriverChanged: (driver) => {
+ if (remotePtyId) {
+ setDriverForPty(remotePtyId, driver)
+ }
+ },
+ onTransportClose: () => {
+ multiplexedStream = null
+ if (destroyed || !connected || !handle || resubscribing) {
+ return
+ }
+ resubscribing = true
+ void subscribeToHandle()
+ .catch((error) => storedCallbacks.onError?.(runtimeTerminalErrorMessage(error)))
+ .finally(() => {
+ resubscribing = false
+ })
+ }
+ }
+ })
+ if (destroyed || !connected || handle !== subscribedHandle) {
+ nextStream.close()
+ return
+ }
+ multiplexedStream = nextStream
+ }
+
+ return {
+ async connect(options) {
+ storedCallbacks = options.callbacks
+ if (destroyed || !worktreeId) {
+ return
+ }
+
+ try {
+ const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
+ worktree: worktreeId,
+ command,
+ env,
+ tabId,
+ leafId,
+ focus: false
+ })
+ handle = created.terminal.handle
+ if (destroyed) {
+ await closeRemoteTerminal(created.terminal.handle)
+ return
+ }
+
+ remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId)
+ connected = true
+ desiredViewport = {
+ cols: options.cols ?? 80,
+ rows: options.rows ?? 24
+ }
+ onPtySpawn?.(remotePtyId)
+
+ await subscribeToHandle()
+ if (destroyed || !connected || !remotePtyId) {
+ return
+ }
+
+ return {
+ id: remotePtyId,
+ replay: ''
+ } satisfies PtyConnectResult
+ } catch (error) {
+ storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
+ return undefined
+ }
+ },
+
+ attach(options) {
+ storedCallbacks = options.callbacks
+ currentRuntimeEnvironmentId =
+ getRemoteRuntimePtyEnvironmentId(options.existingPtyId) ?? runtimeEnvironmentId
+ handle = getRemoteRuntimeTerminalHandle(options.existingPtyId)
+ if (!handle) {
+ connected = false
+ remotePtyId = null
+ storedCallbacks.onError?.('Remote runtime terminal id is invalid.')
+ return
+ }
+ remotePtyId = options.existingPtyId
+ connected = true
+ desiredViewport = {
+ cols: options.cols ?? 80,
+ rows: options.rows ?? 24
+ }
+ void subscribeToHandle().catch((error) => {
+ connected = false
+ storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
+ })
+ },
+
+ disconnect() {
+ inputBatcher.flush()
+ viewportBatcher.flush()
+ outputProcessor.clearAccumulatedState()
+ if (!connected && !handle) {
+ return
+ }
+ connected = false
+ const id = remotePtyId
+ multiplexedStream?.close()
+ multiplexedStream = null
+ void closeRemoteTerminal()
+ handle = null
+ remotePtyId = null
+ storedCallbacks.onDisconnect?.()
+ if (id) {
+ onPtyExit?.(id)
+ }
+ },
+
+ detach() {
+ inputBatcher.flush()
+ viewportBatcher.flush()
+ outputProcessor.clearAccumulatedState()
+ connected = false
+ multiplexedStream?.close()
+ multiplexedStream = null
+ storedCallbacks = {}
+ },
+
+ sendInput(data: string): boolean {
+ if (!connected || !handle) {
+ return false
+ }
+ // Why: remote terminal input currently crosses the runtime RPC boundary;
+ // coalescing same-frame key bursts avoids a per-keystroke remote round-trip.
+ inputBatcher.push(data)
+ return true
+ },
+
+ resize(cols: number, rows: number): boolean {
+ if (!connected || !handle) {
+ return false
+ }
+ rememberViewport(cols, rows)
+ // Why: xterm fit can emit resize bursts while the user drags panes or
+ // restores layouts. Remote runtimes only need the last viewport in a frame.
+ viewportBatcher.queue(cols, rows)
+ return true
+ },
+
+ isConnected() {
+ return connected
+ },
+
+ getPtyId() {
+ return remotePtyId
+ },
+
+ destroy() {
+ destroyed = true
+ this.disconnect()
+ inputBatcher.clear()
+ viewportBatcher.clear()
+ }
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts
index fe8f89c5f7c..3420f1806d1 100644
--- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts
+++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts
@@ -3,7 +3,12 @@ import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
function installGetCwd(fn: (id: string) => Promise): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only shim for window.api.pty.getCwd
- ;(globalThis as any).window = { api: { pty: { getCwd: fn } } }
+ ;(globalThis as any).window = {
+ api: {
+ pty: { getCwd: fn },
+ runtimeEnvironments: { call: vi.fn() }
+ }
+ }
}
describe('resolveSplitCwd', () => {
@@ -104,4 +109,17 @@ describe('resolveSplitCwd', () => {
expect(result).toBe('/worktree')
expect(getCwd).not.toHaveBeenCalled()
})
+
+ it('skips local PTY IPC for remote runtime PTY ids', async () => {
+ const getCwd = vi.fn()
+ installGetCwd(getCwd as unknown as (id: string) => Promise)
+ const result = await resolveSplitCwd({
+ paneCwdMap: new Map(),
+ sourcePaneId: 1,
+ sourcePtyId: 'remote:term-1',
+ fallbackCwd: '/remote/worktree'
+ })
+ expect(result).toBe('/remote/worktree')
+ expect(getCwd).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts
index ce06202350c..300543d4718 100644
--- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts
+++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts
@@ -3,6 +3,7 @@
// `/proc`-or-lsof-backed IPC fallback for shells that never emit OSC 7 (agent
// TUIs, minimal sh). Both layers can legitimately come back empty, so the
// helper always finishes by returning the caller's worktree-root fallback.
+import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
export type PaneCwdEntry = { cwd: string; confirmed: boolean }
@@ -32,7 +33,7 @@ export async function resolveSplitCwd(args: {
// 2) Ask the PTY provider (/proc or lsof). Enforce a soft timeout
// renderer-side so a slow SSH relay can't stall the split.
- if (sourcePtyId) {
+ if (sourcePtyId && !isRemoteRuntimePtyId(sourcePtyId)) {
try {
const ipcCwd = await Promise.race([
window.api.pty.getCwd(sourcePtyId).catch(() => null),
diff --git a/src/renderer/src/components/terminal-pane/stale-agent-row.ts b/src/renderer/src/components/terminal-pane/stale-agent-row.ts
new file mode 100644
index 00000000000..e5cf1b20959
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/stale-agent-row.ts
@@ -0,0 +1,20 @@
+import { toast } from 'sonner'
+import { useAppStore } from '@/store'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
+
+export function dismissStaleAgentRowByKey(paneKey: string): void {
+ const store = useAppStore.getState()
+ const liveExisted = paneKey in store.agentStatusByPaneKey
+ const retainedExisted = paneKey in store.retainedAgentsByPaneKey
+ store.dropAgentStatus(paneKey)
+ store.dismissRetainedAgent(paneKey)
+ if (liveExisted || retainedExisted) {
+ toast.info("Agent's pane is no longer available.", {
+ id: `stale-agent-row-${paneKey}`
+ })
+ }
+}
+
+export function surfaceStaleAgentRow(tabId: string, leafId: string): void {
+ dismissStaleAgentRowByKey(makePaneKey(tabId, leafId))
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
index b9b87976070..570dc848ec1 100644
--- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts
@@ -78,9 +78,9 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[
// scrollback (`SerializeAddon.serialize()`) never contain `?2031`:
// SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/
// [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences
- // emits only ?2004h/?1h/?1049h. If xterm ever adds ?2031 to that
- // whitelist, this guard would start suppressing legitimate
- // subscribes during snapshot reattach — revisit then.
+ // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever
+ // adds ?2031 to that whitelist, this guard would start suppressing
+ // legitimate subscribes during snapshot reattach — revisit then.
if (deps.isReplaying()) {
return false
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts
new file mode 100644
index 00000000000..cf703930e61
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
+
+describe('createTerminalCommandLifecycle', () => {
+ it('emits commandFinished for OSC 133 D with best-effort exit codes', () => {
+ const onCommandFinished = vi.fn()
+ const lifecycle = createTerminalCommandLifecycle({ onCommandFinished })
+
+ lifecycle.handlePtyData('before\x1b]133;A\x07prompt\x1b]133;B\x07')
+ lifecycle.handlePtyData('\x1b]133;C\x07running\x1b]133;D;0\x07')
+ lifecycle.handlePtyData('\x1b]133;D;130\x07')
+ lifecycle.handlePtyData('\x1b]133;D;not-a-number\x07')
+ lifecycle.handlePtyData('\x1b]133;D\x07')
+
+ expect(onCommandFinished).toHaveBeenCalledTimes(4)
+ expect(onCommandFinished).toHaveBeenNthCalledWith(1, 0)
+ expect(onCommandFinished).toHaveBeenNthCalledWith(2, 130)
+ expect(onCommandFinished).toHaveBeenNthCalledWith(3, null)
+ expect(onCommandFinished).toHaveBeenNthCalledWith(4, null)
+ })
+
+ it('detects OSC 133 sequences split across PTY chunks', () => {
+ const onCommandFinished = vi.fn()
+ const lifecycle = createTerminalCommandLifecycle({ onCommandFinished })
+
+ lifecycle.handlePtyData('chunk\x1b]133')
+ lifecycle.handlePtyData(';D;42')
+ lifecycle.handlePtyData('\x07next')
+
+ expect(onCommandFinished).toHaveBeenCalledOnce()
+ expect(onCommandFinished).toHaveBeenCalledWith(42)
+ })
+
+ it('carries partial OSC 133 prefixes after normal output', () => {
+ const onCommandFinished = vi.fn()
+ const lifecycle = createTerminalCommandLifecycle({ onCommandFinished })
+
+ lifecycle.handlePtyData('output\x1b]')
+ lifecycle.handlePtyData('133;D;0\x07')
+
+ expect(onCommandFinished).toHaveBeenCalledOnce()
+ expect(onCommandFinished).toHaveBeenCalledWith(0)
+ })
+
+ it('supports ESC backslash string terminators', () => {
+ const onCommandFinished = vi.fn()
+ const lifecycle = createTerminalCommandLifecycle({ onCommandFinished })
+
+ lifecycle.handlePtyData('\x1b]133;D;7\x1b\\')
+
+ expect(onCommandFinished).toHaveBeenCalledWith(7)
+ })
+
+ it('registers an xterm OSC consumer without emitting lifecycle events', () => {
+ const onCommandFinished = vi.fn()
+ const dispose = vi.fn()
+ const registerOscHandler = vi.fn((_code: number, _handler: (payload: string) => boolean) => ({
+ dispose
+ }))
+ const lifecycle = createTerminalCommandLifecycle({ onCommandFinished })
+
+ const disposable = lifecycle.attachXtermConsumer({
+ parser: { registerOscHandler }
+ } as never)
+
+ expect(registerOscHandler).toHaveBeenCalledWith(133, expect.any(Function))
+ const handler = registerOscHandler.mock.calls[0][1]
+ expect(handler('D;0')).toBe(true)
+ expect(onCommandFinished).not.toHaveBeenCalled()
+
+ disposable.dispose()
+ expect(dispose).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts
new file mode 100644
index 00000000000..3fb103efeac
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts
@@ -0,0 +1,102 @@
+import type { Terminal, IDisposable } from '@xterm/xterm'
+
+type TerminalCommandLifecycleOptions = {
+ onCommandFinished: (bestEffortExitCode: number | null) => void
+}
+
+type OscTerminator = {
+ index: number
+ length: number
+}
+
+const OSC_133_PREFIX = '\x1b]133;'
+const MAX_OSC_CARRY_LENGTH = 4096
+
+function findOscTerminator(data: string, startIndex: number): OscTerminator | null {
+ const bel = data.indexOf('\x07', startIndex)
+ const st = data.indexOf('\x1b\\', startIndex)
+
+ if (bel === -1 && st === -1) {
+ return null
+ }
+ if (bel !== -1 && (st === -1 || bel < st)) {
+ return { index: bel, length: 1 }
+ }
+ return { index: st, length: 2 }
+}
+
+function parseBestEffortExitCode(value: string | undefined): number | null {
+ if (!value) {
+ return null
+ }
+ const parsed = Number.parseInt(value, 10)
+ return Number.isNaN(parsed) ? null : parsed
+}
+
+function findPrefixCarry(data: string): string {
+ const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1)
+ for (let length = maxCarryLength; length > 0; length -= 1) {
+ const suffix = data.slice(data.length - length)
+ if (OSC_133_PREFIX.startsWith(suffix)) {
+ return suffix
+ }
+ }
+ return ''
+}
+
+export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): {
+ handlePtyData: (data: string) => void
+ attachXtermConsumer: (terminal: Terminal) => IDisposable
+ dispose: () => void
+} {
+ let carry = ''
+ const disposables: IDisposable[] = []
+
+ const handleOsc133 = (payload: string): void => {
+ const [sequence, exitCode] = payload.split(';')
+ if (sequence === 'D') {
+ options.onCommandFinished(parseBestEffortExitCode(exitCode))
+ }
+ }
+
+ const handlePtyData = (data: string): void => {
+ let combined = carry + data
+ carry = ''
+
+ while (combined.length > 0) {
+ const start = combined.indexOf(OSC_133_PREFIX)
+ if (start === -1) {
+ carry = findPrefixCarry(combined)
+ return
+ }
+
+ const payloadStart = start + OSC_133_PREFIX.length
+ const terminator = findOscTerminator(combined, payloadStart)
+ if (!terminator) {
+ carry = combined.slice(start)
+ if (carry.length > MAX_OSC_CARRY_LENGTH) {
+ carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH)
+ }
+ return
+ }
+
+ handleOsc133(combined.slice(payloadStart, terminator.index))
+ combined = combined.slice(terminator.index + terminator.length)
+ }
+ }
+
+ return {
+ handlePtyData,
+ attachXtermConsumer(terminal) {
+ const disposable = terminal.parser.registerOscHandler(133, () => true)
+ disposables.push(disposable)
+ return disposable
+ },
+ dispose() {
+ carry = ''
+ for (const disposable of disposables.splice(0)) {
+ disposable.dispose()
+ }
+ }
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts
new file mode 100644
index 00000000000..2a4a29743c5
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts
@@ -0,0 +1,166 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ toastLoading: vi.fn(() => 'toast-1'),
+ toastDismiss: vi.fn(),
+ toastError: vi.fn(),
+ importExternalPathsToRuntime: vi.fn(),
+ storeState: {
+ settings: { activeRuntimeEnvironmentId: 'env-1' as string | null },
+ worktreesByRepo: {
+ repo1: [{ id: 'wt-1', path: '/remote/repo' }]
+ }
+ }
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ loading: mocks.toastLoading,
+ dismiss: mocks.toastDismiss,
+ error: mocks.toastError,
+ message: vi.fn()
+ }
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => mocks.storeState
+ }
+}))
+
+vi.mock('@/runtime/runtime-file-client', () => ({
+ importExternalPathsToRuntime: mocks.importExternalPathsToRuntime
+}))
+
+import { handleTerminalFileDrop, resolveTerminalDropTargetShell } from './terminal-drop-handler'
+
+describe('handleTerminalFileDrop', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' }
+ mocks.storeState.worktreesByRepo = {
+ repo1: [{ id: 'wt-1', path: '/remote/repo' }]
+ }
+ })
+
+ it('uploads client-local drops into the active runtime before pasting paths', async () => {
+ mocks.importExternalPathsToRuntime.mockResolvedValue({
+ results: [
+ {
+ sourcePath: '/Users/me/logo.png',
+ status: 'imported',
+ destPath: '/remote/repo/.orca/drops/logo.png',
+ kind: 'file',
+ renamed: false
+ }
+ ]
+ })
+ const sendInput = vi.fn()
+ const focus = vi.fn()
+ const manager = {
+ getActivePane: () => ({ id: 1, terminal: { focus } }),
+ getPanes: () => []
+ }
+ const paneTransports = new Map([[1, { sendInput }]])
+
+ await handleTerminalFileDrop({
+ manager: manager as never,
+ paneTransports: paneTransports as never,
+ worktreeId: 'wt-1',
+ cwd: undefined,
+ data: { paths: ['/Users/me/logo.png'], target: 'terminal' }
+ })
+
+ expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith(
+ {
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ worktreeId: 'wt-1',
+ worktreePath: '/remote/repo'
+ },
+ ['/Users/me/logo.png'],
+ '/remote/repo/.orca/drops'
+ )
+ expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/logo.png ')
+ expect(focus).toHaveBeenCalled()
+ expect(mocks.toastError).not.toHaveBeenCalled()
+ expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1')
+ })
+
+ it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => {
+ mocks.storeState.worktreesByRepo = {
+ repo1: [{ id: 'wt-1', path: '//server/share/repo' }]
+ }
+ mocks.importExternalPathsToRuntime.mockResolvedValue({
+ results: [
+ {
+ sourcePath: '/Users/me/logo.png',
+ status: 'imported',
+ destPath: '//server/share/repo\\.orca\\drops\\logo.png',
+ kind: 'file',
+ renamed: false
+ }
+ ]
+ })
+ const sendInput = vi.fn()
+ const focus = vi.fn()
+ const manager = {
+ getActivePane: () => ({ id: 1, terminal: { focus } }),
+ getPanes: () => []
+ }
+ const paneTransports = new Map([[1, { sendInput }]])
+
+ await handleTerminalFileDrop({
+ manager: manager as never,
+ paneTransports: paneTransports as never,
+ worktreeId: 'wt-1',
+ cwd: undefined,
+ data: { paths: ['/Users/me/logo.png'], target: 'terminal' }
+ })
+
+ expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith(
+ {
+ settings: { activeRuntimeEnvironmentId: 'env-1' },
+ worktreeId: 'wt-1',
+ worktreePath: '//server/share/repo'
+ },
+ ['/Users/me/logo.png'],
+ '\\\\server\\share\\repo\\.orca\\drops'
+ )
+ expect(sendInput).toHaveBeenCalledWith('\\\\server\\share\\repo\\.orca\\drops\\logo.png ')
+ })
+})
+
+describe('resolveTerminalDropTargetShell', () => {
+ it('uses runtime worktree path shape for active Windows runtimes', () => {
+ expect(
+ resolveTerminalDropTargetShell({
+ activeRuntimeEnvironmentId: 'env-1',
+ worktreePath: '//Server/Share/Repo',
+ connectionId: null,
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
+ })
+ ).toBe('windows')
+ })
+
+ it('uses runtime worktree path shape for active POSIX runtimes', () => {
+ expect(
+ resolveTerminalDropTargetShell({
+ activeRuntimeEnvironmentId: 'env-1',
+ worktreePath: '/home/orca/repo',
+ connectionId: null,
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+ })
+ ).toBe('posix')
+ })
+
+ it('keeps legacy SSH drops POSIX when no runtime environment is active', () => {
+ expect(
+ resolveTerminalDropTargetShell({
+ activeRuntimeEnvironmentId: null,
+ worktreePath: 'C:\\repo',
+ connectionId: 'ssh-1',
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
+ })
+ ).toBe('posix')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts
index 52220bf6af4..df4677e2719 100644
--- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.ts
@@ -5,6 +5,8 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { useAppStore } from '@/store'
import { isWindowsUserAgent, shellEscapePath } from './pane-helpers'
import type { PtyTransport } from './pty-transport'
+import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
+import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
type Args = {
manager: PaneManager
@@ -14,6 +16,32 @@ type Args = {
data: { paths: string[]; target: string; tabId?: string }
}
+export type TerminalTargetShell = 'posix' | 'windows'
+
+export function getTerminalTargetShellForWorktreePath(worktreePath: string): TerminalTargetShell {
+ return isWindowsPathLike(worktreePath) ? 'windows' : 'posix'
+}
+
+export function resolveTerminalDropTargetShell({
+ activeRuntimeEnvironmentId,
+ worktreePath,
+ connectionId,
+ userAgent
+}: {
+ activeRuntimeEnvironmentId: string | null | undefined
+ worktreePath: string | null | undefined
+ connectionId: string | null | undefined
+ userAgent?: string
+}): TerminalTargetShell {
+ if (activeRuntimeEnvironmentId?.trim() && worktreePath) {
+ return getTerminalTargetShellForWorktreePath(worktreePath)
+ }
+ if (typeof connectionId === 'string') {
+ return 'posix'
+ }
+ return isWindowsUserAgent(userAgent) ? 'windows' : 'posix'
+}
+
/**
* Handle a native file drop targeted at a terminal pane.
*
@@ -36,6 +64,51 @@ export async function handleTerminalFileDrop(args: Args): Promise {
if (!transport) {
return
}
+ const settings = useAppStore.getState().settings
+ const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim()
+ const worktreePath = resolveWorktreePath(worktreeId, cwd)
+ if (!worktreePath) {
+ toast.error('Worktree path not available.')
+ return
+ }
+
+ if (activeRuntimeEnvironmentId) {
+ const targetShell = getTerminalTargetShellForWorktreePath(worktreePath)
+ const destinationDir = joinRuntimeDropDir(worktreePath)
+ const pending = toast.loading(
+ `Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : 's'} to runtime…`
+ )
+ try {
+ const { results } = await importExternalPathsToRuntime(
+ {
+ settings,
+ worktreeId,
+ worktreePath
+ },
+ data.paths,
+ destinationDir
+ )
+ const imported = results.filter((result) => result.status === 'imported')
+ const skipped = results.filter((result) => result.status === 'skipped')
+ const failed = results.filter((result) => result.status === 'failed')
+ const liveTransport = paneTransports.get(paneId)
+ if (liveTransport) {
+ for (const result of imported) {
+ const shellPath = isWindowsPathLike(worktreePath)
+ ? result.destPath.replace(/\//g, '\\')
+ : result.destPath
+ liveTransport.sendInput(`${shellEscapePath(shellPath, targetShell)} `)
+ }
+ pane.terminal.focus()
+ }
+ reportUploadSkipsAndFailures(skipped, failed)
+ } catch (err) {
+ toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'))
+ } finally {
+ toast.dismiss(pending)
+ }
+ return
+ }
// Why: `getConnectionId` returns `string` (SSH), `null` (local repo found),
// or `undefined` (store not hydrated / worktree not found). Treat
@@ -47,11 +120,11 @@ export async function handleTerminalFileDrop(args: Args): Promise {
return
}
const isRemote = connectionId !== null
- const targetShell: 'posix' | 'windows' = isRemote
- ? 'posix'
- : isWindowsUserAgent()
- ? 'windows'
- : 'posix'
+ const targetShell = resolveTerminalDropTargetShell({
+ activeRuntimeEnvironmentId: null,
+ worktreePath,
+ connectionId
+ })
// Why: local fast path — no IPC round-trip, no toast — preserves today's
// zero-latency drop behavior. Trailing space separates multiple paths in
@@ -64,12 +137,6 @@ export async function handleTerminalFileDrop(args: Args): Promise {
return
}
- const worktreePath = resolveWorktreePath(worktreeId, cwd)
- if (!worktreePath) {
- toast.error('Worktree path not available.')
- return
- }
-
const pending = toast.loading(
`Uploading ${data.paths.length} file${data.paths.length === 1 ? '' : 's'} to remote…`
)
@@ -90,22 +157,7 @@ export async function handleTerminalFileDrop(args: Args): Promise {
}
pane.terminal.focus()
}
- if (skipped.length > 0) {
- // Why: symlink rejection is policy, not error — show as neutral
- // message. Mixed skips collapse to a single "items" count to avoid
- // enumerating every reason.
- const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length
- const noun = skipped.length === 1 ? 'item' : 'items'
- toast.message(
- symlinkCount === skipped.length
- ? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.`
- : `Skipped ${skipped.length} ${noun}.`
- )
- }
- if (failed.length > 0) {
- const noun = failed.length === 1 ? 'file' : 'files'
- toast.error(`Failed to upload ${failed.length} ${noun}.`)
- }
+ reportUploadSkipsAndFailures(skipped, failed)
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'))
} finally {
@@ -113,9 +165,42 @@ export async function handleTerminalFileDrop(args: Args): Promise {
}
}
+function reportUploadSkipsAndFailures(
+ skipped: { reason: string }[],
+ failed: { reason: string }[]
+): void {
+ if (skipped.length > 0) {
+ // Why: symlink rejection is policy, not error — show as neutral
+ // message. Mixed skips collapse to a single "items" count to avoid
+ // enumerating every reason.
+ const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length
+ const noun = skipped.length === 1 ? 'item' : 'items'
+ toast.message(
+ symlinkCount === skipped.length
+ ? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.`
+ : `Skipped ${skipped.length} ${noun}.`
+ )
+ }
+ if (failed.length > 0) {
+ const noun = failed.length === 1 ? 'file' : 'files'
+ toast.error(`Failed to upload ${failed.length} ${noun}.`)
+ }
+}
+
function resolveWorktreePath(worktreeId: string, fallbackCwd: string | undefined): string | null {
const state = useAppStore.getState()
const allWorktrees = Object.values(state.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
return worktree?.path ?? fallbackCwd ?? null
}
+
+function joinRuntimeDropDir(worktreePath: string): string {
+ if (isWindowsPathLike(worktreePath)) {
+ return `${worktreePath.replace(/[\\/]+$/, '').replace(/\//g, '\\')}\\.orca\\drops`
+ }
+ return `${worktreePath.replace(/[\\/]+$/, '')}/.orca/drops`
+}
+
+function isWindowsPathLike(path: string): boolean {
+ return isWindowsAbsolutePathLike(path) || path.includes('\\')
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts
new file mode 100644
index 00000000000..b04b23db0d5
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts
@@ -0,0 +1,173 @@
+import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types'
+import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id'
+import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id'
+
+const EMPTY_TERMINAL_LAYOUT: TerminalLayoutSnapshot = {
+ root: null,
+ activeLeafId: null,
+ expandedLeafId: null
+}
+
+type LeafIdRewrite = {
+ nextLeafIdByInputLeafId: Map
+ duplicatedInputLeafIds: Set
+}
+
+function cloneLayoutWithLeafRewrite(
+ node: TerminalPaneLayoutNode,
+ rewrite: LeafIdRewrite
+): TerminalPaneLayoutNode {
+ if (node.type === 'leaf') {
+ const replacement = rewrite.nextLeafIdByInputLeafId.get(node.leafId) ?? mintStablePaneId()
+ return { type: 'leaf', leafId: replacement }
+ }
+ return {
+ ...node,
+ first: cloneLayoutWithLeafRewrite(node.first, rewrite),
+ second: cloneLayoutWithLeafRewrite(node.second, rewrite)
+ }
+}
+
+function remapLeafRecord(
+ source: Record | undefined,
+ rewrite: LeafIdRewrite
+): Record | undefined {
+ if (!source) {
+ return undefined
+ }
+ const next: Record = {}
+ for (const [leafId, value] of Object.entries(source)) {
+ if (rewrite.duplicatedInputLeafIds.has(leafId)) {
+ continue
+ }
+ const nextLeafId = rewrite.nextLeafIdByInputLeafId.get(leafId)
+ if (nextLeafId) {
+ next[nextLeafId] = value
+ }
+ }
+ return Object.keys(next).length > 0 ? next : undefined
+}
+
+function collectLeafCounts(
+ node: TerminalPaneLayoutNode,
+ counts: Map = new Map()
+): Map {
+ if (node.type === 'leaf') {
+ counts.set(node.leafId, (counts.get(node.leafId) ?? 0) + 1)
+ return counts
+ }
+ collectLeafCounts(node.first, counts)
+ collectLeafCounts(node.second, counts)
+ return counts
+}
+
+function firstLeafId(node: TerminalPaneLayoutNode | null): string | null {
+ if (!node) {
+ return null
+ }
+ return node.type === 'leaf' ? node.leafId : firstLeafId(node.first)
+}
+
+export function normalizeTerminalLayoutSnapshot(
+ snapshot: TerminalLayoutSnapshot | null | undefined
+): { snapshot: TerminalLayoutSnapshot; changed: boolean } {
+ if (!snapshot?.root) {
+ return { snapshot: snapshot ?? EMPTY_TERMINAL_LAYOUT, changed: false }
+ }
+ const counts = collectLeafCounts(snapshot.root)
+ const duplicatedInputLeafIds = new Set(
+ Array.from(counts.entries())
+ .filter(([, count]) => count > 1)
+ .map(([leafId]) => leafId)
+ )
+ const nextLeafIdByInputLeafId = new Map()
+ let changed = false
+ for (const [leafId, count] of counts) {
+ if (count === 1 && isTerminalLeafId(leafId)) {
+ nextLeafIdByInputLeafId.set(leafId, leafId)
+ continue
+ }
+ changed = true
+ if (count === 1) {
+ nextLeafIdByInputLeafId.set(leafId, mintStablePaneId())
+ }
+ }
+ if (!changed) {
+ return { snapshot, changed: false }
+ }
+ const rewrite: LeafIdRewrite = { nextLeafIdByInputLeafId, duplicatedInputLeafIds }
+ const root = cloneLayoutWithLeafRewrite(snapshot.root, rewrite)
+ const activeLeafId =
+ snapshot.activeLeafId && !duplicatedInputLeafIds.has(snapshot.activeLeafId)
+ ? (nextLeafIdByInputLeafId.get(snapshot.activeLeafId) ?? null)
+ : firstLeafId(root)
+ const expandedLeafId =
+ snapshot.expandedLeafId && !duplicatedInputLeafIds.has(snapshot.expandedLeafId)
+ ? (nextLeafIdByInputLeafId.get(snapshot.expandedLeafId) ?? null)
+ : null
+ const ptyIdsByLeafId = remapLeafRecord(snapshot.ptyIdsByLeafId, rewrite)
+ const buffersByLeafId = remapLeafRecord(snapshot.buffersByLeafId, rewrite)
+ const titlesByLeafId = remapLeafRecord(snapshot.titlesByLeafId, rewrite)
+ const {
+ ptyIdsByLeafId: _oldPtyIdsByLeafId,
+ buffersByLeafId: _oldBuffersByLeafId,
+ titlesByLeafId: _oldTitlesByLeafId,
+ ...snapshotWithoutLeafRecords
+ } = snapshot
+ return {
+ snapshot: {
+ ...snapshotWithoutLeafRecords,
+ root,
+ activeLeafId,
+ expandedLeafId,
+ ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
+ ...(buffersByLeafId ? { buffersByLeafId } : {}),
+ ...(titlesByLeafId ? { titlesByLeafId } : {})
+ },
+ changed: true
+ }
+}
+
+export function collectLeafIdsInOrder(node: TerminalPaneLayoutNode | null | undefined): string[] {
+ if (!node) {
+ return []
+ }
+ if (node.type === 'leaf') {
+ return [node.leafId]
+ }
+ return [...collectLeafIdsInOrder(node.first), ...collectLeafIdsInOrder(node.second)]
+}
+
+export function getLeftmostLeafId(node: TerminalPaneLayoutNode): string {
+ return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first)
+}
+
+function collectReplayCreatedPaneLeafIds(
+ node: Extract,
+ leafIdsInReplayCreationOrder: string[]
+): void {
+ // Why: replayTerminalLayout() creates one new pane per split and assigns it
+ // to the split's second subtree before recursing, so the new pane maps to
+ // the leftmost leaf reachable within that second subtree.
+ leafIdsInReplayCreationOrder.push(getLeftmostLeafId(node.second))
+
+ if (node.first.type === 'split') {
+ collectReplayCreatedPaneLeafIds(node.first, leafIdsInReplayCreationOrder)
+ }
+ if (node.second.type === 'split') {
+ collectReplayCreatedPaneLeafIds(node.second, leafIdsInReplayCreationOrder)
+ }
+}
+
+export function collectLeafIdsInReplayCreationOrder(
+ node: TerminalPaneLayoutNode | null | undefined
+): string[] {
+ if (!node) {
+ return []
+ }
+ const leafIdsInReplayCreationOrder = [getLeftmostLeafId(node)]
+ if (node.type === 'split') {
+ collectReplayCreatedPaneLeafIds(node, leafIdsInReplayCreationOrder)
+ }
+ return leafIdsInReplayCreationOrder
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
index d767824bf70..98a111a5009 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
@@ -1,3 +1,5 @@
+/* eslint-disable max-lines -- Why: terminal link routing has intertwined local,
+SSH, and runtime cases; keeping them in one suite prevents fixture drift. */
import type { IDisposable, ILink } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
@@ -9,21 +11,34 @@ import {
openDetectedFilePath
} from './terminal-link-handlers'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
+import { getConnectionId } from '@/lib/connection-context'
+import {
+ createCompatibleRuntimeStatusResponseIfNeeded,
+ type RuntimeEnvironmentCallRequest
+} from '@/runtime/runtime-compatibility-test-fixture'
+import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
const openUrlMock = vi.fn()
const openFileUriMock = vi.fn()
+const openFilePathMock = vi.fn()
const openFileMock = vi.fn()
const authorizeExternalPathMock = vi.fn()
const statMock = vi.fn().mockResolvedValue({ isDirectory: false })
+const runtimeEnvironmentCallMock = vi.fn()
+const runtimeEnvironmentTransportCallMock = vi.fn()
const setActiveWorktreeMock = vi.fn()
const createBrowserTabMock = vi.fn()
+const setPendingEditorRevealMock = vi.fn()
const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' }
const storeState = {
- settings: undefined as { openLinksInApp?: boolean } | undefined,
+ settings: undefined as
+ | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
+ | undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock,
- openFile: openFileMock
+ openFile: openFileMock,
+ setPendingEditorReveal: setPendingEditorRevealMock
}
vi.mock('@/store', () => ({
@@ -44,27 +59,60 @@ vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
+vi.mock('@/lib/connection-context', () => ({
+ getConnectionId: vi.fn(() => null)
+}))
+
function setPlatform(userAgent: string): void {
vi.stubGlobal('navigator', { userAgent })
}
+function createDeferred(): { promise: Promise; resolve: (value: T) => void } {
+ let resolve!: (value: T) => void
+ const promise = new Promise((res) => {
+ resolve = res
+ })
+ return { promise, resolve }
+}
+
+async function flushAsyncWork(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+async function flushDoubleRaf(): Promise {
+ await flushAsyncWork()
+ await flushAsyncWork()
+}
+
beforeEach(() => {
+ clearRuntimeCompatibilityCacheForTests()
vi.clearAllMocks()
+ runtimeEnvironmentTransportCallMock.mockReset()
+ runtimeEnvironmentTransportCallMock.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
+ return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCallMock(args)
+ })
+ vi.mocked(getConnectionId).mockReturnValue(null)
storeState.settings = undefined
registerHttpLinkStoreAccessor(() => storeState)
vi.stubGlobal('window', {
+ dispatchEvent: vi.fn(),
api: {
shell: {
openUrl: openUrlMock,
openFileUri: openFileUriMock,
+ openFilePath: openFilePathMock,
pathExists: vi.fn().mockResolvedValue(true)
},
fs: {
authorizeExternalPath: authorizeExternalPathMock,
stat: statMock
- }
+ },
+ runtimeEnvironments: { call: runtimeEnvironmentTransportCallMock }
}
})
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => {
+ return setTimeout(() => callback(0), 0) as unknown as number
+ })
})
afterEach(() => {
@@ -166,6 +214,7 @@ describe('handleOscLink', () => {
// Why: .html should not open Monaco — it should render in the browser tab.
expect(openFileMock).not.toHaveBeenCalled()
+ expect(setPendingEditorRevealMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).toHaveBeenCalledWith(
'wt-1',
'file:///tmp/report.html',
@@ -180,6 +229,7 @@ describe('handleOscLink', () => {
await new Promise((resolve) => setTimeout(resolve, 0))
expect(openFileMock).not.toHaveBeenCalled()
+ expect(setPendingEditorRevealMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).toHaveBeenCalledWith(
'wt-1',
'file:///tmp/legacy.HTM',
@@ -187,6 +237,41 @@ describe('handleOscLink', () => {
)
})
+ it('schedules Monaco reveal with default column 1 for :line links', async () => {
+ setPlatform('Macintosh')
+
+ openDetectedFilePath('/tmp/src/main.ts', 42, null, deps)
+ await flushAsyncWork()
+ await flushDoubleRaf()
+
+ expect(openFileMock).toHaveBeenCalledWith(
+ expect.objectContaining({ filePath: '/tmp/src/main.ts' })
+ )
+ expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
+ expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
+ filePath: '/tmp/src/main.ts',
+ line: 42,
+ column: 1,
+ matchLength: 0
+ })
+ })
+
+ it('preserves explicit column for :line:column links', async () => {
+ setPlatform('Macintosh')
+
+ openDetectedFilePath('/tmp/src/main.ts', 42, 7, deps)
+ await flushAsyncWork()
+ await flushDoubleRaf()
+
+ expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
+ expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
+ filePath: '/tmp/src/main.ts',
+ line: 42,
+ column: 7,
+ matchLength: 0
+ })
+ })
+
it('advertises the browser-open behavior in the html hover hint', () => {
setPlatform('Macintosh')
expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open in browser')
@@ -240,6 +325,160 @@ describe('handleOscLink', () => {
})
)
})
+
+ it('stats remote-runtime file links through the active runtime environment', async () => {
+ setPlatform('Macintosh')
+ storeState.settings = { activeRuntimeEnvironmentId: 'env-1' }
+ runtimeEnvironmentCallMock.mockResolvedValueOnce({
+ id: 'rpc-1',
+ ok: true,
+ result: { size: 1, isDirectory: false, mtime: 1 },
+ _meta: { runtimeId: 'remote-runtime' }
+ })
+
+ openDetectedFilePath('/tmp/src/main.ts', null, null, deps)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(authorizeExternalPathMock).not.toHaveBeenCalled()
+ expect(statMock).not.toHaveBeenCalled()
+ await vi.waitFor(() => {
+ expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'files.stat',
+ params: { worktree: 'wt-1', relativePath: 'src/main.ts' },
+ timeoutMs: 15_000
+ })
+ })
+ expect(openFileMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filePath: '/tmp/src/main.ts',
+ relativePath: 'src/main.ts'
+ })
+ )
+ })
+
+ it('stats remote-runtime file links through the owning PTY runtime environment', async () => {
+ setPlatform('Macintosh')
+ storeState.settings = { activeRuntimeEnvironmentId: 'env-2' }
+ runtimeEnvironmentCallMock.mockResolvedValueOnce({
+ id: 'rpc-1',
+ ok: true,
+ result: { size: 1, isDirectory: false, mtime: 1 },
+ _meta: { runtimeId: 'remote-runtime' }
+ })
+
+ openDetectedFilePath('/tmp/src/main.ts', null, null, {
+ ...deps,
+ runtimeEnvironmentId: 'env-1'
+ })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ await vi.waitFor(() => {
+ expect(runtimeEnvironmentCallMock).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'files.stat',
+ params: { worktree: 'wt-1', relativePath: 'src/main.ts' },
+ timeoutMs: 15_000
+ })
+ })
+ expect(openFileMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filePath: '/tmp/src/main.ts',
+ relativePath: 'src/main.ts',
+ runtimeEnvironmentId: 'env-1'
+ })
+ )
+ })
+
+ it('opens SSH file links through Orca without local authorization', async () => {
+ setPlatform('Macintosh')
+ vi.mocked(getConnectionId).mockReturnValue('ssh-1')
+
+ openDetectedFilePath('/home/me/repo/src/main.ts', null, null, {
+ worktreeId: 'wt-1',
+ worktreePath: '/home/me/repo'
+ })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(authorizeExternalPathMock).not.toHaveBeenCalled()
+ expect(statMock).toHaveBeenCalledWith({
+ filePath: '/home/me/repo/src/main.ts',
+ connectionId: 'ssh-1'
+ })
+ expect(openFileMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filePath: '/home/me/repo/src/main.ts',
+ relativePath: 'src/main.ts'
+ })
+ )
+ })
+
+ it('does not open SSH html file links as client-local file browser tabs', async () => {
+ setPlatform('Macintosh')
+ vi.mocked(getConnectionId).mockReturnValue('ssh-1')
+
+ openDetectedFilePath('/home/me/repo/report.html', null, null, {
+ worktreeId: 'wt-1',
+ worktreePath: '/home/me/repo'
+ })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+ expect(openFileMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filePath: '/home/me/repo/report.html',
+ relativePath: 'report.html'
+ })
+ )
+ })
+
+ it('does not ask the client OS to open SSH directories', async () => {
+ setPlatform('Macintosh')
+ vi.mocked(getConnectionId).mockReturnValue('ssh-1')
+ statMock.mockResolvedValueOnce({ isDirectory: true })
+
+ openDetectedFilePath('/home/me/repo/src', null, null, {
+ worktreeId: 'wt-1',
+ worktreePath: '/home/me/repo'
+ })
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(openFilePathMock).not.toHaveBeenCalled()
+ expect(openFileMock).not.toHaveBeenCalled()
+ })
+
+ it('ignores stale async completion so latest click wins for open and reveal', async () => {
+ setPlatform('Macintosh')
+ const firstStat = createDeferred<{ isDirectory: boolean }>()
+ const secondStat = createDeferred<{ isDirectory: boolean }>()
+ statMock
+ .mockImplementationOnce(() => firstStat.promise)
+ .mockImplementationOnce(() => secondStat.promise)
+
+ openDetectedFilePath('/tmp/src/first.ts', 10, 2, deps)
+ openDetectedFilePath('/tmp/src/second.ts', 20, 3, deps)
+
+ secondStat.resolve({ isDirectory: false })
+ await flushAsyncWork()
+ await flushDoubleRaf()
+
+ firstStat.resolve({ isDirectory: false })
+ await flushAsyncWork()
+ await flushDoubleRaf()
+
+ expect(openFileMock).toHaveBeenCalledTimes(1)
+ expect(openFileMock).toHaveBeenCalledWith(
+ expect.objectContaining({ filePath: '/tmp/src/second.ts' })
+ )
+ expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(1, null)
+ expect(setPendingEditorRevealMock).toHaveBeenNthCalledWith(2, {
+ filePath: '/tmp/src/second.ts',
+ line: 20,
+ column: 3,
+ matchLength: 0
+ })
+ expect(setPendingEditorRevealMock).toHaveBeenCalledTimes(2)
+ })
})
describe('createFilePathLinkProvider range bounds', () => {
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts
index 13af7f80be9..0e40e98f8e3 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts
@@ -13,6 +13,13 @@ import { absolutePathToFileUri } from '@/components/editor/markdown-internal-lin
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { openHttpLink } from '@/lib/http-link-routing'
+import {
+ isRemoteRuntimeFileOperation,
+ runtimePathExists,
+ statRuntimePath,
+ type RuntimeFileOperationArgs
+} from '@/runtime/runtime-file-client'
+import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
export type LinkHandlerDeps = {
worktreeId: string
@@ -21,6 +28,8 @@ export type LinkHandlerDeps = {
managerRef: React.RefObject
linkProviderDisposablesRef: React.RefObject>
pathExistsCache: Map
+ runtimeEnvironmentId?: string | null
+ getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null
}
type TerminalLinkEvent = Pick &
@@ -64,28 +73,54 @@ function openHtmlFileInBrowser(filePath: string, worktreeId: string): void {
store.createBrowserTab(worktreeId, fileUrl, { title, activate: true })
}
+function getTerminalFileContext(
+ worktreeId: string,
+ worktreePath: string,
+ runtimeEnvironmentId?: string | null
+): RuntimeFileOperationArgs {
+ const settings = useAppStore.getState().settings
+ return {
+ settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
+ worktreeId: worktreeId || null,
+ worktreePath,
+ connectionId: getConnectionId(worktreeId || null) ?? undefined
+ }
+}
+
+let latestOpenDetectedFilePathRequestId = 0
+
export function openDetectedFilePath(
filePath: string,
line: number | null,
column: number | null,
- deps: Pick
+ deps: Pick
): void {
- const { worktreeId, worktreePath } = deps
+ const { runtimeEnvironmentId, worktreeId, worktreePath } = deps
+ const requestId = ++latestOpenDetectedFilePathRequestId
void (async () => {
let statResult
try {
- const connectionId = getConnectionId(deps.worktreeId ?? null) ?? undefined
- // Why: remote paths don't need local auth — the relay is the security boundary.
- if (!connectionId) {
+ const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
+ const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath)
+ // Why: remote paths don't need local auth — the relay/runtime is the security boundary.
+ if (!fileContext.connectionId && !isRemoteRuntimePath) {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
- statResult = await window.api.fs.stat({ filePath, connectionId })
+ statResult = await statRuntimePath(fileContext, filePath)
} catch {
return
}
+ if (requestId !== latestOpenDetectedFilePathRequestId) {
+ return
+ }
+
if (statResult.isDirectory) {
+ const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
+ if (fileContext.connectionId || isRemoteRuntimeFileOperation(fileContext, filePath)) {
+ return
+ }
await window.api.shell.openFilePath(filePath)
return
}
@@ -94,7 +129,12 @@ export function openDetectedFilePath(
// as source in Monaco — ⌘/Ctrl+click on an HTML path in the terminal should
// feel like clicking an http link and render the page, not dump HTML source.
// Mirrors the editor's "Open Preview to the Side" action.
- if (isHtmlFilePath(filePath)) {
+ const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
+ if (
+ isHtmlFilePath(filePath) &&
+ !fileContext.connectionId &&
+ !isRemoteRuntimeFileOperation(fileContext, filePath)
+ ) {
openHtmlFileInBrowser(filePath, worktreeId)
return
}
@@ -120,15 +160,27 @@ export function openDetectedFilePath(
relativePath,
worktreeId: worktreeId || '',
language: detectLanguage(filePath),
- mode: 'edit'
+ mode: 'edit',
+ runtimeEnvironmentId: runtimeEnvironmentId ?? undefined
})
if (line !== null) {
+ const targetColumn = column ?? 1
+ store.setPendingEditorReveal(null)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
+ if (requestId !== latestOpenDetectedFilePathRequestId) {
+ return
+ }
+ store.setPendingEditorReveal({
+ filePath,
+ line,
+ column: targetColumn,
+ matchLength: 0
+ })
window.dispatchEvent(
new CustomEvent('orca:editor-reveal-location', {
- detail: { filePath, line, column }
+ detail: { filePath, line, column: targetColumn }
})
)
})
@@ -172,9 +224,18 @@ export function createFilePathLinkProvider(
return null
}
- const cachedExists = pathExistsCache.get(resolved.absolutePath)
- const exists = cachedExists ?? (await window.api.shell.pathExists(resolved.absolutePath))
- pathExistsCache.set(resolved.absolutePath, exists)
+ const runtimeEnvironmentId =
+ deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null
+ const cacheKey = `${runtimeEnvironmentId ?? 'active'}\0${resolved.absolutePath}`
+ const cachedExists = pathExistsCache.get(cacheKey)
+ const fileContext = getTerminalFileContext(worktreeId, worktreePath, runtimeEnvironmentId)
+ const exists =
+ cachedExists ??
+ (fileContext.connectionId ||
+ isRemoteRuntimeFileOperation(fileContext, resolved.absolutePath)
+ ? await runtimePathExists(fileContext, resolved.absolutePath)
+ : await window.api.shell.pathExists(resolved.absolutePath))
+ pathExistsCache.set(cacheKey, exists)
if (!exists) {
return null
}
@@ -197,7 +258,8 @@ export function createFilePathLinkProvider(
}
openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, {
worktreeId,
- worktreePath
+ worktreePath,
+ runtimeEnvironmentId
})
},
hover: () => {
@@ -235,7 +297,7 @@ export function handleOscLink(
rawText: string,
event: TerminalLinkEvent | undefined,
deps: Pick &
- Partial>
+ Partial>
): void {
if (!isTerminalLinkActivation(event)) {
return
diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts
index 87a5a3ad394..e9608dcd120 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts
@@ -1,6 +1,8 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
+const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const
+
const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn()
}))
@@ -40,8 +42,11 @@ beforeEach(() => {
mocks.flushTerminalOutput.mockReset()
})
-function mockRootForPane(paneId: number): HTMLDivElement {
- const pane = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: String(paneId) } })
+function mockRootForPane(paneId: number, leafId: string = LEAF_ID): HTMLDivElement {
+ const pane = new MockHTMLElement({
+ classList: ['pane'],
+ dataset: { paneId: String(paneId), leafId }
+ })
return new MockHTMLElement({ firstElementChild: pane }) as unknown as HTMLDivElement
}
@@ -55,6 +60,8 @@ describe('captureTerminalShutdownLayout', () => {
}
const pane = {
id: 1,
+ leafId: LEAF_ID,
+ stablePaneId: LEAF_ID,
terminal,
serializeAddon: {
serialize: vi.fn(() => {
@@ -75,7 +82,7 @@ describe('captureTerminalShutdownLayout', () => {
const layout = captureTerminalShutdownLayout({
manager: manager as never,
- container: mockRootForPane(1),
+ container: mockRootForPane(1, LEAF_ID),
expandedPaneId: null,
paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
paneTitlesByPaneId: { 1: 'build logs' },
@@ -84,12 +91,12 @@ describe('captureTerminalShutdownLayout', () => {
expect(order).toEqual(['flush', 'serialize'])
expect(layout).toMatchObject({
- root: { type: 'leaf', leafId: 'pane:1' },
- activeLeafId: 'pane:1',
+ root: { type: 'leaf', leafId: LEAF_ID },
+ activeLeafId: LEAF_ID,
expandedLeafId: null,
- buffersByLeafId: { 'pane:1': 'snapshot:queued-before-quit' },
- ptyIdsByLeafId: { 'pane:1': 'pty-1' },
- titlesByLeafId: { 'pane:1': 'build logs' }
+ buffersByLeafId: { [LEAF_ID]: 'snapshot:queued-before-quit' },
+ ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' },
+ titlesByLeafId: { [LEAF_ID]: 'build logs' }
})
})
@@ -97,6 +104,8 @@ describe('captureTerminalShutdownLayout', () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const pane = {
id: 1,
+ leafId: LEAF_ID,
+ stablePaneId: LEAF_ID,
terminal: { options: { scrollback: 50_000 } },
serializeAddon: {
serialize: vi.fn(() => 'x'.repeat(512 * 1024))
@@ -117,7 +126,7 @@ describe('captureTerminalShutdownLayout', () => {
root: null,
activeLeafId: null,
expandedLeafId: null,
- buffersByLeafId: { 'pane:1': 'previous-local-scrollback' }
+ buffersByLeafId: { [LEAF_ID]: 'previous-local-scrollback' }
},
captureBuffers: false
})
@@ -125,7 +134,7 @@ describe('captureTerminalShutdownLayout', () => {
expect(mocks.flushTerminalOutput).not.toHaveBeenCalled()
expect(pane.serializeAddon.serialize).not.toHaveBeenCalled()
expect(layout.buffersByLeafId).toBeUndefined()
- expect(layout.ptyIdsByLeafId).toEqual({ 'pane:1': 'pty-1' })
- expect(layout.titlesByLeafId).toEqual({ 'pane:1': 'local shell' })
+ expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' })
+ expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' })
})
})
diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts
index 67f9d9bb4c7..709af1fee8b 100644
--- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts
@@ -2,12 +2,12 @@ import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
-import { paneLeafId, serializeTerminalLayout } from './layout-serialization'
+import { serializeTerminalLayout } from './layout-serialization'
import { mergeCapturedLeafState } from './merge-captured-leaf-state'
const MAX_BUFFER_BYTES = 512 * 1024
-type ShutdownPane = Pick
+type ShutdownPane = Pick
type ShutdownPaneManager = {
getPanes(): ShutdownPane[]
@@ -42,7 +42,7 @@ export function captureTerminalShutdownLayout({
// Why: non-focused panes may have renderer-throttled PTY bytes queued;
// push them into xterm before taking the shutdown scrollback snapshot.
flushTerminalOutput(pane.terminal)
- const leafId = paneLeafId(pane.id)
+ const leafId = pane.leafId
let scrollback = pane.terminal.options.scrollback ?? 10_000
let serialized = pane.serializeAddon.serialize({ scrollback })
// Cap at 512KB — binary search for largest scrollback that fits.
@@ -72,11 +72,16 @@ export function captureTerminalShutdownLayout({
}
const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null
- const layout = serializeTerminalLayout(container, activePaneId, expandedPaneId)
- const currentLeafIds = new Set(panes.map((p) => paneLeafId(p.id)))
+ const layout = serializeTerminalLayout(
+ container,
+ activePaneId,
+ expandedPaneId,
+ new Map(panes.map((pane) => [pane.id, pane.leafId]))
+ )
+ const currentLeafIds = new Set(panes.map((p) => p.leafId))
const ptyEntries = panes
- .map((pane) => [paneLeafId(pane.id), paneTransports.get(pane.id)?.getPtyId() ?? null] as const)
- .filter((entry): entry is readonly [string, string] => entry[1] !== null)
+ .map((pane) => [pane.leafId, paneTransports.get(pane.id)?.getPtyId() ?? null] as const)
+ .filter((entry): entry is readonly [ShutdownPane['leafId'], string] => entry[1] !== null)
const mergedBuffers = captureBuffers
? mergeCapturedLeafState({
@@ -99,7 +104,7 @@ export function captureTerminalShutdownLayout({
const titleEntries = panes
.filter((p) => paneTitlesByPaneId[p.id])
- .map((p) => [paneLeafId(p.id), paneTitlesByPaneId[p.id]] as const)
+ .map((p) => [p.leafId, paneTitlesByPaneId[p.id]] as const)
if (titleEntries.length > 0) {
layout.titlesByLeafId = Object.fromEntries(titleEntries)
}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
index c418419213e..e265ca980b6 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
@@ -30,6 +30,7 @@ type TerminalMenuState = {
onPaste: () => Promise
onSplitRight: () => void
onSplitDown: () => void
+ onEqualizePaneSizes: () => void
onClosePane: () => void
onClearScreen: () => void
onQuickCommand: (command: TerminalQuickCommand) => void
@@ -148,6 +149,16 @@ export function useTerminalPaneContextMenu({
const onSplitRight = (): void => splitWithInheritedCwd('vertical')
const onSplitDown = (): void => splitWithInheritedCwd('horizontal')
+ const onEqualizePaneSizes = (): void => {
+ const pane = resolveMenuPane()
+ const manager = managerRef.current
+ if (!pane || !manager) {
+ return
+ }
+ manager.equalizePaneSizes()
+ pane.terminal.focus()
+ }
+
const onClosePane = (): void => {
const pane = resolveMenuPane()
if (pane && (managerRef.current?.getPanes().length ?? 0) > 1) {
@@ -241,6 +252,7 @@ export function useTerminalPaneContextMenu({
onPaste,
onSplitRight,
onSplitDown,
+ onEqualizePaneSizes,
onClosePane,
onClearScreen,
onQuickCommand,
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
index d739d472779..c2ecd12cef1 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
@@ -12,6 +12,9 @@ import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import type { PtyTransport } from './pty-transport'
import { handleTerminalFileDrop } from './terminal-drop-handler'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
+import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event'
+import { surfaceStaleAgentRow } from './stale-agent-row'
+import { useAppStore } from '@/store'
type UseTerminalPaneGlobalEffectsArgs = {
tabId: string
@@ -114,18 +117,12 @@ export function useTerminalPaneGlobalEffects({
useEffect(() => {
const onFocusPane = (event: Event): void => {
const detail = (event as CustomEvent).detail
- if (!detail?.tabId || detail.tabId !== tabId) {
- return
- }
- const manager = managerRef.current
- if (!manager) {
- return
- }
- const pane = manager.getPanes().find((candidate) => candidate.id === detail.paneId)
- if (!pane) {
- return
- }
- manager.setActivePane(pane.id, { focus: true })
+ handleFocusTerminalPaneDetail(detail, {
+ tabId,
+ manager: managerRef.current,
+ acknowledgeAgents: (paneKeys) => useAppStore.getState().acknowledgeAgents(paneKeys),
+ surfaceStaleAgentRow
+ })
}
window.addEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane)
return () => window.removeEventListener(FOCUS_TERMINAL_PANE_EVENT, onFocusPane)
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index 5823891ad7f..20ab6b285b0 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -19,10 +19,11 @@ import type { EventProps } from '../../../../shared/telemetry-events'
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
import {
buildFontFamily,
- collectLeafIdsInReplayCreationOrder,
+ normalizeTerminalLayoutSnapshot,
replayTerminalLayout,
restoreScrollbackBuffers
} from './layout-serialization'
+import { makePaneKey } from '../../../../shared/stable-pane-id'
import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse'
import {
applyTerminalAppearance,
@@ -38,6 +39,7 @@ import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-optio
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
import type { PtyTransport } from './pty-transport'
+import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
@@ -296,7 +298,11 @@ export function useTerminalPaneLifecycle({
startupCwd,
managerRef,
linkProviderDisposablesRef,
- pathExistsCache
+ pathExistsCache,
+ getRuntimeEnvironmentIdForPane: (paneId) => {
+ const ptyId = paneTransportsRef.current.get(paneId)?.getPtyId()
+ return ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null
+ }
}
let resizeRaf: number | null = null
const queueResizeAll = (focusActive: boolean): void => {
@@ -330,11 +336,12 @@ export function useTerminalPaneLifecycle({
setPaneCount(managerRef.current?.getPanes().length ?? 0)
}
+ const normalizedInitialLayout = normalizeTerminalLayoutSnapshot(initialLayoutRef.current)
+ if (normalizedInitialLayout.changed) {
+ initialLayoutRef.current = normalizedInitialLayout.snapshot
+ useAppStore.getState().setTabLayout(tabId, normalizedInitialLayout.snapshot)
+ }
let shouldPersistLayout = false
- const restoredLeafIdsInCreationOrder = collectLeafIdsInReplayCreationOrder(
- initialLayoutRef.current.root
- )
- let restoredPaneCreateIndex = 0
const ptyDeps = {
tabId,
worktreeId,
@@ -488,7 +495,10 @@ export function useTerminalPaneLifecycle({
pane.terminal.options.linkHandler = {
allowNonHttpProtocols: true,
activate: (event, text) => {
- handleOscLink(text, event as MouseEvent | undefined, linkDeps)
+ handleOscLink(text, event as MouseEvent | undefined, {
+ ...linkDeps,
+ runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null
+ })
// Why: Cmd/Ctrl+clicking a link activates Orca handling (open file,
// new browser tab, system browser) which can steal focus from the
// terminal before the click's mouseup reaches ownerDocument. Without
@@ -511,15 +521,13 @@ export function useTerminalPaneLifecycle({
}
}
applyAppearance(manager)
- const restoredLeafId = restoredLeafIdsInCreationOrder[restoredPaneCreateIndex] ?? null
- restoredPaneCreateIndex += 1
const panePtyBinding = connectPanePty(pane, manager, {
...ptyDeps,
// Why: spread order matters — spawnHints.cwd (inherited from the
// source pane) must override the tab-level ptyDeps.cwd (worktree
// root) so Cmd+D splits boot in the live cwd.
...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}),
- restoredLeafId
+ restoredLeafId: pane.leafId
})
// Why: connectPanePty receives a spread copy of ptyDeps, so the
// `deps.startup = undefined` it performs internally only clears its
@@ -538,7 +546,7 @@ export function useTerminalPaneLifecycle({
scheduleRuntimeGraphSync()
queueResizeAll(true)
},
- onPaneClosed: (paneId) => {
+ onPaneClosed: (paneId, closedPane) => {
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
if (linkProviderDisposable) {
linkProviderDisposable.dispose()
@@ -592,7 +600,12 @@ export function useTerminalPaneLifecycle({
// (not remove) so any retained `done` snapshot for this pane is also
// cleared and a same-frame live→gone transition cannot re-snapshot
// it via the retention sync.
- useAppStore.getState().dropAgentStatus(`${tabId}:${paneId}`)
+ const leafId = closedPane?.leafId
+ if (leafId) {
+ const paneKey = makePaneKey(tabId, leafId)
+ useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
+ useAppStore.getState().dropAgentStatus(paneKey)
+ }
transport.destroy?.()
paneTransportsRef.current.delete(paneId)
}
@@ -685,7 +698,13 @@ export function useTerminalPaneLifecycle({
if (!event) {
return
}
- void handleOscLink(url, event, linkDeps)
+ const activePane = managerRef.current?.getActivePane()
+ void handleOscLink(url, event, {
+ ...linkDeps,
+ runtimeEnvironmentId: activePane
+ ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
+ : null
+ })
// Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus
// away from the terminal before the click's mouseup reaches
// ownerDocument. That leaves xterm's SelectionService drag-select
diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts
index 1fcd48d62f7..af86198f317 100644
--- a/src/renderer/src/components/terminal/tab-type-cycle.ts
+++ b/src/renderer/src/components/terminal/tab-type-cycle.ts
@@ -1,4 +1,4 @@
-export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'notes'
+export type TabCycleType = 'terminal' | 'editor' | 'browser'
export type TypeCyclableTab = {
type: TabCycleType
diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx
index d87333920c9..d73a70461d4 100644
--- a/src/renderer/src/components/ui/button.tsx
+++ b/src/renderer/src/components/ui/button.tsx
@@ -36,20 +36,21 @@ const buttonVariants = cva(
}
)
-function Button({
- className,
- variant = 'default',
- size = 'default',
- asChild = false,
- ...props
-}: React.ComponentProps<'button'> &
- VariantProps & {
- asChild?: boolean
- }) {
+const Button = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }
+>(function Button(
+ { className, variant = 'default', size = 'default', asChild = false, ...props },
+ ref
+) {
const Comp = asChild ? Slot.Root : 'button'
return (
)
-}
+})
export { Button, buttonVariants }
diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx
index cd3dad0ba98..0861a7d57f7 100644
--- a/src/renderer/src/components/ui/dropdown-menu.tsx
+++ b/src/renderer/src/components/ui/dropdown-menu.tsx
@@ -32,7 +32,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
- 'relative z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
+ 'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
// Why: Electron's -webkit-app-region: drag on the titlebar captures
@@ -209,11 +209,11 @@ function DropdownMenuSubContent({
diff --git a/src/renderer/src/components/ui/sheet.tsx b/src/renderer/src/components/ui/sheet.tsx
index 1d26d4ac3ce..3e734b9294c 100644
--- a/src/renderer/src/components/ui/sheet.tsx
+++ b/src/renderer/src/components/ui/sheet.tsx
@@ -68,15 +68,19 @@ function SheetContent({
children,
side = 'right',
showCloseButton = true,
+ overlayClassName,
+ overlayStyle,
style,
...props
}: React.ComponentProps &
VariantProps & {
showCloseButton?: boolean
+ overlayClassName?: string
+ overlayStyle?: React.CSSProperties
}) {
return (
-
+
= {
+ ready: 'Suggested cleanup',
+ review: 'Needs a closer look',
+ protected: 'Not suggested for cleanup'
+}
+
+type CleanupView = WorkspaceCleanupTier | 'hidden'
+
+const BLOCKER_LABELS: Record = {
+ 'main-worktree': 'Main workspace',
+ 'folder-repo': 'Folder project',
+ pinned: 'Pinned',
+ 'active-workspace': 'Active workspace',
+ 'running-terminal': 'Running terminal process',
+ 'terminal-liveness-unknown': 'Terminal liveness unknown',
+ 'dirty-editor-buffer': 'Unsaved editor buffer',
+ 'volatile-local-context': 'Volatile local context',
+ 'recent-visible-context': 'Recently visited tabs',
+ 'live-agent': 'Active agent',
+ 'ssh-disconnected': 'Remote unavailable',
+ 'git-status-error': 'Git status unavailable',
+ 'dirty-files': 'Changed files',
+ 'unpushed-commits': 'Unpushed commits',
+ 'unknown-base': 'Could not verify unpushed commits',
+ dismissed: 'Ignored'
+}
+
+function formatRelativeTime(timestamp: number): string {
+ if (!timestamp) {
+ return 'Never'
+ }
+ const deltaMs = Date.now() - timestamp
+ if (deltaMs < 60_000) {
+ return 'Just now'
+ }
+ const minutes = Math.floor(deltaMs / 60_000)
+ if (minutes < 60) {
+ return `${minutes}m ago`
+ }
+ const hours = Math.floor(minutes / 60)
+ if (hours < 48) {
+ return `${hours}h ago`
+ }
+ return `${Math.floor(hours / 24)}d ago`
+}
+
+function isDisconnectedRemoteScanError(message: string): boolean {
+ return (
+ message === 'SSH provider is unavailable.' ||
+ message === 'Remote workspaces are not connected. Reconnect and refresh to check them.'
+ )
+}
+
+function formatScanNoticeMessage(
+ errors: WorkspaceCleanupScanError[],
+ repoNameById: Map
+): string | null {
+ const visibleErrors = errors.filter(
+ (error) => !isDisconnectedRemoteScanError(error.message ?? '')
+ )
+ if (visibleErrors.length === 0) {
+ return null
+ }
+ if (visibleErrors.length === 1) {
+ const error = visibleErrors[0]
+ const repoName = formatScanErrorRepoName(error, repoNameById)
+ return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some old workspaces may be missing. Refresh to try again.`
+ }
+ const repoNames = visibleErrors
+ .slice(0, 3)
+ .map((error) => formatScanErrorRepoName(error, repoNameById))
+ .join(', ')
+ const moreCount = visibleErrors.length - 3
+ const suffix = moreCount > 0 ? `, +${moreCount} more` : ''
+ return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some old workspaces may be missing. Refresh to try again.`
+}
+
+function formatScanErrorRepoName(
+ error: Partial,
+ repoNameById: Map
+): string {
+ const repoName = error.repoName?.trim()
+ if (repoName) {
+ return repoName
+ }
+ const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : ''
+ return fallback || 'a repository'
+}
+
+function formatScanErrorReason(message: string | undefined): string {
+ if (!message) {
+ return 'Git could not list worktrees'
+ }
+ if (message === 'Could not scan workspace cleanup for this repository.') {
+ return 'Git could not list worktrees'
+ }
+ return message.replace(/\.$/, '')
+}
+
+function isOldWorkspaceCandidate(candidate: WorkspaceCleanupCandidate): boolean {
+ if (candidate.blockers.includes('main-worktree') || candidate.blockers.includes('folder-repo')) {
+ return false
+ }
+ return candidate.reasons.includes('archived') || candidate.reasons.includes('idle-clean')
+}
+
+function compareCleanupCandidates(
+ a: WorkspaceCleanupCandidate,
+ b: WorkspaceCleanupCandidate
+): number {
+ const priorityA = getCleanupCandidatePriority(a)
+ const priorityB = getCleanupCandidatePriority(b)
+ if (priorityA !== priorityB) {
+ return priorityA - priorityB
+ }
+ return a.lastActivityAt - b.lastActivityAt
+}
+
+function getCleanupCandidatePriority(candidate: WorkspaceCleanupCandidate): number {
+ if (candidate.tier === 'ready') {
+ return 0
+ }
+ if (candidate.reasons.length > 0) {
+ return 1
+ }
+ if (isOldWorkspaceCandidate(candidate)) {
+ return 2
+ }
+ return 3
+}
+
+export default function WorkspaceCleanupDialog(): React.JSX.Element {
+ const activeModal = useAppStore((s) => s.activeModal)
+ const closeModal = useAppStore((s) => s.closeModal)
+ const scan = useAppStore((s) => s.workspaceCleanupScan)
+ const loading = useAppStore((s) => s.workspaceCleanupLoading)
+ const error = useAppStore((s) => s.workspaceCleanupError)
+ const repos = useAppStore((s) => s.repos)
+ const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup)
+ const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed)
+ const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates)
+ const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals)
+ const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates)
+
+ const open = activeModal === 'workspace-cleanup'
+ const [selectedIds, setSelectedIds] = useState>(() => new Set())
+ const [activeView, setActiveView] = useState('ready')
+ const [confirming, setConfirming] = useState(false)
+ const [removing, setRemoving] = useState(false)
+ const [rowFailures, setRowFailures] = useState>({})
+
+ useEffect(() => {
+ if (open) {
+ setRowFailures({})
+ setActiveView('ready')
+ void scanWorkspaceCleanup().catch((err: unknown) => {
+ toast.error('Workspace cleanup scan failed', {
+ description: err instanceof Error ? err.message : String(err)
+ })
+ })
+ }
+ }, [open, scanWorkspaceCleanup])
+
+ const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates])
+
+ useEffect(() => {
+ if (!open || !scan) {
+ return
+ }
+ setSelectedIds(
+ new Set(
+ candidates
+ .filter((candidate) => candidate.selectedByDefault)
+ .map((candidate) => candidate.worktreeId)
+ )
+ )
+ setConfirming(false)
+ }, [open, scan, scan?.scannedAt, candidates])
+
+ const visibleCandidates = useMemo(() => {
+ const rows = candidates.filter((candidate) => !candidate.blockers.includes('dismissed'))
+ return [...rows].sort(compareCleanupCandidates)
+ }, [candidates])
+ const hiddenCandidates = useMemo(
+ () =>
+ candidates
+ .filter((candidate) => candidate.blockers.includes('dismissed'))
+ .sort(compareCleanupCandidates),
+ [candidates]
+ )
+ const groups = useMemo(
+ () => ({
+ ready: visibleCandidates.filter((candidate) => candidate.tier === 'ready'),
+ review: visibleCandidates.filter((candidate) => candidate.tier === 'review'),
+ protected: visibleCandidates.filter((candidate) => candidate.tier === 'protected')
+ }),
+ [visibleCandidates]
+ )
+ const selectedCandidates = useMemo(() => {
+ const byId = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate]))
+ return [...selectedIds]
+ .map((id) => byId.get(id))
+ .filter(
+ (candidate): candidate is WorkspaceCleanupCandidate =>
+ candidate != null && canQueueWorkspaceCleanupCandidate(candidate)
+ )
+ }, [candidates, selectedIds])
+
+ const hiddenByKeepCount = candidates.filter((candidate) =>
+ candidate.blockers.includes('dismissed')
+ ).length
+ const repoNameById = useMemo(
+ () => new Map(repos.map((repo) => [repo.id, repo.displayName || repo.path])),
+ [repos]
+ )
+ const scanNoticeMessage = useMemo(
+ () => formatScanNoticeMessage(scan?.errors ?? [], repoNameById),
+ [repoNameById, scan?.errors]
+ )
+ const readyCount = groups.ready.length
+ const oldCandidateCount = useMemo(
+ () => candidates.filter(isOldWorkspaceCandidate).length,
+ [candidates]
+ )
+ const initialLoading = loading && !scan
+ const activeRows = activeView === 'hidden' ? hiddenCandidates : groups[activeView]
+ const activeQueueableRows = useMemo(
+ () => activeRows.filter(canQueueWorkspaceCleanupCandidate),
+ [activeRows]
+ )
+ const activeQueueableSelected = useMemo(
+ () => activeQueueableRows.filter((candidate) => selectedIds.has(candidate.worktreeId)).length,
+ [activeQueueableRows, selectedIds]
+ )
+ const allActiveQueueableSelected =
+ activeQueueableRows.length > 0 && activeQueueableSelected === activeQueueableRows.length
+ const someActiveQueueableSelected = activeQueueableSelected > 0
+ const activeSelectionState = allActiveQueueableSelected
+ ? 'checked'
+ : someActiveQueueableSelected
+ ? 'mixed'
+ : 'unchecked'
+
+ useEffect(() => {
+ if (!open || loading || !scan) {
+ return
+ }
+ if (activeRows.length > 0) {
+ return
+ }
+ if (readyCount > 0) {
+ setActiveView('ready')
+ } else if (groups.review.length > 0) {
+ setActiveView('review')
+ } else if (groups.protected.length > 0) {
+ setActiveView('protected')
+ } else if (hiddenCandidates.length > 0) {
+ setActiveView('hidden')
+ }
+ }, [
+ activeRows.length,
+ groups.protected.length,
+ groups.review.length,
+ hiddenCandidates.length,
+ loading,
+ open,
+ readyCount,
+ scan
+ ])
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (!nextOpen && !removing) {
+ closeModal()
+ }
+ },
+ [closeModal, removing]
+ )
+
+ const refresh = useCallback(() => {
+ setRowFailures({})
+ void scanWorkspaceCleanup().catch((err: unknown) => {
+ toast.error('Workspace cleanup scan failed', {
+ description: err instanceof Error ? err.message : String(err)
+ })
+ })
+ }, [scanWorkspaceCleanup])
+
+ const toggleActiveSelection = useCallback(() => {
+ if (activeQueueableRows.length === 0) {
+ return
+ }
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ if (allActiveQueueableSelected) {
+ for (const candidate of activeQueueableRows) {
+ next.delete(candidate.worktreeId)
+ }
+ } else {
+ for (const candidate of activeQueueableRows) {
+ next.add(candidate.worktreeId)
+ }
+ }
+ return next
+ })
+ }, [activeQueueableRows, allActiveQueueableSelected])
+
+ const ignoreCandidate = useCallback(
+ (candidate: WorkspaceCleanupCandidate) => {
+ void dismissCandidates([candidate])
+ .then(() => {
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ next.delete(candidate.worktreeId)
+ return next
+ })
+ })
+ .catch((err: unknown) => {
+ toast.error('Could not ignore cleanup suggestion', {
+ description: err instanceof Error ? err.message : String(err)
+ })
+ })
+ },
+ [dismissCandidates]
+ )
+
+ const confirmRemove = useCallback(async () => {
+ if (selectedCandidates.length === 0) {
+ return
+ }
+ setRemoving(true)
+ setRowFailures({})
+ try {
+ const result = await removeCandidates(
+ selectedCandidates.map((candidate) => candidate.worktreeId)
+ )
+ const nextFailures: Record = {}
+ for (const failure of result.failures) {
+ nextFailures[failure.worktreeId] = failure.message
+ }
+ setRowFailures(nextFailures)
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ for (const id of result.removedIds) {
+ next.delete(id)
+ }
+ return next
+ })
+ if (result.removedIds.length > 0) {
+ toast.success(
+ `Removed ${result.removedIds.length} workspace${result.removedIds.length === 1 ? '' : 's'}`
+ )
+ }
+ if (result.failures.length > 0) {
+ toast.error(
+ `${result.failures.length} workspace${result.failures.length === 1 ? '' : 's'} could not be removed`
+ )
+ } else {
+ setConfirming(false)
+ }
+ } finally {
+ setRemoving(false)
+ }
+ }, [removeCandidates, selectedCandidates])
+
+ const selectedCount = selectedCandidates.length
+
+ return (
+
+
+ {!confirming ? (
+ <>
+
+
+
+ Clean Up Old Workspaces
+
+ Review old workspaces before deleting their local files and Orca state.
+
+
+
+
+
+
+
+
+
+
+ Refresh
+
+
+ closeModal()}
+ disabled={removing}
+ >
+
+
+
+
+
+
+ {initialLoading ? (
+
+
+ Checking old workspaces
+
+ ) : oldCandidateCount > 0 || hiddenByKeepCount > 0 ? (
+
+
+
+ {selectedCount} selected
+
+ {readyCount > 0 ? (
+
{readyCount} safe to remove
+ ) : null}
+ {groups.review.length > 0 ? (
+
{groups.review.length} need review
+ ) : null}
+
+
+ setConfirming(true)}
+ disabled={selectedCount === 0}
+ >
+
+ Remove selected
+
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : scanNoticeMessage ? (
+
+
+
{scanNoticeMessage}
+
+ ) : null}
+
+
+
+
+
+
+ {activeView !== 'hidden' && activeQueueableRows.length > 0 ? (
+
+ {activeSelectionState === 'checked' ? (
+
+ ) : activeSelectionState === 'mixed' ? (
+
+ ) : null}
+
+ ) : null}
+
+ {activeView === 'hidden'
+ ? 'Ignored cleanup suggestions'
+ : TIER_LABELS[activeView]}
+
+
+ {activeView === 'hidden' && hiddenByKeepCount > 0 ? (
+
void resetDismissals()}
+ >
+ Restore ignored suggestions
+
+ ) : (
+
+ Sorted by oldest activity
+
+ )}
+
+
+
+ {initialLoading ? : null}
+ {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? (
+
+ ) : null}
+ {!loading && scan && candidates.length === 0 && scanNoticeMessage ? (
+
+ ) : null}
+ {!loading && scan && candidates.length > 0 && visibleCandidates.length === 0 ? (
+ setActiveView('hidden')}
+ />
+ ) : null}
+ {!loading && scan && activeRows.length === 0 && visibleCandidates.length > 0 ? (
+
+ ) : null}
+ {activeRows.map((candidate, index) => (
+
+ setSelectedIds((current) => toggleSetMember(current, id))
+ }
+ onView={closeAndView}
+ onIgnore={ignoreCandidate}
+ onRemove={(candidate) => {
+ setSelectedIds(new Set([candidate.worktreeId]))
+ setConfirming(true)
+ }}
+ />
+ ))}
+
+
+
+
+ >
+ ) : (
+ setConfirming(false)}
+ onConfirm={() => void confirmRemove()}
+ />
+ )}
+
+
+ )
+
+ function closeAndView(candidate: WorkspaceCleanupCandidate): void {
+ markCandidateViewed(candidate)
+ closeModal()
+ activateAndRevealWorktree(candidate.worktreeId)
+ }
+}
+
+function StatusPill({
+ children,
+ tone = 'neutral'
+}: {
+ children: React.ReactNode
+ tone?: 'neutral' | 'ready' | 'review' | 'destructive'
+}): React.JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
+
+function CleanupViewNav({
+ activeView,
+ counts,
+ onViewChange
+}: {
+ activeView: CleanupView
+ counts: Record
+ onViewChange: (view: CleanupView) => void
+}): React.JSX.Element {
+ const items: { view: CleanupView; label: string }[] = [
+ { view: 'ready', label: 'Suggested' },
+ { view: 'review', label: 'Needs review' },
+ { view: 'protected', label: 'Not suggested' },
+ { view: 'hidden', label: 'Ignored' }
+ ]
+
+ return (
+
+
+ {items.map((item) => (
+ onViewChange(item.view)}
+ >
+ {item.label}
+ {counts[item.view]}
+
+ ))}
+
+
+ )
+}
+
+function CandidateRow({
+ candidate,
+ last,
+ selected,
+ failure,
+ onToggleSelected,
+ onView,
+ onIgnore,
+ onRemove
+}: {
+ candidate: WorkspaceCleanupCandidate
+ last: boolean
+ selected: boolean
+ failure?: string
+ onToggleSelected: (worktreeId: string) => void
+ onView: (candidate: WorkspaceCleanupCandidate) => void
+ onIgnore: (candidate: WorkspaceCleanupCandidate) => void
+ onRemove: (candidate: WorkspaceCleanupCandidate) => void
+}): React.JSX.Element {
+ const selectable = canQueueWorkspaceCleanupCandidate(candidate)
+ const ignored = candidate.blockers.includes('dismissed')
+ const blockers = candidate.blockers.map((blocker) => BLOCKER_LABELS[blocker])
+ const contextDetails = formatContextDetails(candidate)
+ const branchSafetyDetails = formatBranchSafetyDetails(candidate)
+ const status = getCandidateStatus(candidate)
+
+ return (
+
+
+ {selectable ? (
+
onToggleSelected(candidate.worktreeId)}
+ className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent"
+ >
+ {selected ? : null}
+
+ ) : (
+
+ )}
+
+
+ {candidate.displayName}
+ {status.label}
+
+ Last active {formatRelativeTime(candidate.lastActivityAt)}
+
+ {blockers.length > 0 ? (
+
+ {blockers.slice(0, 2).join(', ')}
+
+ ) : null}
+
+
+ {candidate.path}
+
+
+ Repo {candidate.repoName}
+ Branch {candidate.branch}
+ {formatGitStatus(candidate)}
+ {branchSafetyDetails.slice(0, 1).map((detail) => (
+ {detail}
+ ))}
+ {contextDetails ? {contextDetails} : null}
+
+ {failure ? (
+
+ ) : null}
+
+
+
+
+ onView(candidate)}
+ >
+
+
+
+
+ View
+
+
+ {!ignored ? (
+
+
+ onIgnore(candidate)}
+ >
+
+
+
+
+ Ignore
+
+
+ ) : null}
+ {selectable ? (
+
+
+ onRemove(candidate)}
+ >
+
+
+
+
+ Remove
+
+
+ ) : null}
+
+
+
+ )
+}
+
+function getCandidateStatus(candidate: WorkspaceCleanupCandidate): {
+ label: string
+ tone: 'neutral' | 'ready' | 'review' | 'destructive'
+} {
+ if (candidate.blockers.includes('dismissed')) {
+ return { label: 'Ignored', tone: 'neutral' }
+ }
+ if (candidate.tier === 'ready') {
+ return { label: candidate.reasons.includes('archived') ? 'Archived' : 'Clean', tone: 'ready' }
+ }
+ if (candidate.blockers.length > 0) {
+ return { label: BLOCKER_LABELS[candidate.blockers[0]], tone: 'neutral' }
+ }
+ if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) {
+ return { label: 'Unpushed commits', tone: 'review' }
+ }
+ if (candidate.git.clean === false) {
+ return { label: 'Dirty', tone: 'review' }
+ }
+ if (candidate.tier === 'review') {
+ return { label: 'Review', tone: 'review' }
+ }
+ return { label: 'Not suggested', tone: 'neutral' }
+}
+
+function formatGitStatus(candidate: WorkspaceCleanupCandidate): string {
+ if (candidate.git.clean === true) {
+ return 'Clean git'
+ }
+ if (candidate.git.clean === false) {
+ return 'Dirty git'
+ }
+ return 'Git unknown'
+}
+
+function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] {
+ const details: string[] = []
+ if (candidate.git.upstreamAhead !== null) {
+ details.push(
+ candidate.git.upstreamAhead === 0
+ ? 'No unpushed commits'
+ : `${candidate.git.upstreamAhead} unpushed commit${
+ candidate.git.upstreamAhead === 1 ? '' : 's'
+ }`
+ )
+ }
+ return details
+}
+
+function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null {
+ const parts: string[] = []
+ if (candidate.localContext.terminalTabCount > 0) {
+ parts.push(
+ `${candidate.localContext.terminalTabCount} terminal tab${
+ candidate.localContext.terminalTabCount === 1 ? '' : 's'
+ }`
+ )
+ }
+ if (candidate.localContext.cleanEditorTabCount > 0) {
+ parts.push(
+ `${candidate.localContext.cleanEditorTabCount} editor tab${
+ candidate.localContext.cleanEditorTabCount === 1 ? '' : 's'
+ }`
+ )
+ }
+ if (candidate.localContext.browserTabCount > 0) {
+ parts.push(
+ `${candidate.localContext.browserTabCount} browser tab${
+ candidate.localContext.browserTabCount === 1 ? '' : 's'
+ }`
+ )
+ }
+ if (candidate.localContext.diffCommentCount > 0) {
+ parts.push(
+ `${candidate.localContext.diffCommentCount} diff note${
+ candidate.localContext.diffCommentCount === 1 ? '' : 's'
+ }`
+ )
+ }
+ if (candidate.localContext.retainedDoneAgentCount > 0) {
+ parts.push(
+ `${candidate.localContext.retainedDoneAgentCount} completed agent${
+ candidate.localContext.retainedDoneAgentCount === 1 ? '' : 's'
+ }`
+ )
+ }
+ return parts.length > 0 ? parts.join(', ') : null
+}
+
+function ConfirmRemove({
+ count,
+ removing,
+ onCancel,
+ onConfirm
+}: {
+ count: number
+ removing: boolean
+ onCancel: () => void
+ onConfirm: () => void
+}): React.JSX.Element {
+ return (
+ <>
+
+
+ Remove {count} workspace{count === 1 ? '' : 's'}?
+
+
+ Removing a workspace deletes its working tree folder, local Orca metadata, terminal
+ history, browser workspace state, and the local branch when the existing git deletion path
+ decides that branch is no longer used.
+
+
+
+ Cleanup rechecks each selected workspace before deletion. Suggested rows use the normal
+ git-safe removal path; not suggested rows may need forced worktree removal and are reported
+ if they cannot be removed.
+
+
+
+ Cancel
+
+
+ {removing ? : }
+ Remove {count}
+
+
+ >
+ )
+}
+
+function SkeletonRows(): React.JSX.Element {
+ return (
+
+ {[0, 1, 2].map((index) => (
+
+ ))}
+
+ )
+}
+
+function EmptyState({
+ title,
+ actionLabel,
+ onAction
+}: {
+ title: string
+ actionLabel?: string
+ onAction?: () => void
+}): React.JSX.Element {
+ return (
+
+ {title}
+ {actionLabel && onAction ? (
+
+ {actionLabel}
+
+ ) : null}
+
+ )
+}
+
+function toggleSetMember(current: Set, value: string): Set {
+ const next = new Set(current)
+ if (next.has(value)) {
+ next.delete(value)
+ } else {
+ next.add(value)
+ }
+ return next
+}
diff --git a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx
new file mode 100644
index 00000000000..2060ce32724
--- /dev/null
+++ b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx
@@ -0,0 +1,85 @@
+import { useEffect } from 'react'
+import { ArrowLeft, HardDrive } from 'lucide-react'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { WorkspaceSpaceManagerPanel } from '../status-bar/WorkspaceSpaceManagerPanel'
+import { useAppStore } from '../../store'
+
+export default function WorkspaceSpacePage(): React.JSX.Element {
+ const closeSpacePage = useAppStore((state) => state.closeSpacePage)
+
+ useEffect(() => {
+ const hasVisibleOverlay = (): boolean =>
+ Array.from(
+ document.querySelectorAll('[role="dialog"], [role="listbox"], [role="menu"]')
+ ).some((element) => {
+ if (!(element instanceof HTMLElement)) {
+ return false
+ }
+ if (element.closest('[aria-hidden="true"]')) {
+ return false
+ }
+ const style = window.getComputedStyle(element)
+ return (
+ style.display !== 'none' &&
+ style.visibility !== 'hidden' &&
+ element.getClientRects().length > 0
+ )
+ })
+
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== 'Escape') {
+ return
+ }
+ // Why: confirmation dialogs own Escape first; page-level Escape should
+ // only leave the full Space surface when no modal or popover is active.
+ if (hasVisibleOverlay()) {
+ return
+ }
+ const target = event.target as HTMLElement | null
+ if (
+ target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
+ ) {
+ return
+ }
+ event.preventDefault()
+ closeSpacePage()
+ }
+
+ // Why: tooltips can consume Escape before bubble listeners see it. Capture
+ // keeps the first Escape reliable while still deferring to real overlays.
+ window.addEventListener('keydown', handleKeyDown, { capture: true })
+ return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
+ }, [closeSpacePage])
+
+ return (
+
+
+
+
+ Back
+
+
+
+
+
+
+
+
Space
+ Beta
+
+
+ Workspace disk usage and reclaimable worktree storage.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts
index bdfa382d176..570685d884e 100644
--- a/src/renderer/src/constants/terminal.ts
+++ b/src/renderer/src/constants/terminal.ts
@@ -24,7 +24,12 @@ export type ToggleTerminalPaneExpandDetail = {
export type FocusTerminalPaneDetail = {
tabId: string
- paneId: number
+ /** Stable terminal layout leaf UUID. Numeric PaneManager ids are renderer-local
+ * and can be reminted during replay/reload, so cross-component focus uses
+ * the durable leaf identity and resolves it at the receiving TerminalPane. */
+ leafId: string | null
+ /** Optional paneKey to ack only after the target leaf resolves and focuses. */
+ ackPaneKeyOnSuccess?: string
}
export type PasteTerminalTextDetail = {
diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts
index 88fac6dd73e..632d65e532d 100644
--- a/src/renderer/src/hooks/ipc-tab-switch.ts
+++ b/src/renderer/src/hooks/ipc-tab-switch.ts
@@ -67,9 +67,6 @@ function applyNextTab(store: AppStoreState, next: TypeCyclableTab): void {
store.activateTab?.(next.tabId)
}
store.setActiveTabType('browser')
- } else if (next.type === 'notes') {
- store.activateTab?.(next.tabId ?? next.id)
- store.setActiveTabType('notes')
} else {
// Why: `setActiveFile` targets the file entity (its implicit activateTab
// picks the first matching tab in the active group); `activateTab(tabId)`
diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts
index 255fbf386ce..bc70e777afa 100644
--- a/src/renderer/src/hooks/resolve-zoom-target.ts
+++ b/src/renderer/src/hooks/resolve-zoom-target.ts
@@ -3,8 +3,8 @@
* based on current view, tab type, and focused element.
*/
export function resolveZoomTarget(args: {
- activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
- activeTabType: 'terminal' | 'editor' | 'browser' | 'notes'
+ activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space'
+ activeTabType: 'terminal' | 'editor' | 'browser'
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {
const { activeView, activeTabType, activeElement } = args
@@ -35,7 +35,7 @@ export function resolveZoomTarget(args: {
if (activeView !== 'terminal') {
return 'ui'
}
- if (activeTabType === 'editor' || activeTabType === 'notes' || editorFocused) {
+ if (activeTabType === 'editor' || editorFocused) {
return 'editor'
}
// Why: terminal tabs should keep using per-pane terminal font zoom even when
diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts
index ee92d729a0f..52ebd4e8d63 100644
--- a/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts
+++ b/src/renderer/src/hooks/useAutoAckViewedAgent.test.ts
@@ -2,6 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { computeAutoAckTargets } from './useAutoAckViewedAgent'
import { createTestStore, makeTab } from '../store/slices/store-test-helpers'
import type { RetainedAgentEntry } from '../store/slices/agent-status'
+import { makePaneKey } from '../../../shared/stable-pane-id'
+
+const CODEX_LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
// Why: regression coverage for the codex inline-agent row that stayed bold
// after returning from another workspace (docs/codex-agent-row-bold-stuck.md).
@@ -26,8 +30,8 @@ describe('computeAutoAckTargets — codex retain race regression', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z'))
const store = createTestStore()
- const paneKey = 'tab-codex:2'
const activeTabId = 'tab-codex'
+ const paneKey = makePaneKey(activeTabId, CODEX_LEAF_ID)
// 1. Codex starts working, user acks it (e.g. by clicking the row).
store.getState().setAgentStatus(paneKey, {
@@ -72,7 +76,7 @@ describe('computeAutoAckTargets — codex retain race regression', () => {
// 5. The user is back on the codex tab. computeAutoAckTargets must see
// the retained row and surface it for ack — pre-fix this returned [].
- const targets = computeAutoAckTargets(store.getState(), activeTabId)
+ const targets = computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID)
expect(targets).toEqual([paneKey])
})
@@ -80,8 +84,8 @@ describe('computeAutoAckTargets — codex retain race regression', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z'))
const store = createTestStore()
- const paneKey = 'tab-codex:2'
const activeTabId = 'tab-codex'
+ const paneKey = makePaneKey(activeTabId, CODEX_LEAF_ID)
store.getState().setAgentStatus(paneKey, {
state: 'done',
@@ -101,19 +105,19 @@ describe('computeAutoAckTargets — codex retain race regression', () => {
store.getState().removeAgentStatus(paneKey)
// First scan: the retained row is unvisited.
- expect(computeAutoAckTargets(store.getState(), activeTabId)).toEqual([paneKey])
+ expect(computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID)).toEqual([paneKey])
// Simulate the ack effect.
vi.setSystemTime(new Date('2026-05-05T12:00:01.000Z'))
store.getState().acknowledgeAgents([paneKey])
// Second scan: idempotent — nothing to ack.
- expect(computeAutoAckTargets(store.getState(), activeTabId)).toEqual([])
+ expect(computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID)).toEqual([])
})
it('skips retained rows whose paneKey is on a different tab', () => {
const store = createTestStore()
- const paneKey = 'tab-other:0'
+ const paneKey = makePaneKey('tab-other', OTHER_LEAF_ID)
store.getState().setAgentStatus(paneKey, {
state: 'done',
prompt: 'p',
@@ -134,15 +138,15 @@ describe('computeAutoAckTargets — codex retain race regression', () => {
// Active tab differs — the retained row must NOT be acked while the user
// is looking at a different tab; the bold-until-viewed signal must
// survive the tab switch.
- expect(computeAutoAckTargets(store.getState(), 'tab-codex')).toEqual([])
+ expect(computeAutoAckTargets(store.getState(), 'tab-codex', CODEX_LEAF_ID)).toEqual([])
})
it('acks a paneKey present in BOTH live and retained without throwing', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-05T12:00:00.000Z'))
const store = createTestStore()
- const paneKey = 'tab-codex:2'
const activeTabId = 'tab-codex'
+ const paneKey = makePaneKey(activeTabId, CODEX_LEAF_ID)
// Construct a (rare) state where retainedAgentsByPaneKey and
// agentStatusByPaneKey both contain the same paneKey — e.g. the
@@ -165,7 +169,7 @@ describe('computeAutoAckTargets — codex retain race regression', () => {
}
])
- const targets = computeAutoAckTargets(store.getState(), activeTabId)
+ const targets = computeAutoAckTargets(store.getState(), activeTabId, CODEX_LEAF_ID)
// Two pushes, same paneKey — duplicates are intentional and harmless;
// acknowledgeAgents short-circuits per key.
expect(targets.length).toBeLessThanOrEqual(2)
diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts
index 2209c554040..25cb244a691 100644
--- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts
+++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts
@@ -2,17 +2,26 @@ import { useEffect } from 'react'
import { useAppStore } from '@/store'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
+import type { TerminalLayoutSnapshot } from '../../../shared/types'
+import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
+
+function resolveActiveLeafId(
+ state: { terminalLayoutsByTabId: Record },
+ activeTabId: string
+): string | null {
+ const leafId = state.terminalLayoutsByTabId[activeTabId]?.activeLeafId ?? null
+ return leafId && isTerminalLeafId(leafId) ? leafId : null
+}
/**
* Pure helper used by the hook below — exported so the regression test for
* the codex-row-stays-bold race (docs/codex-agent-row-bold-stuck.md) can
* exercise the decision against a real test store without needing a DOM.
*
- * Returns the list of paneKeys that should be acked given the active tab.
- * Walks BOTH the live agent map AND the retained snapshot map: the inline
- * agents list renders the union, so the ack scan must too. A paneKey may
- * appear in both maps simultaneously (paneKey reuse mid-frame); duplicate
- * pushes are harmless because acknowledgeAgents short-circuits per key.
+ * Returns the list of paneKeys that should be acked given the active tab and
+ * exact active leaf. Split tabs can host multiple agent panes, so equality on
+ * `${tabId}:${leafId}` is required; tab-prefix matching would mark siblings
+ * read without ever displaying them.
*/
export function computeAutoAckTargets(
state: {
@@ -20,29 +29,29 @@ export function computeAutoAckTargets(
retainedAgentsByPaneKey: Record
acknowledgedAgentsByPaneKey: Record
},
- activeTabId: string
+ activeTabId: string,
+ activeLeafId: string | null
): string[] {
- const prefix = `${activeTabId}:`
+ if (!activeLeafId || !isTerminalLeafId(activeLeafId)) {
+ return []
+ }
+ const targetKey = makePaneKey(activeTabId, activeLeafId)
const targets: string[] = []
- for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
- if (!paneKey.startsWith(prefix)) {
- continue
- }
- const ackAt = state.acknowledgedAgentsByPaneKey[paneKey] ?? 0
+ const liveEntry = state.agentStatusByPaneKey[targetKey]
+ if (liveEntry) {
+ const ackAt = state.acknowledgedAgentsByPaneKey[targetKey] ?? 0
// Why: use stateStartedAt (not updatedAt) so tool/prompt pings within the
// same state don't re-trigger ack work — keeping the comparison aligned
// with WorktreeCardAgents' is-unvisited rule.
- if (ackAt < entry.stateStartedAt) {
- targets.push(paneKey)
+ if (ackAt < liveEntry.stateStartedAt) {
+ targets.push(targetKey)
}
}
- for (const [paneKey, retained] of Object.entries(state.retainedAgentsByPaneKey)) {
- if (!paneKey.startsWith(prefix)) {
- continue
- }
- const ackAt = state.acknowledgedAgentsByPaneKey[paneKey] ?? 0
+ const retained = state.retainedAgentsByPaneKey[targetKey]
+ if (retained) {
+ const ackAt = state.acknowledgedAgentsByPaneKey[targetKey] ?? 0
if (ackAt < retained.entry.stateStartedAt) {
- targets.push(paneKey)
+ targets.push(targetKey)
}
}
return targets
@@ -102,6 +111,7 @@ export function useAutoAckViewedAgent(): void {
let lastAgentStatus: unknown = undefined
let lastRetained: unknown = undefined
let lastAcknowledged: unknown = undefined
+ let lastLayouts: unknown = undefined
const maybeAck = (): void => {
const s = useAppStore.getState()
@@ -110,7 +120,8 @@ export function useAutoAckViewedAgent(): void {
s.activeTabId === lastActiveTabId &&
s.agentStatusByPaneKey === lastAgentStatus &&
s.retainedAgentsByPaneKey === lastRetained &&
- s.acknowledgedAgentsByPaneKey === lastAcknowledged
+ s.acknowledgedAgentsByPaneKey === lastAcknowledged &&
+ s.terminalLayoutsByTabId === lastLayouts
) {
return
}
@@ -136,6 +147,7 @@ export function useAutoAckViewedAgent(): void {
if (!activeTabId) {
return
}
+ const activeLeafId = resolveActiveLeafId(s, activeTabId)
// Why: advance the refs ONLY after all gates have passed — if the
// visibility gate (window hidden/unfocused or no activeTabId) caused an
// early return, leave the refs stale so the next call (e.g. triggered by
@@ -148,7 +160,8 @@ export function useAutoAckViewedAgent(): void {
lastAgentStatus = s.agentStatusByPaneKey
lastRetained = s.retainedAgentsByPaneKey
lastAcknowledged = s.acknowledgedAgentsByPaneKey
- const toAck = computeAutoAckTargets(s, activeTabId)
+ lastLayouts = s.terminalLayoutsByTabId
+ const toAck = computeAutoAckTargets(s, activeTabId, activeLeafId)
if (toAck.length > 0) {
s.acknowledgeAgents(toAck)
}
diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts
index e833e85369b..b6fdf1a6e1c 100644
--- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts
+++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session'
import { useAppStore } from '@/store'
import type { AutomationDispatchResult } from '../../../shared/automations-types'
-import { FIRST_PANE_ID } from '../../../shared/pane-key'
+import { parsePaneKey } from '../../../shared/stable-pane-id'
const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed'
@@ -151,10 +151,14 @@ export function useAutomationDispatchEvents(): void {
void markCompletionResult()
}
const observeAgentStatus = (tabId: string): void => {
- const paneKey = `${tabId}:${FIRST_PANE_ID}`
const checkCurrentStatus = (): void => {
- if (useAppStore.getState().agentStatusByPaneKey[paneKey]?.state === 'done') {
- handleAgentDone()
+ const { agentStatusByPaneKey } = useAppStore.getState()
+ for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
+ const parsed = parsePaneKey(paneKey)
+ if (parsed?.tabId === tabId && entry.state === 'done') {
+ handleAgentDone()
+ return
+ }
}
}
// Why: Codex/Claude completion normally arrives through the global
diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts
index 88c2e46b317..3ff303c7bb5 100644
--- a/src/renderer/src/hooks/useComposerState.ts
+++ b/src/renderer/src/hooks/useComposerState.ts
@@ -17,6 +17,7 @@ import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agen
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { isGitRepoKind } from '../../../shared/repo-kind'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type {
GitHubWorkItem,
GitPushTarget,
@@ -45,10 +46,21 @@ import {
renderIssueCommandTemplate,
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
+import {
+ getFullComposerCreateDisabled,
+ getQuickComposerCreateDisabled
+} from '@/lib/new-workspace-create-gates'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
+import { joinPath } from '@/lib/path'
+import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
+import {
+ checkRuntimeHooks,
+ readRuntimeIssueCommand,
+ type HookCheckResult
+} from '@/runtime/runtime-hooks-client'
export type UseComposerStateOptions = {
initialRepoId?: string
@@ -76,6 +88,11 @@ export type UseComposerStateOptions = {
* `sidebar`, keyboard shortcut → `shortcut`). Omitted callers default
* to `unknown` at the IPC boundary. */
telemetrySource?: WorkspaceCreateTelemetrySource
+ /** Quick-create launches a blank/draft agent session and does not run
+ * issueCommand automation, so it can skip the issue-command probe that the
+ * full composer needs for linked-item prompt previews. */
+ enableIssueAutomation?: boolean
+ createGateMode?: 'full' | 'quick'
}
export type ComposerCardProps = {
@@ -139,7 +156,7 @@ export type ComposerCardProps = {
baseBranchLinkedPrNumber: number | null
/** Absolute path of the selected repo, used by Start-from picker for SWR. */
selectedRepoPath: string | null
- /** True when the selected repo is a remote SSH repo; disables the PR tab in v1. */
+ /** True when the selected repo is a remote SSH repo. */
selectedRepoIsRemote: boolean
/** Transient inline hint shown next to the Start-from trigger after a repo
* switch resets a prior selection (e.g. "was PR #8778"). Null when none. */
@@ -194,7 +211,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onCreated,
repoIdOverride,
onRepoIdOverrideChange,
- telemetrySource
+ telemetrySource,
+ enableIssueAutomation = true,
+ createGateMode = 'full'
} = options
// Why: each `useAppStore(s => s.someAction)` registers its own equality
@@ -252,6 +271,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [internalRepoId, setInternalRepoId] = useState(resolvedInitialRepoId)
const repoId = repoIdOverride ?? internalRepoId
+ const repoIdRef = useRef(repoId)
+ repoIdRef.current = repoId
const setRepoId = useCallback(
(value: string) => {
if (onRepoIdOverrideChange) {
@@ -282,7 +303,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
if (persistDraft && newWorkspaceDraft?.linkedIssue) {
return newWorkspaceDraft.linkedIssue
}
- if (initialLinkedWorkItem?.type === 'issue') {
+ if (initialLinkedWorkItem?.type === 'issue' && !initialLinkedWorkItem.linearIdentifier) {
return String(initialLinkedWorkItem.number)
}
return ''
@@ -392,6 +413,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// multi-instance routing).
const agentPromptRef = useRef(agentPrompt)
agentPromptRef.current = agentPrompt
+ const connectionIdRef = useRef(connectionId)
+ connectionIdRef.current = connectionId
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
@@ -404,14 +427,43 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
null
)
const selectedRepoPath = selectedRepo?.path
+ const selectedRepoPathRef = useRef(selectedRepoPath)
+ selectedRepoPathRef.current = selectedRepoPath
+ const settingsRef = useRef(settings)
+ settingsRef.current = settings
+ const hookCheckRef = useRef<{
+ key: string
+ promise: Promise
+ } | null>(null)
+ const loadHookCheckForRepo = useCallback((targetRepoId: string): Promise => {
+ const key = `${settingsRef.current?.activeRuntimeEnvironmentId ?? 'local'}:${targetRepoId}`
+ const existing = hookCheckRef.current
+ if (existing?.key === key) {
+ return existing.promise
+ }
+ const promise = checkRuntimeHooks(settingsRef.current, targetRepoId)
+ hookCheckRef.current = { key, promise }
+ return promise
+ }, [])
+ const commitHookCheckIfCurrent = useCallback(
+ (targetRepoId: string, hooks: OrcaHooks | null): boolean => {
+ if (repoIdRef.current !== targetRepoId) {
+ return false
+ }
+ setYamlHooks(hooks)
+ setCheckedHooksRepoId(targetRepoId)
+ return true
+ },
+ []
+ )
useEffect(() => {
- if (!selectedRepoPath) {
+ if (!selectedRepo || !selectedRepoPath) {
setSelectedRepoSlug(null)
return
}
let cancelled = false
void (
- window.api.gh.repoSlug({ repoPath: selectedRepoPath }) as Promise<{
+ window.api.gh.repoSlug({ repoPath: selectedRepoPath, repoId }) as Promise<{
owner: string
repo: string
} | null>
@@ -430,7 +482,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
- }, [selectedRepoPath])
+ }, [repoId, selectedRepo, selectedRepoPath])
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
const normalizedSparseDirectories = useMemo(
@@ -505,14 +557,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[selectedRepo, yamlHooks]
)
const setupPolicy: SetupRunPolicy = selectedRepo?.hookSettings?.setupRunPolicy ?? 'run-by-default'
- const hasIssueAutomationConfig = issueCommandTemplate.length > 0
+ const hasIssueAutomationConfig = enableIssueAutomation && issueCommandTemplate.length > 0
const canOfferIssueAutomation = parsedLinkedIssueNumber !== null && hasIssueAutomationConfig
// Why: the "no prompt + linked item" path below rehydrates the issueCommand
// template into the main startup prompt. When that happens we suppress the
// separate split pane that would otherwise run the same command twice.
- const willApplyIssueCommandAsPrompt = !agentPrompt.trim() && Boolean(linkedWorkItem)
+ const willApplyIssueCommandAsPrompt =
+ enableIssueAutomation && !agentPrompt.trim() && Boolean(linkedWorkItem)
const shouldWaitForIssueAutomationCheck =
- (parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) && !hasLoadedIssueCommand
+ enableIssueAutomation &&
+ (parsedLinkedIssueNumber !== null || willApplyIssueCommandAsPrompt) &&
+ !hasLoadedIssueCommand
const shouldRunIssueAutomation = canOfferIssueAutomation && !willApplyIssueCommandAsPrompt
const requiresExplicitSetupChoice = Boolean(setupConfig) && setupPolicy === 'ask'
const resolvedSetupDecision =
@@ -551,7 +606,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// the common "paste a link and hit enter" flow produce a useful agent task
// instead of a bare URL bullet.
const shouldApplyLinkedOnlyTemplate =
- !agentPrompt.trim() && Boolean(linkedWorkItem) && hasLoadedIssueCommand
+ enableIssueAutomation && !agentPrompt.trim() && Boolean(linkedWorkItem) && hasLoadedIssueCommand
const linkedOnlyTemplatePrompt = useMemo(() => {
if (!shouldApplyLinkedOnlyTemplate || !linkedWorkItem) {
return ''
@@ -702,23 +757,26 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setYamlHooks(null)
setCheckedHooksRepoId(null)
- void window.api.hooks
- .check({ repoId })
+ void loadHookCheckForRepo(repoId)
.then((result) => {
if (!cancelled) {
- setYamlHooks(result.hooks)
- setCheckedHooksRepoId(repoId)
+ commitHookCheckIfCurrent(repoId, result.hooks)
}
})
.catch(() => {
if (!cancelled) {
- setYamlHooks(null)
- setCheckedHooksRepoId(repoId)
+ commitHookCheckIfCurrent(repoId, null)
}
})
- void window.api.hooks
- .readIssueCommand({ repoId })
+ if (!enableIssueAutomation) {
+ setHasLoadedIssueCommand(true)
+ return () => {
+ cancelled = true
+ }
+ }
+
+ void readRuntimeIssueCommand(settings, repoId)
.then((result) => {
if (!cancelled) {
setIssueCommandTemplate(result.effectiveContent ?? '')
@@ -735,17 +793,17 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
- }, [repoId])
+ }, [commitHookCheckIfCurrent, enableIssueAutomation, loadHookCheckForRepo, repoId, settings])
// Why: warm the Start-from picker's PR cache on composer mount and whenever
// the selected repo changes so opening the picker paints instantly from
- // cache. Local repos only — remote SSH repos disable the PR tab in v1.
+ // cache.
useEffect(() => {
- if (!selectedRepo?.path || selectedRepo.connectionId) {
+ if (!selectedRepo?.path) {
return
}
prefetchWorkItems(selectedRepo.id, selectedRepo.path, PER_REPO_FETCH_LIMIT, 'is:pr is:open')
- }, [prefetchWorkItems, selectedRepo?.connectionId, selectedRepo?.id, selectedRepo?.path])
+ }, [prefetchWorkItems, selectedRepo?.id, selectedRepo?.path])
// Reset setup decision when config / policy changes.
useEffect(() => {
@@ -780,7 +838,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const lookupRepoId = selectedRepo.id
void window.api.gh
- .listWorkItems({ repoPath: selectedRepo.path, limit: 100 })
+ .listWorkItems({ repoPath: selectedRepo.path, repoId: selectedRepo.id, limit: 100 })
.then((envelope) => {
if (!cancelled) {
// Why: IPC payload omits repoId — stamp it here from the repo we
@@ -844,7 +902,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// text match in the recent-items list.
const lookupRepoId = selectedRepo.id
void window.api.gh
- .workItem({ repoPath: selectedRepo.path, number: normalizedLinkQuery.directNumber })
+ .workItem({
+ repoPath: selectedRepo.path,
+ repoId: selectedRepo.id,
+ number: normalizedLinkQuery.directNumber
+ })
.then((item) => {
if (!cancelled) {
setLinkDirectItem(
@@ -975,23 +1037,165 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
},
[name]
)
+
+ const addComposerAttachments = useCallback((paths: string[]): void => {
+ if (paths.length === 0) {
+ return
+ }
+ setAttachmentPaths((current) => {
+ const next = [...current]
+ for (const pathValue of paths) {
+ if (!next.includes(pathValue)) {
+ next.push(pathValue)
+ }
+ }
+ return next
+ })
+ }, [])
+
+ const insertComposerFolderPaths = useCallback((folderPaths: string[]): void => {
+ if (folderPaths.length === 0) {
+ return
+ }
+ // Why: de-dup within a single drop — the OS occasionally delivers the
+ // same folder twice when a user drags from a selection that includes both
+ // the item and its parent, and we don't want to insert it multiple times.
+ const uniqueFolderPaths = Array.from(new Set(folderPaths))
+ // Why: wrap paths containing shell metacharacters in double quotes (and
+ // escape embedded quotes) so inserted folder refs stay a single token if
+ // pasted into a terminal. Simple paths stay unadorned to match OS drops.
+ const formatPath = (p: string): string => {
+ if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) {
+ return `"${p.replace(/(["\\$`])/g, '\\$1')}"`
+ }
+ return p
+ }
+ const insertion = uniqueFolderPaths.map(formatPath).join(' ')
+ const textarea = promptTextareaRef.current
+ // Why: compute selection, insertion, and caret target OUTSIDE the
+ // setAgentPrompt updater so the updater stays pure. React Strict Mode
+ // double-invokes updaters in dev, and batching can delay execution.
+ const current = agentPromptRef.current
+ const selStart = textarea?.selectionStart ?? current.length
+ const selEnd = textarea?.selectionEnd ?? current.length
+ const before = current.slice(0, selStart)
+ const after = current.slice(selEnd)
+ // Why: pad with single spaces when the caret sits directly against other
+ // text so the folder path doesn't merge into an adjacent word.
+ const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
+ const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
+ const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}`
+ const caret = before.length + padded.length
+ if (textarea) {
+ requestAnimationFrame(() => {
+ textarea.focus()
+ textarea.setSelectionRange(caret, caret)
+ })
+ }
+ // Why: pass a plain value (not an updater) since `before`/`after` were
+ // already resolved from `agentPromptRef.current`; this keeps the state
+ // write side-effect-free under Strict-Mode double-render.
+ setAgentPrompt(before + padded + after)
+ }, [])
+
+ const uploadComposerPaths = useCallback(
+ async (
+ sourcePaths: string[],
+ targetSettings = settings,
+ targetConnectionId = connectionId,
+ targetRepoPath = selectedRepoPath
+ ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => {
+ if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) {
+ return null
+ }
+ if (!targetRepoPath) {
+ toast.error('No remote repository path is available for attachments.')
+ return { filePaths: [], folderPaths: [] }
+ }
+ const destinationDir = joinPath(targetRepoPath, '.orca/drops')
+ const { results } = await importExternalPathsToRuntime(
+ {
+ settings: targetSettings,
+ worktreeId: targetRepoPath,
+ worktreePath: targetRepoPath,
+ connectionId: targetConnectionId ?? undefined
+ },
+ sourcePaths,
+ destinationDir,
+ { ensureDestinationDir: true }
+ )
+ const filePaths: string[] = []
+ const folderPaths: string[] = []
+ let skippedOrFailed = 0
+ for (const result of results) {
+ if (result.status !== 'imported') {
+ skippedOrFailed += 1
+ continue
+ }
+ if (result.kind === 'directory') {
+ folderPaths.push(result.destPath)
+ } else {
+ filePaths.push(result.destPath)
+ }
+ }
+ if (skippedOrFailed > 0) {
+ toast.error('Some attachments could not be uploaded.')
+ }
+ return { filePaths, folderPaths }
+ },
+ [connectionId, selectedRepoPath, settings]
+ )
+
const handleAddAttachment = useCallback(async (): Promise => {
try {
const selectedPath = await window.api.shell.pickAttachment()
if (!selectedPath) {
return
}
- setAttachmentPaths((current) => {
- if (current.includes(selectedPath)) {
- return current
- }
- return [...current, selectedPath]
- })
+ const uploaded = await uploadComposerPaths([selectedPath])
+ if (uploaded) {
+ addComposerAttachments(uploaded.filePaths)
+ insertComposerFolderPaths(uploaded.folderPaths)
+ return
+ }
+ addComposerAttachments([selectedPath])
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to add attachment.'
toast.error(message)
}
- }, [])
+ }, [addComposerAttachments, insertComposerFolderPaths, uploadComposerPaths])
+
+ const applyLocalComposerDrop = useCallback(
+ async (paths: string[]): Promise => {
+ const fileAttachments: string[] = []
+ const folderPaths: string[] = []
+ for (const filePath of paths) {
+ try {
+ await window.api.fs.authorizeExternalPath({ targetPath: filePath })
+ const stat = await window.api.fs.stat({ filePath })
+ if (stat.isDirectory) {
+ folderPaths.push(filePath)
+ } else {
+ fileAttachments.push(filePath)
+ }
+ } catch {
+ // Skip paths we cannot authorize or stat.
+ }
+ }
+
+ addComposerAttachments(fileAttachments)
+ insertComposerFolderPaths(folderPaths)
+ },
+ [addComposerAttachments, insertComposerFolderPaths]
+ )
+ const addComposerAttachmentsRef = useRef(addComposerAttachments)
+ addComposerAttachmentsRef.current = addComposerAttachments
+ const insertComposerFolderPathsRef = useRef(insertComposerFolderPaths)
+ insertComposerFolderPathsRef.current = insertComposerFolderPaths
+ const uploadComposerPathsRef = useRef(uploadComposerPaths)
+ uploadComposerPathsRef.current = uploadComposerPaths
+ const applyLocalComposerDropRef = useRef(applyLocalComposerDrop)
+ applyLocalComposerDropRef.current = applyLocalComposerDrop
// Why: native OS file drops onto the composer are captured by the preload
// bridge (see `data-native-file-drop-target="composer"` markers) and relayed
@@ -1015,82 +1219,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return
}
void (async () => {
- const fileAttachments: string[] = []
- const folderPaths: string[] = []
- for (const filePath of data.paths) {
- try {
- await window.api.fs.authorizeExternalPath({ targetPath: filePath })
- const stat = await window.api.fs.stat({ filePath })
- if (stat.isDirectory) {
- folderPaths.push(filePath)
- } else {
- fileAttachments.push(filePath)
- }
- } catch {
- // Skip paths we cannot authorize or stat.
- }
- }
-
- if (fileAttachments.length > 0) {
- setAttachmentPaths((current) => {
- const next = [...current]
- for (const p of fileAttachments) {
- if (!next.includes(p)) {
- next.push(p)
- }
- }
- return next
- })
- }
-
- if (folderPaths.length > 0) {
- // Why: de-dup within a single drop — the OS occasionally delivers
- // the same folder twice when a user drags from a selection that
- // includes both the item and its parent, and we don't want to
- // insert it multiple times.
- const uniqueFolderPaths = Array.from(new Set(folderPaths))
- // Why: wrap paths containing shell metacharacters in double quotes
- // (and escape embedded quotes) so the inserted text reads as a
- // single token if the user pastes it into a terminal. Simple paths
- // stay unadorned to match how Finder/Explorer drops appear.
- const formatPath = (p: string): string => {
- if (/[\s"'$`\\()[\]{}*?!;&|<>#~]/.test(p)) {
- return `"${p.replace(/(["\\$`])/g, '\\$1')}"`
- }
- return p
- }
- const insertion = uniqueFolderPaths.map(formatPath).join(' ')
- const textarea = promptTextareaRef.current
- // Why: compute selection, insertion, and caret target OUTSIDE the
- // setAgentPrompt updater so the updater stays pure. React Strict
- // Mode double-invokes updaters in dev, and batching can delay
- // execution — reading `textarea.selectionStart` inside the updater
- // risks seeing a shifted caret. Read `agentPromptRef.current` for
- // the latest prompt because this effect subscribes once and the
- // outer closure's `agentPrompt` would be stale.
- const current = agentPromptRef.current
- const selStart = textarea?.selectionStart ?? current.length
- const selEnd = textarea?.selectionEnd ?? current.length
- const before = current.slice(0, selStart)
- const after = current.slice(selEnd)
- // Why: pad with single spaces when the caret sits directly against
- // other text so the folder path doesn't merge into an adjacent word.
- const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
- const needsTrailingSpace = after.length > 0 && !/^\s/.test(after)
- const padded = `${needsLeadingSpace ? ' ' : ''}${insertion}${needsTrailingSpace ? ' ' : ''}`
- const caret = before.length + padded.length
- if (textarea) {
- // Restore the caret to the end of the inserted text after React flushes.
- requestAnimationFrame(() => {
- textarea.focus()
- textarea.setSelectionRange(caret, caret)
- })
- }
- // Why: pass a plain value (not an updater) since `before`/`after`
- // were already resolved from `agentPromptRef.current`; this keeps
- // the state write side-effect-free under Strict-Mode double-render.
- setAgentPrompt(before + padded + after)
+ const uploaded = await uploadComposerPathsRef.current(
+ data.paths,
+ settingsRef.current,
+ connectionIdRef.current,
+ selectedRepoPathRef.current
+ )
+ if (uploaded) {
+ addComposerAttachmentsRef.current(uploaded.filePaths)
+ insertComposerFolderPathsRef.current(uploaded.folderPaths)
+ return
}
+ await applyLocalComposerDropRef.current(data.paths)
})()
})
return () => {
@@ -1231,15 +1371,31 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return
}
setPushTarget(undefined)
- void window.api.worktrees
- .resolvePrBase({
- repoId: repoForItem.id,
- prNumber: item.number,
- ...(item.branchName ? { headRefName: item.branchName } : {}),
- ...(item.isCrossRepository !== undefined
- ? { isCrossRepository: item.isCrossRepository }
- : {})
- })
+ const target = getActiveRuntimeTarget(settings)
+ const resolvePrBase =
+ target.kind === 'local'
+ ? window.api.worktrees.resolvePrBase({
+ repoId: repoForItem.id,
+ prNumber: item.number,
+ ...(item.branchName ? { headRefName: item.branchName } : {}),
+ ...(item.isCrossRepository !== undefined
+ ? { isCrossRepository: item.isCrossRepository }
+ : {})
+ })
+ : callRuntimeRpc<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>(
+ target,
+ 'worktree.resolvePrBase',
+ {
+ repo: repoForItem.id,
+ prNumber: item.number,
+ ...(item.branchName ? { headRefName: item.branchName } : {}),
+ ...(item.isCrossRepository !== undefined
+ ? { isCrossRepository: item.isCrossRepository }
+ : {})
+ },
+ { timeoutMs: 30_000 }
+ )
+ void resolvePrBase
.then((result) => {
if ('error' in result) {
setBaseBranch(undefined)
@@ -1255,7 +1411,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
toast.error(error instanceof Error ? error.message : 'Failed to resolve PR base.')
})
},
- [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo]
+ [applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo, settings]
)
// Why: GitLab parallel of handleSmartGitHubItemSelect. For a picked
@@ -1420,6 +1576,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
+ const linkedLinearIssue = linkedWorkItem?.linearIdentifier
const result = await createWorktree(
repoId,
workspaceName,
@@ -1436,7 +1593,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
parsedLinkedIssueNumber ?? undefined,
effectiveLinkedPR ?? undefined,
pushTarget,
- tuiAgent
+ tuiAgent,
+ linkedLinearIssue
)
const worktree = result.worktree
@@ -1521,6 +1679,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
effectiveLinkedPR,
linkedGitLabIssue,
linkedGitLabMR,
+ linkedWorkItem?.linearIdentifier,
linkedWorkItem?.title,
linkedWorkItem?.url,
normalizedSparseDirectories,
@@ -1564,7 +1723,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!repoId ||
!workspaceName ||
!selectedRepo ||
- shouldWaitForSetupCheck ||
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
@@ -1574,12 +1732,39 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setCreateError(null)
setCreating(true)
try {
+ let submitSetupConfig = setupConfig
+ let submitResolvedSetupDecision = resolvedSetupDecision
+ if (checkedHooksRepoId !== repoId) {
+ let hookCheck: HookCheckResult
+ try {
+ hookCheck = await loadHookCheckForRepo(repoId)
+ } catch {
+ hookCheck = { hasHooks: false, hooks: null, mayNeedUpdate: false }
+ }
+ if (!commitHookCheckIfCurrent(repoId, hookCheck.hooks)) {
+ return
+ }
+ submitSetupConfig = getSetupConfig(selectedRepo, hookCheck.hooks)
+ submitResolvedSetupDecision =
+ setupDecision ??
+ (!submitSetupConfig || setupPolicy === 'ask'
+ ? null
+ : setupPolicy === 'run-by-default'
+ ? 'run'
+ : 'skip')
+ }
+ if (submitSetupConfig && setupPolicy === 'ask' && !setupDecision) {
+ setAdvancedOpen(true)
+ return
+ }
+
const trustDecision = await ensureHooksConfirmed(useAppStore.getState(), repoId, 'setup')
const effectiveSetupDecision: SetupDecision =
trustDecision === 'skip'
? 'skip'
- : ((resolvedSetupDecision ?? 'inherit') as SetupDecision)
+ : ((submitResolvedSetupDecision ?? 'inherit') as SetupDecision)
+ const linkedLinearIssue = linkedWorkItem?.linearIdentifier
const result = await createWorktree(
repoId,
workspaceName,
@@ -1596,7 +1781,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
parsedLinkedIssueNumber ?? undefined,
effectiveLinkedPR ?? undefined,
pushTarget,
- agent ?? undefined
+ agent ?? undefined,
+ linkedLinearIssue
)
const worktree = result.worktree
@@ -1747,18 +1933,28 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
sparseError,
effectivePresetId,
telemetrySource,
- shouldWaitForSetupCheck
+ checkedHooksRepoId,
+ commitHookCheckIfCurrent,
+ loadHookCheckForRepo,
+ setupConfig,
+ setupPolicy
]
)
+ const createGateInput = {
+ repoId,
+ workspaceSeedName,
+ creating,
+ shouldWaitForSetupCheck,
+ shouldWaitForIssueAutomationCheck,
+ requiresExplicitSetupChoice,
+ hasSetupDecision: Boolean(setupDecision),
+ sparseError
+ }
const createDisabled =
- !repoId ||
- !workspaceSeedName ||
- creating ||
- shouldWaitForSetupCheck ||
- shouldWaitForIssueAutomationCheck ||
- (requiresExplicitSetupChoice && !setupDecision) ||
- sparseError !== null
+ createGateMode === 'quick'
+ ? getQuickComposerCreateDisabled(createGateInput)
+ : getFullComposerCreateDisabled(createGateInput)
const cardProps: ComposerCardProps = {
eligibleRepos,
diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts
index f6f809ff472..1b0b823f6c7 100644
--- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts
+++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts
@@ -20,6 +20,10 @@ import {
getWatchedTargetKey
} from './useEditorExternalWatch'
import { useAppStore } from '@/store'
+import {
+ getOpenFilesForExternalFileChange,
+ notifyEditorExternalFileChange
+} from '@/components/editor/editor-autosave'
describe('getWatchedTargetKey', () => {
it('changes when a worktree gains an SSH connection id', () => {
@@ -27,13 +31,33 @@ describe('getWatchedTargetKey', () => {
getWatchedTargetKey({
worktreeId: 'wt-1',
worktreePath: '/repo',
- connectionId: undefined
+ connectionId: undefined,
+ runtimeEnvironmentId: undefined
})
).not.toBe(
getWatchedTargetKey({
worktreeId: 'wt-1',
worktreePath: '/repo',
- connectionId: 'conn-1'
+ connectionId: 'conn-1',
+ runtimeEnvironmentId: undefined
+ })
+ )
+ })
+
+ it('changes when the active runtime environment changes', () => {
+ expect(
+ getWatchedTargetKey({
+ worktreeId: 'wt-1',
+ worktreePath: '/repo',
+ connectionId: undefined,
+ runtimeEnvironmentId: undefined
+ })
+ ).not.toBe(
+ getWatchedTargetKey({
+ worktreeId: 'wt-1',
+ worktreePath: '/repo',
+ connectionId: undefined,
+ runtimeEnvironmentId: 'env-1'
})
)
})
@@ -104,10 +128,16 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => {
worktreeId: string
worktreePath: string
connectionId: string | undefined
+ runtimeEnvironmentId: string | undefined
}
| undefined =>
worktreePath === '/repo'
- ? { worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined }
+ ? {
+ worktreeId: 'wt-1',
+ worktreePath: '/repo',
+ connectionId: undefined,
+ runtimeEnvironmentId: undefined
+ }
: undefined
const fileNotes = {
@@ -182,4 +212,76 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => {
dispose()
})
+
+ it('reloads Windows editor tabs when watcher event casing differs', () => {
+ const file = {
+ id: 'file-win',
+ worktreeId: 'wt-win',
+ worktreePath: 'C:\\Repo',
+ filePath: 'C:\\Repo\\notes.md',
+ relativePath: 'notes.md',
+ mode: 'edit' as const,
+ isDirty: false
+ }
+ vi.mocked(useAppStore.getState).mockReturnValue({
+ openFiles: [file],
+ setExternalMutation
+ } as never)
+ vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never)
+ const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({
+ worktreeId: 'wt-win',
+ worktreePath: 'C:\\Repo',
+ connectionId: undefined,
+ runtimeEnvironmentId: 'env-1'
+ }))
+
+ handleFsChanged({
+ worktreePath: 'c:\\repo',
+ events: [{ kind: 'update', absolutePath: 'c:\\repo\\notes.md', isDirectory: false }]
+ })
+ vi.advanceTimersByTime(100)
+
+ expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
+ worktreeId: 'wt-win',
+ worktreePath: 'C:\\Repo',
+ relativePath: 'notes.md'
+ })
+ dispose()
+ })
+
+ it('reloads UNC editor tabs without collapsing the share root', () => {
+ const file = {
+ id: 'file-unc',
+ worktreeId: 'wt-unc',
+ worktreePath: '//Server/Share/Repo',
+ filePath: '//Server/Share/Repo/notes.md',
+ relativePath: 'notes.md',
+ mode: 'edit' as const,
+ isDirty: false
+ }
+ vi.mocked(useAppStore.getState).mockReturnValue({
+ openFiles: [file],
+ setExternalMutation
+ } as never)
+ vi.mocked(getOpenFilesForExternalFileChange).mockReturnValue([file] as never)
+ const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({
+ worktreeId: 'wt-unc',
+ worktreePath: '//Server/Share/Repo',
+ connectionId: undefined,
+ runtimeEnvironmentId: 'env-1'
+ }))
+
+ handleFsChanged({
+ worktreePath: '//server/share/repo',
+ events: [{ kind: 'update', absolutePath: '//server/share/repo/notes.md', isDirectory: false }]
+ })
+ vi.advanceTimersByTime(100)
+
+ expect(notifyEditorExternalFileChange).toHaveBeenCalledWith({
+ worktreeId: 'wt-unc',
+ worktreePath: '//Server/Share/Repo',
+ relativePath: 'notes.md'
+ })
+ dispose()
+ })
})
diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts
index 26e97953c1f..d6d1081837b 100644
--- a/src/renderer/src/hooks/useEditorExternalWatch.ts
+++ b/src/renderer/src/hooks/useEditorExternalWatch.ts
@@ -5,8 +5,8 @@
import { useEffect, useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
import { basename, joinPath } from '@/lib/path'
-import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths'
import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch'
+import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
import {
getOpenFilesForExternalFileChange,
notifyEditorExternalFileChange
@@ -15,6 +15,7 @@ import { hasRecentSelfWrite } from '@/components/editor/editor-self-write-regist
import type { FsChangedPayload } from '../../../shared/types'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { OpenFile } from '@/store/slices/editor'
+import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client'
// Why: atomic-write patterns (Claude Code's Edit tool, editors like vim,
// VSCode) land as a short burst of `update` events — or `delete + create` on
@@ -25,7 +26,7 @@ import type { OpenFile } from '@/store/slices/editor'
// and black out the window (issue #826). Coalescing per (worktreeId + path)
// on a short debounce collapses that burst into one reload notification.
const EXTERNAL_RELOAD_DEBOUNCE_MS = 75
-const pendingExternalReloadTimers = new Map()
+const pendingExternalReloadTimers = new Map>()
function warnExternalWatchFailure(target: WatchedTarget, err: unknown): void {
console.warn('[filesystem-watch] failed to watch worktree', {
@@ -44,9 +45,9 @@ function scheduleDebouncedExternalReload(notification: {
const key = `${notification.worktreeId}::${notification.relativePath}`
const existing = pendingExternalReloadTimers.get(key)
if (existing !== undefined) {
- window.clearTimeout(existing)
+ globalThis.clearTimeout(existing)
}
- const handle = window.setTimeout(() => {
+ const handle = globalThis.setTimeout(() => {
pendingExternalReloadTimers.delete(key)
notifyEditorExternalFileChange(notification)
}, EXTERNAL_RELOAD_DEBOUNCE_MS)
@@ -57,6 +58,7 @@ type WatchedTarget = {
worktreeId: string
worktreePath: string
connectionId: string | undefined
+ runtimeEnvironmentId: string | undefined
}
type ExternalWatchNotification = {
@@ -69,7 +71,7 @@ export function getWatchedTargetKey(target: WatchedTarget): string {
// Why: SSH worktrees can exist in the store before their remote filesystem
// provider is ready. Include connectionId so a local/unknown placeholder
// watch is replaced by the real SSH watch when the repo metadata hydrates.
- return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}`
+ return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}`
}
// Why: macOS atomic writes (Claude Code Edit, vim :w, VSCode save) deliver a
@@ -106,6 +108,7 @@ export function useEditorExternalWatch(): void {
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
+ const runtimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
// Why: unify the target computation and the dependency key into one memo so
// there's a single source of truth. The derived string key drives the
@@ -134,17 +137,20 @@ export function useEditorExternalWatch(): void {
const target = {
worktreeId: id,
worktreePath: wt.path,
- connectionId: repo?.connectionId ?? undefined
+ connectionId: repo?.connectionId ?? undefined,
+ runtimeEnvironmentId: runtimeEnvironmentId?.trim() || undefined
}
nextTargets.push(target)
parts.push(getWatchedTargetKey(target))
}
return { targets: nextTargets, targetsKey: parts.join('|') }
- }, [openFiles, worktreesByRepo, repos, activeWorktreeId])
+ }, [openFiles, worktreesByRepo, repos, activeWorktreeId, runtimeEnvironmentId])
const targetsRef = useRef([])
const latestTargetsRef = useRef(targets)
latestTargetsRef.current = targets
+ const remoteWatchUnsubsRef = useRef(new Map void>())
+ const fsChangedHandlerRef = useRef<((payload: FsChangedPayload) => void) | null>(null)
// Why: diff previous vs next targets so unchanged worktrees keep their
// existing subscription. Tearing down every subscription on each targetsKey
@@ -159,12 +165,55 @@ export function useEditorExternalWatch(): void {
const added = nextTargets.filter((t) => !prevKeys.has(getWatchedTargetKey(t)))
for (const target of removed) {
- void window.api.fs.unwatchWorktree({
- worktreePath: target.worktreePath,
- connectionId: target.connectionId
- })
+ const key = getWatchedTargetKey(target)
+ const remoteUnsubscribe = remoteWatchUnsubsRef.current.get(key)
+ if (remoteUnsubscribe) {
+ remoteUnsubscribe()
+ remoteWatchUnsubsRef.current.delete(key)
+ } else {
+ void window.api.fs.unwatchWorktree({
+ worktreePath: target.worktreePath,
+ connectionId: target.connectionId
+ })
+ }
}
for (const target of added) {
+ if (target.runtimeEnvironmentId) {
+ const key = getWatchedTargetKey(target)
+ let cancelled = false
+ const pendingUnsubscribe = (): void => {
+ cancelled = true
+ }
+ remoteWatchUnsubsRef.current.set(key, pendingUnsubscribe)
+ void subscribeRuntimeFileChanges(
+ {
+ settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId },
+ worktreeId: target.worktreeId,
+ worktreePath: target.worktreePath,
+ connectionId: target.connectionId
+ },
+ (payload) => fsChangedHandlerRef.current?.(payload),
+ (err) => warnExternalWatchFailure(target, err)
+ )
+ .then((unsubscribe) => {
+ if (cancelled) {
+ unsubscribe()
+ return
+ }
+ if (remoteWatchUnsubsRef.current.get(key) === pendingUnsubscribe) {
+ remoteWatchUnsubsRef.current.set(key, unsubscribe)
+ } else {
+ unsubscribe()
+ }
+ })
+ .catch((err) => {
+ if (remoteWatchUnsubsRef.current.get(key) === pendingUnsubscribe) {
+ remoteWatchUnsubsRef.current.delete(key)
+ }
+ warnExternalWatchFailure(target, err)
+ })
+ continue
+ }
void window.api.fs
.watchWorktree({
worktreePath: target.worktreePath,
@@ -188,25 +237,37 @@ export function useEditorExternalWatch(): void {
// single always-mounted effect avoids re-subscribing on every targetsKey
// change (which would otherwise miss events fired during re-subscription).
useEffect(() => {
+ const remoteWatchUnsubs = remoteWatchUnsubsRef.current
const { handleFsChanged, dispose } = createExternalWatchEventHandler((worktreePath) =>
targetsRef.current.find(
- (t) => normalizeAbsolutePath(t.worktreePath) === normalizeAbsolutePath(worktreePath)
+ (t) =>
+ normalizeRuntimePathForComparison(t.worktreePath) ===
+ normalizeRuntimePathForComparison(worktreePath)
)
)
const unsubscribe = window.api.fs.onFsChanged(handleFsChanged)
+ fsChangedHandlerRef.current = handleFsChanged
return () => {
unsubscribe()
dispose()
+ fsChangedHandlerRef.current = null
// Why: final unmount must tear down every outstanding subscription.
// The differential watch effect above intentionally never unwatches on
// cleanup, so this is the only place that clears them.
for (const target of targetsRef.current) {
- void window.api.fs.unwatchWorktree({
- worktreePath: target.worktreePath,
- connectionId: target.connectionId
- })
+ const key = getWatchedTargetKey(target)
+ const remoteUnsubscribe = remoteWatchUnsubs.get(key)
+ if (remoteUnsubscribe) {
+ remoteUnsubscribe()
+ } else {
+ void window.api.fs.unwatchWorktree({
+ worktreePath: target.worktreePath,
+ connectionId: target.connectionId
+ })
+ }
}
+ remoteWatchUnsubs.clear()
targetsRef.current = []
// Why: deliberately do NOT clear pendingExternalReloadTimers here.
// The map is module-scoped, so in React StrictMode (dev) the first
@@ -253,7 +314,7 @@ export function createExternalWatchEventHandler(
continue
}
if (evt.kind === 'create' || evt.kind === 'update') {
- createOrUpdatePaths.add(normalizeAbsolutePath(evt.absolutePath))
+ createOrUpdatePaths.add(normalizeRuntimePathForComparison(evt.absolutePath))
}
}
for (const createdPath of createOrUpdatePaths) {
@@ -346,7 +407,7 @@ export function createExternalWatchEventHandler(
file.worktreeId === target.worktreeId &&
(file.mode === 'edit' || file.mode === 'markdown-preview') &&
file.externalMutation &&
- createOrUpdatePaths.has(normalizeAbsolutePath(file.filePath))
+ createOrUpdatePaths.has(normalizeRuntimePathForComparison(file.filePath))
) {
state.setExternalMutation(file.id, null)
}
@@ -384,7 +445,7 @@ export function createExternalWatchEventHandler(
const relativePath = getExternalFileChangeRelativePath(
target.worktreePath,
- normalizeAbsolutePath(evt.absolutePath),
+ evt.absolutePath,
evt.isDirectory
)
if (relativePath) {
@@ -481,7 +542,7 @@ function buildDeletePathByFileId(
const deletePaths = new Set()
for (const evt of payload.events) {
if (evt.kind === 'delete') {
- deletePaths.add(normalizeAbsolutePath(evt.absolutePath))
+ deletePaths.add(normalizeRuntimePathForComparison(evt.absolutePath))
}
}
const result = new Map()
@@ -493,7 +554,7 @@ function buildDeletePathByFileId(
if (!deletedIdSet.has(file.id) || file.worktreeId !== worktreeId) {
continue
}
- const normalized = normalizeAbsolutePath(file.filePath)
+ const normalized = normalizeRuntimePathForComparison(file.filePath)
if (deletePaths.has(normalized)) {
result.set(file.id, normalized)
}
@@ -509,7 +570,7 @@ function collectDeletedOpenEditorIds(
const deletePaths = new Set()
for (const evt of payload.events) {
if (evt.kind === 'delete') {
- deletePaths.add(normalizeAbsolutePath(evt.absolutePath))
+ deletePaths.add(normalizeRuntimePathForComparison(evt.absolutePath))
}
}
if (deletePaths.size === 0) {
@@ -523,7 +584,7 @@ function collectDeletedOpenEditorIds(
) {
continue
}
- if (deletePaths.has(normalizeAbsolutePath(file.filePath))) {
+ if (deletePaths.has(normalizeRuntimePathForComparison(file.filePath))) {
result.push(file.id)
}
}
@@ -564,7 +625,7 @@ function hasRenameCorrelatedCreate(
if (!deletedIdSet.has(file.id)) {
continue
}
- deletedBasenames.add(basename(normalizeAbsolutePath(file.filePath)))
+ deletedBasenames.add(basename(file.filePath))
}
if (deletedBasenames.size === 0) {
return false
@@ -573,7 +634,7 @@ function hasRenameCorrelatedCreate(
if (evt.kind !== 'create' || evt.isDirectory === true) {
continue
}
- if (deletedBasenames.has(basename(normalizeAbsolutePath(evt.absolutePath)))) {
+ if (deletedBasenames.has(basename(evt.absolutePath))) {
return true
}
}
diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts
index 06861899518..49decd64ec5 100644
--- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts
+++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts
@@ -5,7 +5,12 @@
// existing repoPath-keyed hooks stay focused on the local-workspace flow
// and so this file remains under the lint line cap.
import { useEffect, useRef, useState } from 'react'
-import type { GitHubAssignableUser } from '../../../shared/types'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
+import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types'
+import type {
+ ListAssignableUsersBySlugResult,
+ ListLabelsBySlugResult
+} from '../../../shared/github-project-types'
import {
clearMetadataRequestStore,
createMetadataRequestStore,
@@ -29,7 +34,8 @@ export function clearGitHubSlugMetadataCache(): void {
export function useRepoLabelsBySlug(
owner: string | null,
- repo: string | null
+ repo: string | null,
+ settings?: Pick | null
): MetadataState {
const [state, setState] = useState>({
data: [],
@@ -42,7 +48,11 @@ export function useRepoLabelsBySlug(
if (!owner || !repo) {
return
}
- const key = `${owner}/${repo}`
+ const target = getActiveRuntimeTarget(settings)
+ const key =
+ target.kind === 'environment'
+ ? `runtime:${target.environmentId}:${owner}/${repo}`
+ : `${owner}/${repo}`
const cached = getFreshMetadata(slugLabelStore, key)
if (cached) {
@@ -64,7 +74,15 @@ export function useRepoLabelsBySlug(
error: null
}))
loadMetadata(slugLabelStore, key, () =>
- window.api.gh.listLabelsBySlug({ owner, repo }).then((res) => {
+ (target.kind === 'environment'
+ ? callRuntimeRpc(
+ target,
+ 'github.project.listLabelsBySlug',
+ { owner, repo },
+ { timeoutMs: 30_000 }
+ )
+ : window.api.gh.listLabelsBySlug({ owner, repo })
+ ).then((res) => {
if (!res.ok) {
throw new Error(res.error.message)
}
@@ -88,7 +106,7 @@ export function useRepoLabelsBySlug(
error: err instanceof Error ? err.message : 'Failed to load labels'
}))
})
- }, [owner, repo])
+ }, [owner, repo, settings])
return state
}
@@ -96,7 +114,8 @@ export function useRepoLabelsBySlug(
export function useRepoAssigneesBySlug(
owner: string | null,
repo: string | null,
- seedLogins?: string[]
+ seedLogins?: string[],
+ settings?: Pick | null
): MetadataState {
const [state, setState] = useState>({
data: [],
@@ -113,7 +132,11 @@ export function useRepoAssigneesBySlug(
if (!owner || !repo) {
return
}
- const key = `${owner}/${repo}#${seedKey}`
+ const target = getActiveRuntimeTarget(settings)
+ const key =
+ target.kind === 'environment'
+ ? `runtime:${target.environmentId}:${owner}/${repo}#${seedKey}`
+ : `${owner}/${repo}#${seedKey}`
const cached = getFreshMetadata(slugAssigneeStore, key)
if (cached) {
@@ -133,19 +156,26 @@ export function useRepoAssigneesBySlug(
loading: true,
error: null
}))
+ const args = {
+ owner,
+ repo,
+ ...(seedKey ? { seedLogins: seedKey.split(',') } : {})
+ }
loadMetadata(slugAssigneeStore, key, () =>
- window.api.gh
- .listAssignableUsersBySlug({
- owner,
- repo,
- ...(seedKey ? { seedLogins: seedKey.split(',') } : {})
- })
- .then((res) => {
- if (!res.ok) {
- throw new Error(res.error.message)
- }
- return res.users
- })
+ (target.kind === 'environment'
+ ? callRuntimeRpc(
+ target,
+ 'github.project.listAssignableUsersBySlug',
+ args,
+ { timeoutMs: 30_000 }
+ )
+ : window.api.gh.listAssignableUsersBySlug(args)
+ ).then((res) => {
+ if (!res.ok) {
+ throw new Error(res.error.message)
+ }
+ return res.users
+ })
)
.then((data) => {
if (activeKeyRef.current !== requestKey) {
@@ -164,7 +194,7 @@ export function useRepoAssigneesBySlug(
error: err instanceof Error ? err.message : 'Failed to load assignees'
}))
})
- }, [owner, repo, seedKey])
+ }, [owner, repo, seedKey, settings])
return state
}
diff --git a/src/renderer/src/hooks/useGlobalFileDrop.test.ts b/src/renderer/src/hooks/useGlobalFileDrop.test.ts
new file mode 100644
index 00000000000..1439e401c69
--- /dev/null
+++ b/src/renderer/src/hooks/useGlobalFileDrop.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { shouldUploadRemoteEditorFileDrop } from './useGlobalFileDrop'
+
+describe('shouldUploadRemoteEditorFileDrop', () => {
+ it('does not upload editor drops for local workspaces', () => {
+ expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false)
+ })
+
+ it('uploads editor drops while a runtime environment is active', () => {
+ expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe(
+ true
+ )
+ })
+
+ it('uploads editor drops for SSH workspaces', () => {
+ expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe(
+ true
+ )
+ })
+})
diff --git a/src/renderer/src/hooks/useGlobalFileDrop.ts b/src/renderer/src/hooks/useGlobalFileDrop.ts
index b5020e94271..4f0564fee00 100644
--- a/src/renderer/src/hooks/useGlobalFileDrop.ts
+++ b/src/renderer/src/hooks/useGlobalFileDrop.ts
@@ -1,8 +1,24 @@
import { useEffect } from 'react'
+import { toast } from 'sonner'
import { detectLanguage } from '@/lib/language-detect'
import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
+import { joinPath } from '@/lib/path'
+import {
+ importExternalPathsToRuntime,
+ isRemoteRuntimeFileOperation,
+ statRuntimePath,
+ type RuntimeFileOperationArgs
+} from '@/runtime/runtime-file-client'
+import type { GlobalSettings } from '../../../shared/types'
+
+export function shouldUploadRemoteEditorFileDrop(
+ settings: Pick | null | undefined,
+ connectionId: string | null | undefined
+): boolean {
+ return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim())
+}
export function useGlobalFileDrop(): void {
useEffect(() => {
@@ -19,6 +35,58 @@ export function useGlobalFileDrop(): void {
const activeWorktree = store.allWorktrees().find((w) => w.id === activeWorktreeId)
const worktreePath = activeWorktree?.path
+ const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ const dropSettings = store.settings
+ const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId?.trim() || undefined
+ if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) {
+ if (!worktreePath) {
+ toast.error('No remote workspace path is available for dropped files.')
+ return
+ }
+ void (async () => {
+ try {
+ // Why: OS file drops provide client-local paths. Remote runtime and
+ // SSH editors must upload into the server worktree before opening.
+ const destinationDir = joinPath(worktreePath, '.orca/drops')
+ const { results } = await importExternalPathsToRuntime(
+ {
+ settings: dropSettings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ },
+ data.paths,
+ destinationDir,
+ { ensureDestinationDir: true }
+ )
+ const imported = results.filter((result) => result.status === 'imported')
+ for (const result of imported) {
+ if (result.kind === 'directory') {
+ continue
+ }
+ const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath)
+ store.setActiveTabType('editor')
+ store.openFile(
+ {
+ filePath: result.destPath,
+ relativePath: maybeRelative ?? result.destPath,
+ worktreeId: activeWorktreeId,
+ runtimeEnvironmentId,
+ language: detectLanguage(result.destPath),
+ mode: 'edit'
+ },
+ { suppressActiveRuntimeFallback: runtimeEnvironmentId === undefined }
+ )
+ }
+ if (results.some((result) => result.status !== 'imported')) {
+ toast.error('Some dropped files could not be uploaded.')
+ }
+ } catch {
+ toast.error('Failed to upload dropped files.')
+ }
+ })()
+ return
+ }
// Why: the relay payload now sends all paths in one gesture-scoped event.
// Loop over every dropped file so multi-file editor drops still open
@@ -26,12 +94,18 @@ export function useGlobalFileDrop(): void {
for (const filePath of data.paths) {
void (async () => {
try {
- const connectionId = getConnectionId(activeWorktreeId) ?? undefined
- // Why: remote paths don't need local auth — the relay is the security boundary.
- if (!connectionId) {
+ const fileContext: RuntimeFileOperationArgs = {
+ settings: store.settings,
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId
+ }
+ const isRemoteRuntimePath = isRemoteRuntimeFileOperation(fileContext, filePath)
+ // Why: remote paths don't need local auth — the relay/runtime is the security boundary.
+ if (!connectionId && !isRemoteRuntimePath) {
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
}
- const stat = await window.api.fs.stat({ filePath, connectionId })
+ const stat = await statRuntimePath(fileContext, filePath)
if (stat.isDirectory) {
return
}
diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts
index a91316e83c9..b73359289de 100644
--- a/src/renderer/src/hooks/useIpcEvents.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents.test.ts
@@ -2,6 +2,16 @@
import type * as ReactModule from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveZoomTarget } from './useIpcEvents'
+import { makePaneKey } from '../../../shared/stable-pane-id'
+
+const FUTURE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
+const STALE_LEAF_ID = '22222222-2222-4222-8222-222222222222'
+const ORPHAN_LEAF_ID = '33333333-3333-4333-8333-333333333333'
+const TAB_1_LEAF_ID = '44444444-4444-4444-8444-444444444444'
+const FUTURE_PANE_KEY = makePaneKey('tab-future', FUTURE_LEAF_ID)
+const STALE_PANE_KEY = makePaneKey('tab-future', STALE_LEAF_ID)
+const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', ORPHAN_LEAF_ID)
+const TAB_1_PANE_KEY = makePaneKey('tab-1', TAB_1_LEAF_ID)
function makeTarget(args: { hasXtermClass?: boolean; editorClosest?: boolean }): {
classList: { contains: (token: string) => boolean }
@@ -217,6 +227,7 @@ describe('useIpcEvents updater integration', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -425,6 +436,7 @@ describe('useIpcEvents updater integration', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -657,6 +669,7 @@ describe('useIpcEvents updater integration', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -950,6 +963,7 @@ describe('useIpcEvents browser tab close routing', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -1156,6 +1170,7 @@ describe('useIpcEvents browser tab close routing', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -1357,6 +1372,7 @@ describe('useIpcEvents browser tab close routing', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -1567,6 +1583,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -1665,6 +1682,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
removeSshCredentialRequest: vi.fn(),
clearTabPtyId: vi.fn(),
runtimePaneTitlesByTabId: {},
+ terminalLayoutsByTabId: {},
repos: [],
worktreesByRepo: {},
tabsByWorktree: {},
@@ -1678,6 +1696,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
onSet: (cb: (data: AgentStatusSetData) => void) => () => void
getSnapshot?: () => Promise
drop?: (paneKey: string) => void
+ remoteWorkspace?: Record
}): Record {
return {
api: {
@@ -1748,6 +1767,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
},
runtime: {
getTerminalFitOverrides: () => Promise.resolve([]),
+ getTerminalDrivers: () => Promise.resolve([]),
onTerminalFitOverrideChanged: () => () => {},
onTerminalDriverChanged: () => () => {}
},
@@ -1766,7 +1786,8 @@ describe('useIpcEvents agent status snapshot integration', () => {
onSet: args.onSet,
getSnapshot: args.getSnapshot ?? vi.fn(() => Promise.resolve([])),
drop: args.drop ?? vi.fn()
- }
+ },
+ remoteWorkspace: args.remoteWorkspace
}
}
}
@@ -1814,7 +1835,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
const getSnapshot = vi.fn(() =>
Promise.resolve([
{
- paneKey: 'tab-future:0',
+ paneKey: FUTURE_PANE_KEY,
state: 'working' as const,
prompt: 'p',
agentType: 'claude',
@@ -1869,7 +1890,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
// Fire an event for an unknown paneKey while not ready — must NOT call setAgentStatus.
onSetListenerRef.current({
- paneKey: 'tab-future:0',
+ paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'p',
agentType: 'claude',
@@ -1883,6 +1904,13 @@ describe('useIpcEvents agent status snapshot integration', () => {
storeState.tabsByWorktree = {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
}
+ storeState.terminalLayoutsByTabId = {
+ 'tab-future': {
+ root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
+ activeLeafId: FUTURE_LEAF_ID,
+ expandedLeafId: null
+ }
+ }
if (typeof subscribeListenerRef.current !== 'function') {
throw new Error('Expected useAppStore.subscribe listener to be registered')
}
@@ -1891,7 +1919,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(setAgentStatus).toHaveBeenCalledWith(
- 'tab-future:0',
+ FUTURE_PANE_KEY,
expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }),
'Future Tab',
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }
@@ -1997,12 +2025,109 @@ describe('useIpcEvents agent status snapshot integration', () => {
expect(setAgentStatus).not.toHaveBeenCalled()
})
+ it('waits for the remote workspace client id before dropping self notifications', async () => {
+ const hydrateWorkspaceSession = vi.fn()
+ const hydrateTabsSession = vi.fn()
+ const hydrateEditorSession = vi.fn()
+ const hydrateBrowserSession = vi.fn()
+ let resolveClientId!: (id: string) => void
+ const clientId = new Promise((resolve) => {
+ resolveClientId = resolve
+ })
+ const onChangedListenerRef: {
+ current:
+ | ((event: {
+ targetId: string
+ sourceClientId?: string
+ snapshot: Record
+ }) => void)
+ | null
+ } = { current: null }
+ const storeState: StoreLike = buildStoreState({
+ workspaceSessionReady: true,
+ repos: [{ id: 'repo-1', connectionId: 'conn-1' }],
+ worktreesByRepo: { 'repo-1': [{ id: 'repo-1::/repo', repoId: 'repo-1' }] },
+ hydrateWorkspaceSession,
+ hydrateTabsSession,
+ hydrateEditorSession,
+ hydrateBrowserSession,
+ markRemoteWorkspaceHydrated: vi.fn(),
+ setRemoteWorkspaceSyncStatus: vi.fn(),
+ reconnectPersistedTerminals: vi.fn(() => Promise.resolve())
+ })
+
+ stubReactSyncEffect()
+ vi.doMock('../store', () => ({
+ useAppStore: {
+ subscribe: vi.fn(() => () => {}),
+ getState: () => storeState
+ }
+ }))
+ stubAuxiliaryModules()
+ vi.stubGlobal(
+ 'window',
+ buildWindowApi({
+ onSet: () => () => {},
+ remoteWorkspace: {
+ clientId: () => clientId,
+ onChanged: (cb: typeof onChangedListenerRef.current) => {
+ onChangedListenerRef.current = cb
+ return () => {}
+ }
+ }
+ })
+ )
+
+ const { useIpcEvents } = await import('./useIpcEvents')
+
+ useIpcEvents()
+ await Promise.resolve()
+
+ onChangedListenerRef.current?.({
+ targetId: 'conn-1',
+ sourceClientId: 'client-self',
+ snapshot: {
+ revision: 1,
+ updatedAt: Date.now(),
+ session: {
+ activeWorktreePath: '/repo',
+ activeTabId: 'tab-1',
+ tabsByWorktreePath: {
+ '/repo': [
+ {
+ id: 'tab-1',
+ ptyId: null,
+ worktreePath: '/repo',
+ title: 'Remote',
+ customTitle: null,
+ color: null,
+ sortOrder: 1,
+ createdAt: 1
+ }
+ ]
+ },
+ terminalLayoutsByTabId: {}
+ }
+ }
+ })
+ await Promise.resolve()
+ expect(hydrateWorkspaceSession).not.toHaveBeenCalled()
+
+ resolveClientId('client-self')
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(hydrateWorkspaceSession).not.toHaveBeenCalled()
+ expect(hydrateTabsSession).not.toHaveBeenCalled()
+ expect(hydrateEditorSession).not.toHaveBeenCalled()
+ expect(hydrateBrowserSession).not.toHaveBeenCalled()
+ })
+
it('silently discards snapshot entries whose tabs are still unknown', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>
Promise.resolve([
{
- paneKey: 'tab-orphan:0',
+ paneKey: ORPHAN_PANE_KEY,
state: 'done' as const,
prompt: 'p',
agentType: 'claude',
@@ -2017,6 +2142,68 @@ describe('useIpcEvents agent status snapshot integration', () => {
tabsByWorktree: {
'wt-1': [{ id: 'tab-other', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Other' }]
},
+ terminalLayoutsByTabId: {
+ 'tab-orphan': {
+ root: { type: 'leaf', leafId: ORPHAN_LEAF_ID },
+ activeLeafId: ORPHAN_LEAF_ID,
+ expandedLeafId: null
+ }
+ },
+ workspaceSessionReady: true
+ })
+
+ stubReactSyncEffect()
+ vi.doMock('../store', () => ({
+ useAppStore: {
+ subscribe: vi.fn(() => () => {}),
+ getState: () => storeState
+ }
+ }))
+ stubAuxiliaryModules()
+ vi.stubGlobal(
+ 'window',
+ buildWindowApi({
+ getSnapshot,
+ onSet: () => () => {}
+ })
+ )
+
+ const { useIpcEvents } = await import('./useIpcEvents')
+
+ useIpcEvents()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(setAgentStatus).not.toHaveBeenCalled()
+ })
+
+ it('silently discards valid paneKeys whose leaf is not in the current layout', async () => {
+ const setAgentStatus = vi.fn()
+ const getSnapshot = vi.fn(() =>
+ Promise.resolve([
+ {
+ paneKey: STALE_PANE_KEY,
+ state: 'done' as const,
+ prompt: 'p',
+ agentType: 'claude',
+ receivedAt: 1_700_000_000_000,
+ stateStartedAt: 1_699_999_999_000
+ }
+ ])
+ )
+
+ const storeState: StoreLike = buildStoreState({
+ setAgentStatus,
+ tabsByWorktree: {
+ 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
+ },
+ terminalLayoutsByTabId: {
+ 'tab-future': {
+ root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
+ activeLeafId: FUTURE_LEAF_ID,
+ expandedLeafId: null
+ }
+ },
workspaceSessionReady: true
})
@@ -2059,6 +2246,13 @@ describe('useIpcEvents agent status snapshot integration', () => {
},
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
+ },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
+ activeLeafId: TAB_1_LEAF_ID,
+ expandedLeafId: null
+ }
}
})
@@ -2082,7 +2276,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
await Promise.resolve()
onSetListenerRef.current?.({
- paneKey: 'tab-1:0',
+ paneKey: TAB_1_PANE_KEY,
connectionId: 'conn-1',
state: 'working',
receivedAt: 1_700_000_000_100,
@@ -2090,7 +2284,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
})
expect(setAgentStatus).toHaveBeenCalledWith(
- 'tab-1:0',
+ TAB_1_PANE_KEY,
expect.objectContaining({ state: 'working' }),
'Terminal 1',
{ updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 }
@@ -2111,6 +2305,13 @@ describe('useIpcEvents agent status snapshot integration', () => {
},
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
+ },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
+ activeLeafId: TAB_1_LEAF_ID,
+ expandedLeafId: null
+ }
}
})
@@ -2134,7 +2335,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
await Promise.resolve()
onSetListenerRef.current?.({
- paneKey: 'tab-1:0',
+ paneKey: TAB_1_PANE_KEY,
connectionId: 'conn-stale',
state: 'working',
receivedAt: 1_700_000_000_100,
@@ -2156,6 +2357,13 @@ describe('useIpcEvents agent status snapshot integration', () => {
worktreesByRepo: { 'repo-1': [] },
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
+ },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
+ activeLeafId: TAB_1_LEAF_ID,
+ expandedLeafId: null
+ }
}
})
@@ -2179,7 +2387,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
await Promise.resolve()
onSetListenerRef.current?.({
- paneKey: 'tab-1:0',
+ paneKey: TAB_1_PANE_KEY,
connectionId: 'conn-other',
state: 'working',
receivedAt: 1_700_000_000_100,
@@ -2203,6 +2411,13 @@ describe('useIpcEvents agent status snapshot integration', () => {
},
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 1' }]
+ },
+ terminalLayoutsByTabId: {
+ 'tab-1': {
+ root: { type: 'leaf', leafId: TAB_1_LEAF_ID },
+ activeLeafId: TAB_1_LEAF_ID,
+ expandedLeafId: null
+ }
}
})
@@ -2226,7 +2441,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
await Promise.resolve()
onSetListenerRef.current?.({
- paneKey: 'tab-1:0',
+ paneKey: TAB_1_PANE_KEY,
state: 'working',
receivedAt: 1_700_000_000_100,
stateStartedAt: 1_699_999_999_100
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index 52b3987e16e..ea052deeac1 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -9,9 +9,15 @@ import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constant
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
-import type { UpdateStatus } from '../../../shared/types'
+import type { UpdateStatus, WorkspaceSessionState } from '../../../shared/types'
+import type {
+ RemoteWorkspacePatchResult,
+ RemoteWorkspaceSnapshot
+} from '../../../shared/remote-workspace-types'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { SshConnectionState } from '../../../shared/ssh-types'
+import type { RuntimeTerminalDriverState } from '../../../shared/runtime-types'
+import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection'
import { zoomLevelToPercent, ZOOM_MIN, ZOOM_MAX } from '@/components/settings/SettingsConstants'
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { resolveZoomTarget } from './resolve-zoom-target'
@@ -29,16 +35,288 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph'
import { setFitOverride, hydrateOverrides } from '@/lib/pane-manager/mobile-fit-overrides'
-import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
+import { setDriverForPty, hydrateDrivers } from '@/lib/pane-manager/mobile-driver-state'
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { detectLanguage } from '@/lib/language-detect'
+import { parsePaneKey } from '../../../shared/stable-pane-id'
+import { collectLeafIdsInOrder } from '@/components/terminal-pane/layout-serialization'
import { track } from '@/lib/telemetry'
-import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request'
+import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
+import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
export { resolveZoomTarget } from './resolve-zoom-target'
const ZOOM_STEP = 0.5
+let remoteWorkspaceSnapshotApplyDepth = 0
+let remoteWorkspaceSnapshotWriteSuppressUntil = 0
+const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
+
+export function isRemoteWorkspaceSnapshotApplyInProgress(): boolean {
+ return (
+ remoteWorkspaceSnapshotApplyDepth > 0 || Date.now() < remoteWorkspaceSnapshotWriteSuppressUntil
+ )
+}
+
+async function waitForWorkspaceSessionReady(): Promise {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (useAppStore.getState().workspaceSessionReady) {
+ return true
+ }
+ await new Promise((resolve) => window.setTimeout(resolve, 100))
+ }
+ return useAppStore.getState().workspaceSessionReady
+}
+
+async function prepareRemoteWorkspaceTarget(targetId: string): Promise {
+ if (!(await waitForWorkspaceSessionReady())) {
+ return false
+ }
+ const store = useAppStore.getState()
+ let repos = store.repos.filter((repo) => repo.connectionId === targetId)
+ if (repos.length === 0) {
+ await store.fetchRepos()
+ repos = useAppStore.getState().repos.filter((repo) => repo.connectionId === targetId)
+ }
+ await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id)))
+ return true
+}
+
+function targetRepoIds(targetId: string): Set {
+ return new Set(
+ useAppStore
+ .getState()
+ .repos.filter((repo) => repo.connectionId === targetId)
+ .map((repo) => repo.id)
+ )
+}
+
+function targetWorktreeIds(targetId: string): Set {
+ const repoIds = targetRepoIds(targetId)
+ return new Set(
+ Object.values(useAppStore.getState().worktreesByRepo)
+ .flat()
+ .filter((worktree) => repoIds.has(worktree.repoId))
+ .map((worktree) => worktree.id)
+ )
+}
+
+function mergeRemoteWorkspaceSession(
+ current: WorkspaceSessionState,
+ remote: WorkspaceSessionState,
+ targetId: string
+): WorkspaceSessionState {
+ const replaceWorktreeIds = targetWorktreeIds(targetId)
+ const remoteTabIds = new Set(
+ Object.values(remote.tabsByWorktree)
+ .flat()
+ .map((tab) => tab.id)
+ )
+ const replacedTabIds = new Set([
+ ...remoteTabIds,
+ ...Object.entries(current.tabsByWorktree)
+ .filter(([worktreeId]) => replaceWorktreeIds.has(worktreeId))
+ .flatMap(([, tabs]) => tabs.map((tab) => tab.id))
+ ])
+ const omitTargetWorktrees = (record: Record | undefined): Record =>
+ Object.fromEntries(
+ Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId))
+ )
+
+ return {
+ ...current,
+ activeRepoId:
+ remote.activeRepoId ??
+ (current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId)
+ ? null
+ : current.activeRepoId),
+ activeWorktreeId:
+ remote.activeWorktreeId ??
+ (current.activeWorktreeId && replaceWorktreeIds.has(current.activeWorktreeId)
+ ? null
+ : current.activeWorktreeId),
+ activeTabId:
+ remote.activeTabId ??
+ (current.activeTabId && replacedTabIds.has(current.activeTabId) ? null : current.activeTabId),
+ tabsByWorktree: {
+ ...omitTargetWorktrees(current.tabsByWorktree),
+ ...remote.tabsByWorktree
+ },
+ terminalLayoutsByTabId: {
+ ...Object.fromEntries(
+ Object.entries(current.terminalLayoutsByTabId).filter(
+ ([tabId]) => !replacedTabIds.has(tabId)
+ )
+ ),
+ ...remote.terminalLayoutsByTabId
+ },
+ activeWorktreeIdsOnShutdown: [
+ ...(current.activeWorktreeIdsOnShutdown ?? []).filter((id) => !replaceWorktreeIds.has(id)),
+ ...(remote.activeWorktreeIdsOnShutdown ?? [])
+ ],
+ activeTabIdByWorktree: {
+ ...omitTargetWorktrees(current.activeTabIdByWorktree),
+ ...remote.activeTabIdByWorktree
+ },
+ remoteSessionIdsByTabId: {
+ ...Object.fromEntries(
+ Object.entries(current.remoteSessionIdsByTabId ?? {}).filter(
+ ([tabId]) => !replacedTabIds.has(tabId)
+ )
+ ),
+ ...remote.remoteSessionIdsByTabId
+ },
+ lastVisitedAtByWorktreeId: {
+ ...omitTargetWorktrees(current.lastVisitedAtByWorktreeId),
+ ...remote.lastVisitedAtByWorktreeId
+ }
+ }
+}
+
+async function applyRemoteWorkspaceSnapshot(
+ targetId: string,
+ snapshot: RemoteWorkspaceSnapshot
+): Promise {
+ if (!(await prepareRemoteWorkspaceTarget(targetId))) {
+ throw new Error('Workspace sync waited for local session hydration and timed out')
+ }
+ const worktreeIds = targetWorktreeIds(targetId)
+ const localByPath = new Map(
+ Array.from(worktreeIds).map((worktreeId) => {
+ const separator = worktreeId.indexOf('::')
+ return [separator === -1 ? worktreeId : worktreeId.slice(separator + 2), worktreeId] as const
+ })
+ )
+ const remoteSession = importRemoteWorkspaceSession(snapshot.session, {
+ resolveWorktreeId: (worktreePath) => localByPath.get(worktreePath) ?? null
+ })
+ const current = buildWorkspaceSessionPayload(useAppStore.getState())
+ const merged = mergeRemoteWorkspaceSession(current, remoteSession, targetId)
+ const store = useAppStore.getState()
+ remoteWorkspaceSnapshotApplyDepth += 1
+ try {
+ store.hydrateWorkspaceSession(merged)
+ store.hydrateTabsSession(merged)
+ store.hydrateEditorSession(merged)
+ store.hydrateBrowserSession(merged)
+ store.markRemoteWorkspaceHydrated(targetId)
+ store.setRemoteWorkspaceSyncStatus(targetId, {
+ phase: 'synced',
+ direction: 'pull',
+ revision: snapshot.revision,
+ updatedAt: snapshot.updatedAt,
+ lastSyncedAt: Date.now(),
+ message: 'Workspace synced'
+ })
+ await useAppStore.getState().reconnectPersistedTerminals()
+ } finally {
+ // Why: remote terminal reattach can update pty ids and titles just after
+ // hydration. Those local side effects came from the remote snapshot and
+ // must not echo back as a fresh workspace revision.
+ remoteWorkspaceSnapshotWriteSuppressUntil =
+ Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS
+ remoteWorkspaceSnapshotApplyDepth -= 1
+ }
+}
+
+async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise {
+ const store = useAppStore.getState()
+ if (store.sshTargetRemoteSyncEnabled.get(targetId) !== true) {
+ return
+ }
+ if (!(await prepareRemoteWorkspaceTarget(targetId))) {
+ store.setRemoteWorkspaceSyncStatus(targetId, {
+ phase: 'error',
+ direction: 'pull',
+ message: 'Workspace sync waited for local session hydration and timed out'
+ })
+ return
+ }
+ store.setRemoteWorkspaceSyncStatus(targetId, { phase: 'pulling', direction: 'pull' })
+ const worktreeIds = targetWorktreeIds(targetId)
+ const hasLocalTabs = Array.from(worktreeIds).some(
+ (worktreeId) => (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).length > 0
+ )
+ const snapshot = await window.api.remoteWorkspace.get({ targetId })
+ if (!snapshot) {
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
+ phase: 'offline',
+ direction: 'pull',
+ message: 'Remote workspace sync unavailable'
+ })
+ return
+ }
+ if (snapshot.revision > 0) {
+ await applyRemoteWorkspaceSnapshot(targetId, snapshot)
+ return
+ }
+
+ useAppStore.getState().markRemoteWorkspaceHydrated(targetId)
+ if (hasLocalTabs) {
+ // Why: first connect must read the relay before publishing local tabs.
+ // Otherwise a reconnecting device can overwrite a newer cross-device
+ // workspace snapshot with stale local renderer state.
+ const session = buildWorkspaceSessionPayload(useAppStore.getState())
+ const results = await window.api.remoteWorkspace.setForConnectedTargets({
+ session,
+ hydratedTargetIds: [targetId]
+ })
+ const result = results.find((entry) => entry.targetId === targetId)?.result
+ applyRemoteWorkspacePatchStatus(targetId, result)
+ if (result?.ok) {
+ useAppStore.getState().markRemoteWorkspaceHydrated(targetId)
+ }
+ return
+ }
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
+ phase: 'idle',
+ revision: snapshot.revision,
+ updatedAt: snapshot.updatedAt,
+ message: 'No remote workspace yet'
+ })
+}
+
+function applyRemoteWorkspacePatchStatus(
+ targetId: string,
+ result: RemoteWorkspacePatchResult | undefined
+): void {
+ if (!result) {
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
+ phase: 'offline',
+ direction: 'push',
+ lastSyncedAt: Date.now(),
+ message: 'Remote workspace sync unavailable'
+ })
+ return
+ }
+ if (result.ok) {
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
+ phase: 'synced',
+ direction: 'push',
+ revision: result.snapshot.revision,
+ updatedAt: result.snapshot.updatedAt,
+ lastSyncedAt: Date.now(),
+ message: 'Workspace uploaded'
+ })
+ return
+ }
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(targetId, {
+ phase: result.reason === 'stale-revision' ? 'conflict' : 'offline',
+ direction: 'push',
+ revision: result.snapshot?.revision,
+ updatedAt: result.snapshot?.updatedAt,
+ lastSyncedAt: Date.now(),
+ message:
+ result.message ??
+ (result.reason === 'stale-revision'
+ ? 'Workspace changed on another device'
+ : 'Remote workspace sync unavailable')
+ })
+}
+
+function isRuntimeEnvironmentActive(): boolean {
+ return Boolean(useAppStore.getState().settings?.activeRuntimeEnvironmentId?.trim())
+}
export function useIpcEvents(): void {
useEffect(() => {
@@ -48,12 +326,23 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.repos.onChanged(() => {
+ if (isRuntimeEnvironmentActive()) {
+ // Why: this event comes from the local Electron store. While a
+ // runtime server is selected, repo hydration must be driven by the
+ // selected server instead of local-disk changes.
+ return
+ }
useAppStore.getState().fetchRepos()
})
)
unsubs.push(
window.api.worktrees.onChanged(async (data: { repoId: string }) => {
+ if (isRuntimeEnvironmentActive()) {
+ // Why: local worktree events carry local repo ids. Fetching the
+ // active runtime with those ids can purge or overwrite server state.
+ return
+ }
// Why: diff before vs. after fetchWorktrees to detect server-side
// deletions (CLI `orca worktree rm`, other window, out-of-band RPC)
// and purge worktree-scoped state for removed ids. Without this,
@@ -78,18 +367,25 @@ export function useIpcEvents(): void {
removed
)
afterState.purgeWorktreeTerminalState(removed)
+ afterState.removeWorkspaceSpaceWorktrees(removed)
}
})
)
unsubs.push(
window.api.worktrees.onBaseStatus((event) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
useAppStore.getState().updateWorktreeBaseStatus(event)
})
)
unsubs.push(
window.api.worktrees.onRemoteBranchConflict((event) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
useAppStore.getState().updateWorktreeRemoteBranchConflict(event)
})
)
@@ -231,6 +527,12 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup }) => {
void (async () => {
+ if (isRuntimeEnvironmentActive()) {
+ // Why: local CLI-created worktree events carry local repo/worktree
+ // ids. Runtime server activation arrives through runtime state,
+ // not this local Electron event.
+ return
+ }
// Why: fetch worktrees first so the activation helper can resolve
// the CLI-created worktree via findWorktreeById — it arrived from
// the main process and is not yet in the renderer state.
@@ -249,8 +551,17 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onCreateTerminal(
- ({ requestId, worktreeId, command, title, ptyId, activate, tabId }) => {
+ ({ requestId, worktreeId, command, title, ptyId, activate, tabId, leafId }) => {
try {
+ if (isRuntimeEnvironmentActive()) {
+ if (requestId) {
+ window.api.ui.replyTerminalCreate({
+ requestId,
+ error: 'Local terminal reveal is unavailable while a remote runtime is active'
+ })
+ }
+ return
+ }
const store = useAppStore.getState()
const shouldActivate = activate !== false
if (shouldActivate) {
@@ -272,17 +583,15 @@ export function useIpcEvents(): void {
initialPtyId: ptyId,
activate: shouldActivate,
// Why: tabId hint comes from CLI-spawned PTYs whose env
- // already has paneKey=`${tabId}:1` baked in. Adopting the
- // tab under the same id keeps hook-event attribution working;
- // see docs/cli-terminal-hook-pane-key.md.
+ // already has the pane key baked in. Adopting the tab under
+ // the same id keeps hook-event attribution working.
...(tabId !== undefined ? { id: tabId } : {})
}))
: store.createTab(worktreeId)
// Why: when an existing tab already owns this ptyId, we reuse it instead of
- // minting a new one — but the PTY env already carries `paneKey=`${tabId}:1``
- // from main. If the existing tab id doesn't match the hint, hook attribution
- // will degrade for that PTY's lifetime. Warn so this is visible during
- // development; in production this surfaces via `agent_hook_unattributed`.
+ // minting a new one — but the PTY env already carries a paneKey from main.
+ // If the existing tab id doesn't match the hint, hook attribution degrades
+ // for that PTY's lifetime. Warn so this is visible during development.
if (tabId !== undefined && tab.id !== tabId) {
console.warn(
`[onCreateTerminal] tabId hint ${tabId} ignored for ptyId ${ptyId}; existing tab ${tab.id} adopted instead (hook attribution will degrade for this terminal)`
@@ -296,6 +605,12 @@ export function useIpcEvents(): void {
if (title) {
store.setTabCustomTitle(tab.id, title)
}
+ if (leafId && ptyId) {
+ // Why: CLI/runtime-spawned PTYs emit hook events before a hidden
+ // tab mounts TerminalPane, so the adopted UUID leaf must exist
+ // in layout state for paneKey validation to accept them.
+ store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title))
+ }
if (command) {
store.queueTabStartupCommand(tab.id, { command })
}
@@ -325,6 +640,13 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onRequestTerminalCreate((data) => {
try {
+ if (isRuntimeEnvironmentActive()) {
+ window.api.ui.replyTerminalCreate({
+ requestId: data.requestId,
+ error: 'Local terminal creation is unavailable while a remote runtime is active'
+ })
+ return
+ }
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
@@ -412,7 +734,7 @@ export function useIpcEvents(): void {
store.setActiveTab(tabId)
store.revealWorktreeInSidebar(worktreeId)
if (!focusRuntimeTerminalSurface(tabId, leafId)) {
- focusTerminalTabSurface(tabId)
+ focusTerminalTabSurface(tabId, leafId)
}
})
)
@@ -511,6 +833,9 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
useAppStore.getState().updateBrowserPageState(browserPageId, {
loading: false,
loadError,
@@ -526,6 +851,9 @@ export function useIpcEvents(): void {
// This IPC pushes the live URL/title from main after goto/click/back/reload.
unsubs.push(
window.api.browser.onNavigationUpdate(({ browserPageId, url, title }) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
const store = useAppStore.getState()
store.setBrowserPageUrl(browserPageId, url)
store.updateBrowserPageState(browserPageId, { title, loading: false })
@@ -538,6 +866,9 @@ export function useIpcEvents(): void {
// before browser commands so the webview can start and registerGuest fires.
unsubs.push(
window.api.browser.onActivateView(() => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
useAppStore.getState().setActiveTabType('browser')
})
)
@@ -553,6 +884,9 @@ export function useIpcEvents(): void {
// pre-staging for whenever the user next visits that worktree.
unsubs.push(
window.api.browser.onPaneFocus(({ worktreeId, browserPageId }) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
const store = useAppStore.getState()
// Why: main sends `worktreeId: null` if the tab closed between the
// bridge resolving tabSwitch and getWorktreeIdForTab running. Falling
@@ -569,6 +903,9 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
const store = useAppStore.getState()
const sourcePage = Object.values(store.browserPagesByWorkspace)
.flat()
@@ -588,6 +925,9 @@ export function useIpcEvents(): void {
// capture keyboard focus and bypass the renderer's window-level keydown.
unsubs.push(
window.api.ui.onNewBrowserTab(() => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
const store = useAppStore.getState()
const worktreeId = store.activeWorktreeId
if (worktreeId) {
@@ -605,6 +945,15 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onRequestTabCreate((data) => {
try {
+ if (isRuntimeEnvironmentActive()) {
+ // Why: browser automation targets client-local Electron webviews.
+ // Runtime agents cannot see or control those surfaces.
+ window.api.ui.replyTabCreate({
+ requestId: data.requestId,
+ error: 'Browser tabs are unavailable while a remote runtime is active'
+ })
+ return
+ }
const store = useAppStore.getState()
const worktreeId = data.worktreeId ?? store.activeWorktreeId
if (!worktreeId) {
@@ -644,6 +993,13 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onRequestTabSetProfile((data) => {
try {
+ if (isRuntimeEnvironmentActive()) {
+ window.api.ui.replyTabSetProfile({
+ requestId: data.requestId,
+ error: 'Browser profiles are unavailable while a remote runtime is active'
+ })
+ return
+ }
const store = useAppStore.getState()
const owningWorkspace = Object.values(store.browserTabsByWorktree)
.flat()
@@ -685,6 +1041,13 @@ export function useIpcEvents(): void {
unsubs.push(
window.api.ui.onRequestTabClose((data) => {
try {
+ if (isRuntimeEnvironmentActive()) {
+ window.api.ui.replyTabClose({
+ requestId: data.requestId,
+ error: 'Browser tabs are unavailable while a remote runtime is active'
+ })
+ return
+ }
const store = useAppStore.getState()
const explicitTargetId = data.tabId ?? null
let tabToClose =
@@ -780,15 +1143,6 @@ export function useIpcEvents(): void {
const store = useAppStore.getState()
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
store.closeBrowserTab(store.activeBrowserTabId)
- return
- }
- if (store.activeTabType === 'notes' && store.activeWorktreeId) {
- const activeTab = store.getActiveTab(store.activeWorktreeId)
- if (activeTab?.contentType === 'notes') {
- requestProjectNotesTabClose(activeTab.id, () => {
- store.closeUnifiedTab(activeTab.id)
- })
- }
}
})
)
@@ -808,21 +1162,24 @@ export function useIpcEvents(): void {
})
)
+ const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.(
+ (progress) => {
+ useAppStore.getState().applyWorkspaceSpaceProgress(progress)
+ }
+ )
+ if (unsubscribeWorkspaceSpaceProgress) {
+ unsubs.push(unsubscribeWorkspaceSpaceProgress)
+ }
+
// Track SSH connection state changes so the renderer can show
// disconnected indicators on remote worktrees.
// Why: hydrate initial state for all known targets so worktree cards
// reflect the correct connected/disconnected state on app launch.
void (async () => {
try {
- const targets = (await window.api.ssh.listTargets()) as {
- id: string
- label: string
- }[]
- // Why: populate target labels map so WorktreeCard (and other components)
- // can look up display labels without issuing per-card IPC calls.
- const labels = new Map()
+ const targets = await window.api.ssh.listTargets()
+ useAppStore.getState().setSshTargetsMetadata(targets)
for (const target of targets) {
- labels.set(target.id, target.label)
const state = await window.api.ssh.getState({ targetId: target.id })
if (state) {
useAppStore.getState().setSshConnectionState(target.id, state as SshConnectionState)
@@ -843,10 +1200,17 @@ export function useIpcEvents(): void {
useAppStore.getState().setPortForwards(target.id, forwards)
useAppStore.getState().setDetectedPorts(target.id, detected)
}
+ if (target.remoteWorkspaceSyncEnabled) {
+ void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => {
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, {
+ phase: 'error',
+ message: err instanceof Error ? err.message : 'Workspace sync failed'
+ })
+ })
+ }
}
}
}
- useAppStore.getState().setSshTargetLabels(labels)
} catch {
// SSH may not be configured
}
@@ -889,11 +1253,7 @@ export function useIpcEvents(): void {
window.api.ssh
.listTargets()
.then((targets) => {
- const labels = new Map()
- for (const t of targets as { id: string; label: string }[]) {
- labels.set(t.id, t.label)
- }
- useAppStore.getState().setSshTargetLabels(labels)
+ useAppStore.getState().setSshTargetsMetadata(targets)
})
.catch(() => {})
}
@@ -963,11 +1323,59 @@ export function useIpcEvents(): void {
}))
}
}
+ void syncRemoteWorkspaceAfterConnect(data.targetId).catch((err) => {
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(data.targetId, {
+ phase: 'error',
+ message: err instanceof Error ? err.message : 'Workspace sync failed'
+ })
+ })
})
}
})
)
+ let remoteWorkspaceClientId: string | null = null
+ let remoteWorkspaceClientIdPromise: Promise | null = null
+ const getRemoteWorkspaceClientId = (): Promise => {
+ const remoteWorkspace = window.api.remoteWorkspace
+ if (!remoteWorkspace) {
+ return Promise.resolve(null)
+ }
+ if (remoteWorkspaceClientId) {
+ return Promise.resolve(remoteWorkspaceClientId)
+ }
+ remoteWorkspaceClientIdPromise ??= remoteWorkspace
+ .clientId()
+ .then((id) => {
+ remoteWorkspaceClientId = id
+ return id
+ })
+ .catch(() => null)
+ return remoteWorkspaceClientIdPromise
+ }
+ if (window.api.remoteWorkspace) {
+ void getRemoteWorkspaceClientId()
+ unsubs.push(
+ window.api.remoteWorkspace.onChanged((event) => {
+ void (async () => {
+ // Why: relay notifications can race the initial client-id IPC.
+ // Self-originated writes must never bounce back into restore.
+ const clientId = await getRemoteWorkspaceClientId()
+ if (event.sourceClientId && clientId && event.sourceClientId === clientId) {
+ return
+ }
+ await applyRemoteWorkspaceSnapshot(event.targetId, event.snapshot).catch((err) => {
+ useAppStore.getState().setRemoteWorkspaceSyncStatus(event.targetId, {
+ phase: 'error',
+ revision: event.snapshot.revision,
+ message: err instanceof Error ? err.message : 'Failed to apply remote workspace'
+ })
+ })
+ })()
+ })
+ )
+ }
+
// Zoom handling for menu accelerators and keyboard fallback paths.
unsubs.push(
window.api.ui.onTerminalZoom((direction) => {
@@ -1094,6 +1502,22 @@ export function useIpcEvents(): void {
for (const entry of entries) {
applyAgentStatus(entry, { replay: true })
}
+ const getMigrationUnsupportedSnapshot =
+ window.api.agentStatus.getMigrationUnsupportedSnapshot
+ if (typeof getMigrationUnsupportedSnapshot !== 'function') {
+ return
+ }
+ void getMigrationUnsupportedSnapshot().then((unsupportedEntries) => {
+ const unsupportedStore = useAppStore.getState()
+ if (!unsupportedStore.workspaceSessionReady) {
+ return
+ }
+ for (const entry of unsupportedEntries) {
+ if (entry.paneKey && resolvePaneKey(unsupportedStore, entry.paneKey).exists) {
+ unsupportedStore.setMigrationUnsupportedPty(entry)
+ }
+ }
+ })
})
.catch((err) => {
// Why: keep snapshotRequestedForReadyWindow latched on failure. The
@@ -1112,6 +1536,27 @@ export function useIpcEvents(): void {
applyAgentStatus(data)
})
)
+ const unsubscribeMigrationUnsupported = window.api.agentStatus.onMigrationUnsupported?.(
+ (entry) => {
+ const store = useAppStore.getState()
+ if (!store.workspaceSessionReady) {
+ return
+ }
+ if (entry.paneKey && resolvePaneKey(store, entry.paneKey).exists) {
+ store.setMigrationUnsupportedPty(entry)
+ }
+ }
+ )
+ if (unsubscribeMigrationUnsupported) {
+ unsubs.push(unsubscribeMigrationUnsupported)
+ }
+ const unsubscribeMigrationUnsupportedClear =
+ window.api.agentStatus.onMigrationUnsupportedClear?.(({ ptyId }) => {
+ useAppStore.getState().clearMigrationUnsupportedPty(ptyId)
+ })
+ if (unsubscribeMigrationUnsupportedClear) {
+ unsubs.push(unsubscribeMigrationUnsupportedClear)
+ }
// Why: the main hook server is the durable source of truth. Pull a
// snapshot only after workspace tabs are ready, so early startup pushes
@@ -1120,14 +1565,47 @@ export function useIpcEvents(): void {
requestAgentStatusSnapshotIfReady()
unsubs.push(useAppStore.subscribe(() => requestAgentStatusSnapshotIfReady()))
- // Why: hydrate mobile-fit overrides before terminal panes run their first
- // attach/fit logic, so a renderer reload doesn't undo active mobile fits.
- void window.api.runtime.getTerminalFitOverrides().then((overrides) => {
- hydrateOverrides(overrides)
- })
+ let mobileStateHydrated = isRuntimeEnvironmentActive()
+ type PendingMobileStateEvent =
+ | {
+ kind: 'fit'
+ event: {
+ ptyId: string
+ mode: 'mobile-fit' | 'desktop-fit'
+ cols: number
+ rows: number
+ }
+ }
+ | {
+ kind: 'driver'
+ event: {
+ ptyId: string
+ driver: RuntimeTerminalDriverState
+ }
+ }
+ const pendingMobileStateEvents: PendingMobileStateEvent[] = []
+
+ const applyPendingMobileStateEvents = (): void => {
+ for (const pending of pendingMobileStateEvents) {
+ if (pending.kind === 'fit') {
+ const { ptyId, mode, cols, rows } = pending.event
+ setFitOverride(ptyId, mode, cols, rows)
+ } else {
+ setDriverForPty(pending.event.ptyId, pending.event.driver)
+ }
+ }
+ pendingMobileStateEvents.length = 0
+ }
unsubs.push(
window.api.runtime.onTerminalFitOverrideChanged((event) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
+ if (!mobileStateHydrated) {
+ pendingMobileStateEvents.push({ kind: 'fit', event })
+ return
+ }
setFitOverride(event.ptyId, event.mode, event.cols, event.rows)
})
)
@@ -1138,15 +1616,43 @@ export function useIpcEvents(): void {
// know which PTYs are currently driven by mobile. See
// docs/mobile-presence-lock.md.
window.api.runtime.onTerminalDriverChanged((event) => {
+ if (isRuntimeEnvironmentActive()) {
+ return
+ }
+ if (!mobileStateHydrated) {
+ pendingMobileStateEvents.push({ kind: 'driver', event })
+ return
+ }
setDriverForPty(event.ptyId, event.driver)
})
)
+ // Why: hydrate mobile-owned terminal state on renderer reload. Subscribe
+ // first and buffer live events during the snapshot round trip; otherwise an
+ // older snapshot could overwrite a newer live lock and hide the overlay.
+ if (!isRuntimeEnvironmentActive()) {
+ void Promise.all([
+ window.api.runtime.getTerminalFitOverrides(),
+ window.api.runtime.getTerminalDrivers()
+ ])
+ .then(([overrides, drivers]) => {
+ hydrateOverrides(overrides)
+ hydrateDrivers(drivers)
+ mobileStateHydrated = true
+ applyPendingMobileStateEvents()
+ })
+ .catch((error: unknown) => {
+ console.error('Failed to hydrate mobile terminal state:', error)
+ mobileStateHydrated = true
+ applyPendingMobileStateEvents()
+ })
+ }
+
return () => unsubs.forEach((fn) => fn())
}, [])
}
-/** Resolve a paneKey (tabId:paneId) to a liveness check, the current terminal
+/** Resolve a paneKey (tabId:leafId) to both a liveness check and the current
* title, and the connectionId of the repo that owns the pane's worktree.
* Walks tabsByWorktree to locate the tab, then resolves the owning worktree
* and repo via cached selector maps. Used for agent type inference when the
@@ -1161,16 +1667,19 @@ function resolvePaneKey(
store: ReturnType,
paneKey: string
): { exists: boolean; title: string | undefined; repoConnectionId: string | null } {
- const [tabId, paneIdRaw] = paneKey.split(':')
- if (!tabId) {
+ const parsed = parsePaneKey(paneKey)
+ if (!parsed) {
return { exists: false, title: undefined, repoConnectionId: null }
}
- // Why: split panes track per-pane titles in runtimePaneTitlesByTabId; prefer
- // the pane's own title over the tab-level (last-winning) title so agent type
- // inference attributes status to the correct pane.
- const paneTitles = store.runtimePaneTitlesByTabId?.[tabId]
- const paneIdNum = paneIdRaw !== undefined ? Number(paneIdRaw) : NaN
- const rawPaneTitle = paneTitles && !Number.isNaN(paneIdNum) ? paneTitles[paneIdNum] : undefined
+ const { tabId, leafId } = parsed
+ const layout = store.terminalLayoutsByTabId?.[tabId]
+ const leafExists = collectLeafIdsInOrder(layout?.root).includes(leafId)
+ if (!leafExists) {
+ return { exists: false, title: undefined, repoConnectionId: null }
+ }
+ // Why: replay can remint numeric pane ids, so status title recovery must use
+ // persisted leaf-keyed titles when crossing from hook state into tab state.
+ const rawPaneTitle = layout?.titlesByLeafId?.[leafId]
// Why: treat an empty-string paneTitle as "no title" so the tab-level
// fallback still fires. `paneTitle ?? tabTitle` alone would short-circuit on
// '' and also erase any previously-cached terminalTitle in the store
diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts
index c715d6cb550..a7ce4531c3e 100644
--- a/src/renderer/src/hooks/useIssueMetadata.ts
+++ b/src/renderer/src/hooks/useIssueMetadata.ts
@@ -1,6 +1,15 @@
+/* eslint-disable max-lines -- Why: repo metadata hooks share TTL caches and
+Linear/GitHub cache invalidation entrypoints used by the issue dialog. */
import { useEffect, useRef, useState } from 'react'
+import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
+import {
+ linearTeamLabels,
+ linearTeamMembers,
+ linearTeamStates
+} from '@/runtime/runtime-linear-client'
import type {
GitHubAssignableUser,
+ GlobalSettings,
LinearWorkflowState,
LinearLabel,
LinearMember
@@ -23,7 +32,10 @@ type MetadataState = {
const ghLabelStore = createMetadataRequestStore()
const ghAssigneeStore = createMetadataRequestStore()
-export function useRepoLabels(repoPath: string | null): MetadataState {
+export function useRepoLabels(
+ repoPath: string | null,
+ repoId?: string | null
+): MetadataState {
const [state, setState] = useState>({
data: [],
loading: false,
@@ -32,29 +44,31 @@ export function useRepoLabels(repoPath: string | null): MetadataState
const activeKeyRef = useRef(null)
useEffect(() => {
- if (!repoPath) {
+ if (!repoPath && !repoId) {
return
}
-
- const cached = getFreshMetadata(ghLabelStore, repoPath)
+ const cacheKey = repoId ?? repoPath ?? ''
+ const cached = getFreshMetadata(ghLabelStore, cacheKey)
if (cached) {
- if (activeKeyRef.current !== repoPath) {
+ if (activeKeyRef.current !== cacheKey) {
setState({ data: cached.data, loading: false, error: null })
- activeKeyRef.current = repoPath
+ activeKeyRef.current = cacheKey
}
return
}
- activeKeyRef.current = repoPath
- const requestKey = repoPath
+ activeKeyRef.current = cacheKey
+ const requestKey = cacheKey
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
- loadMetadata(ghLabelStore, repoPath, () =>
- window.api.gh.listLabels({ repoPath }).then((labels) => labels as string[])
+ loadMetadata(ghLabelStore, cacheKey, () =>
+ window.api.gh
+ .listLabels({ repoPath: repoPath ?? '', repoId: repoId ?? undefined })
+ .then((labels) => labels as string[])
)
.then((data) => {
if (activeKeyRef.current !== requestKey) {
@@ -73,12 +87,15 @@ export function useRepoLabels(repoPath: string | null): MetadataState
error: err instanceof Error ? err.message : 'Failed to load labels'
}))
})
- }, [repoPath])
+ }, [repoPath, repoId])
return state
}
-export function useRepoAssignees(repoPath: string | null): MetadataState {
+export function useRepoAssignees(
+ repoPath: string | null,
+ repoId?: string | null
+): MetadataState {
const [state, setState] = useState>({
data: [],
loading: false,
@@ -87,30 +104,30 @@ export function useRepoAssignees(repoPath: string | null): MetadataState(null)
useEffect(() => {
- if (!repoPath) {
+ if (!repoPath && !repoId) {
return
}
-
- const cached = getFreshMetadata(ghAssigneeStore, repoPath)
+ const cacheKey = repoId ?? repoPath ?? ''
+ const cached = getFreshMetadata(ghAssigneeStore, cacheKey)
if (cached) {
- if (activeKeyRef.current !== repoPath) {
+ if (activeKeyRef.current !== cacheKey) {
setState({ data: cached.data, loading: false, error: null })
- activeKeyRef.current = repoPath
+ activeKeyRef.current = cacheKey
}
return
}
- activeKeyRef.current = repoPath
- const requestKey = repoPath
+ activeKeyRef.current = cacheKey
+ const requestKey = cacheKey
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
- loadMetadata(ghAssigneeStore, repoPath, () =>
+ loadMetadata(ghAssigneeStore, cacheKey, () =>
window.api.gh
- .listAssignableUsers({ repoPath })
+ .listAssignableUsers({ repoPath: repoPath ?? '', repoId: repoId ?? undefined })
.then((users) => users as GitHubAssignableUser[])
)
.then((data) => {
@@ -130,7 +147,7 @@ export function useRepoAssignees(repoPath: string | null): MetadataState()
const linearLabelStore = createMetadataRequestStore()
const linearMemberStore = createMetadataRequestStore()
+function linearMetadataCacheKey(
+ teamId: string,
+ settings: Pick | null | undefined,
+ workspaceId?: string | null
+): string {
+ const target = getActiveRuntimeTarget(settings)
+ const workspaceKey = workspaceId ?? 'selected'
+ return target.kind === 'environment'
+ ? `runtime:${target.environmentId}:${workspaceKey}:${teamId}`
+ : `${workspaceKey}:${teamId}`
+}
+
export function clearLinearMetadataCache(): void {
clearMetadataRequestStore(linearStateStore)
clearMetadataRequestStore(linearLabelStore)
@@ -152,7 +181,11 @@ export function clearGitHubMetadataCache(): void {
clearMetadataRequestStore(ghAssigneeStore)
}
-export function useTeamStates(teamId: string | null): MetadataState {
+export function useTeamStates(
+ teamId: string | null,
+ settings?: Pick | null,
+ workspaceId?: string | null
+): MetadataState {
const [state, setState] = useState>({
data: [],
loading: false,
@@ -165,25 +198,28 @@ export function useTeamStates(teamId: string | null): MetadataState ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
- loadMetadata(linearStateStore, teamId, () =>
- window.api.linear.teamStates({ teamId }).then((states) => states as LinearWorkflowState[])
+ loadMetadata(linearStateStore, cacheKey, () =>
+ linearTeamStates(settings, teamId, workspaceId).then(
+ (states) => states as LinearWorkflowState[]
+ )
)
.then((data) => {
if (activeKeyRef.current !== requestKey) {
@@ -202,12 +238,16 @@ export function useTeamStates(teamId: string | null): MetadataState {
+export function useTeamLabels(
+ teamId: string | null,
+ settings?: Pick | null,
+ workspaceId?: string | null
+): MetadataState {
const [state, setState] = useState>({
data: [],
loading: false,
@@ -220,25 +260,26 @@ export function useTeamLabels(teamId: string | null): MetadataState ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
- loadMetadata(linearLabelStore, teamId, () =>
- window.api.linear.teamLabels({ teamId }).then((labels) => labels as LinearLabel[])
+ loadMetadata(linearLabelStore, cacheKey, () =>
+ linearTeamLabels(settings, teamId, workspaceId).then((labels) => labels as LinearLabel[])
)
.then((data) => {
if (activeKeyRef.current !== requestKey) {
@@ -257,12 +298,16 @@ export function useTeamLabels(teamId: string | null): MetadataState {
+export function useTeamMembers(
+ teamId: string | null,
+ settings?: Pick | null,
+ workspaceId?: string | null
+): MetadataState {
const [state, setState] = useState>({
data: [],
loading: false,
@@ -275,25 +320,26 @@ export function useTeamMembers(teamId: string | null): MetadataState ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
- loadMetadata(linearMemberStore, teamId, () =>
- window.api.linear.teamMembers({ teamId }).then((members) => members as LinearMember[])
+ loadMetadata(linearMemberStore, cacheKey, () =>
+ linearTeamMembers(settings, teamId, workspaceId).then((members) => members as LinearMember[])
)
.then((data) => {
if (activeKeyRef.current !== requestKey) {
@@ -312,7 +358,7 @@ export function useTeamMembers(teamId: string | null): MetadataState {
+ const detail: FocusTerminalPaneDetail = {
+ tabId,
+ leafId,
+ ...(opts?.ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess: opts.ackPaneKeyOnSuccess } : {})
+ }
window.dispatchEvent(
new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, {
- detail: { tabId, paneId }
+ detail
})
)
})
diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx
index 37a8713d9f1..1d77e38ae27 100644
--- a/src/renderer/src/lib/agent-catalog.tsx
+++ b/src/renderer/src/lib/agent-catalog.tsx
@@ -28,6 +28,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
cmd: 'codex',
homepageUrl: 'https://github.com/openai/codex'
},
+ {
+ id: 'grok',
+ label: 'Grok',
+ cmd: 'grok',
+ faviconDomain: 'x.ai',
+ homepageUrl: 'https://x.ai/cli'
+ },
{
id: 'copilot',
label: 'GitHub Copilot',
@@ -182,6 +189,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
cmd: 'hermes',
faviconDomain: 'nousresearch.com',
homepageUrl: 'https://hermes-agent.nousresearch.com/docs/'
+ },
+ {
+ id: 'openclaw',
+ label: 'OpenClaw',
+ cmd: 'openclaw',
+ faviconDomain: 'openclaw.ai',
+ homepageUrl: 'https://github.com/openclaw/openclaw'
}
]
diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts
index cdce83c8272..5dd27b91dc8 100644
--- a/src/renderer/src/lib/agent-paste-draft.ts
+++ b/src/renderer/src/lib/agent-paste-draft.ts
@@ -2,6 +2,8 @@ import type { TuiAgent } from '../../../shared/types'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { useAppStore } from '@/store'
import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher'
+import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
+import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream'
// Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi /
// OpenCode / Gemini / cursor-agent / copilot) treat the inserted text as a
@@ -89,7 +91,8 @@ export async function pasteDraftWhenAgentReady(args: {
return false
}
- window.api.pty.write(
+ sendRuntimePtyInput(
+ useAppStore.getState().settings,
ptyId,
`${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}${submit ? '\r' : ''}`
)
@@ -136,7 +139,7 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise finish(true), BRACKETED_PASTE_QUIET_MS)
}
- unsubscribe = subscribeToPtyData(ptyId, (data) => {
+ const observeData = (data: string): void => {
// Why: keep just enough recent bytes that an escape sequence split
// across two IPC frames is still detectable. 64 bytes >> 8-byte
// sequence; cheap and bounded.
@@ -151,7 +154,26 @@ function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise {
+ if (settled) {
+ remoteUnsubscribe()
+ return
+ }
+ unsubscribe = remoteUnsubscribe
+ })
+ .catch(() => finish(false))
+ } else {
+ unsubscribe = subscribeToPtyData(ptyId, observeData)
+ }
const hardTimer = window.setTimeout(() => finish(false), timeoutMs)
})
diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts
index 70af9f83910..84b75c721f9 100644
--- a/src/renderer/src/lib/agent-ready-wait.ts
+++ b/src/renderer/src/lib/agent-ready-wait.ts
@@ -1,6 +1,7 @@
import { detectAgentStatusFromTitle } from '../../../shared/agent-detection'
import { isShellProcess } from '@/lib/tui-agent-startup'
import { useAppStore } from '@/store'
+import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
// Why: agent CLIs vary widely in how they signal readiness. Title-based
// detection (OSC titles parsed by detectAgentStatusFromTitle) is the tightest
@@ -89,7 +90,8 @@ export async function waitForAgentReady(
}
try {
- const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? ''
+ const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId)
+ const foreground = process.foregroundProcess?.toLowerCase() ?? ''
if (
foreground === expectedProcess ||
foreground.startsWith(`${expectedProcess}.`) ||
@@ -103,8 +105,7 @@ export async function waitForAgentReady(
// polls so the shell's own startup children don't spoof readiness on
// cold-start. Never accept it while the foreground is still a shell.
if (attempt >= 4 && !isShellProcess(foreground)) {
- const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId)
- if (hasChildProcesses) {
+ if (process.hasChildProcesses) {
return { ready: true, reason: 'child-process' }
}
}
diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts
index 2bf49ff71a6..5aaff7a9204 100644
--- a/src/renderer/src/lib/agent-status.test.ts
+++ b/src/renderer/src/lib/agent-status.test.ts
@@ -200,6 +200,11 @@ describe('detectAgentStatusFromTitle', () => {
expect(detectAgentStatusFromTitle('Droid working')).toBe('working')
})
+ it('does not treat Factory Droid native needs-input titles as completion', () => {
+ expect(detectAgentStatusFromTitle('Factory Droid needs input')).toBeNull()
+ expect(detectAgentStatusFromTitle('Factory Droid needs your input')).toBeNull()
+ })
+
// --- Case insensitivity ---
it('is case-insensitive for agent names', () => {
expect(detectAgentStatusFromTitle('CLAUDE')).toBe('idle')
@@ -372,6 +377,7 @@ describe('getAgentLabel', () => {
expect(getAgentLabel('✦ Gemini CLI')).toBe('Gemini CLI')
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')
expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex')
+ expect(getAgentLabel('Grok running')).toBe('Grok')
expect(getAgentLabel('⠋ Droid')).toBe('Droid')
expect(getAgentLabel('Droid ready')).toBe('Droid')
})
@@ -488,6 +494,15 @@ describe('createAgentStatusTracker', () => {
expect(onBecameIdle).toHaveBeenCalledTimes(1)
})
+ it('does not fire when Factory Droid reports needs input during a working turn', () => {
+ const onBecameIdle = vi.fn()
+ const tracker = createAgentStatusTracker(onBecameIdle)
+
+ tracker.handleTitle('⠋ Droid')
+ tracker.handleTitle('Factory Droid needs input')
+ expect(onBecameIdle).not.toHaveBeenCalled()
+ })
+
// --- Multiple cycles ---
it('fires on each working → idle cycle', () => {
const onBecameIdle = vi.fn()
diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts
index 7c02d7bf06d..f1a3a0de942 100644
--- a/src/renderer/src/lib/agent-status.ts
+++ b/src/renderer/src/lib/agent-status.ts
@@ -117,7 +117,8 @@ const WELL_KNOWN_LABELS: Record = {
cursor: 'Cursor',
aider: 'Aider',
pi: 'Pi',
- droid: 'Droid'
+ droid: 'Droid',
+ grok: 'Grok'
}
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
@@ -165,7 +166,9 @@ const ICONABLE_AGENT_TYPES: Record = {
'qwen-code': true,
rovo: true,
hermes: true,
- copilot: true
+ openclaw: true,
+ copilot: true,
+ grok: true
}
export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent | null {
diff --git a/src/renderer/src/lib/browser-uuid.test.ts b/src/renderer/src/lib/browser-uuid.test.ts
new file mode 100644
index 00000000000..8ad0616200a
--- /dev/null
+++ b/src/renderer/src/lib/browser-uuid.test.ts
@@ -0,0 +1,29 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createBrowserUuid } from './browser-uuid'
+
+describe('createBrowserUuid', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('uses native randomUUID when it is available', () => {
+ vi.stubGlobal('crypto', {
+ randomUUID: () => 'native-id'
+ })
+
+ expect(createBrowserUuid()).toBe('native-id')
+ })
+
+ it('falls back to getRandomValues when randomUUID is unavailable', () => {
+ vi.stubGlobal('crypto', {
+ getRandomValues: (bytes: Uint8Array) => {
+ for (let index = 0; index < bytes.length; index += 1) {
+ bytes[index] = index
+ }
+ return bytes
+ }
+ })
+
+ expect(createBrowserUuid()).toBe('00010203-0405-4607-8809-0a0b0c0d0e0f')
+ })
+})
diff --git a/src/renderer/src/lib/browser-uuid.ts b/src/renderer/src/lib/browser-uuid.ts
new file mode 100644
index 00000000000..177d05f7ddb
--- /dev/null
+++ b/src/renderer/src/lib/browser-uuid.ts
@@ -0,0 +1,28 @@
+export function createBrowserUuid(): string {
+ const cryptoApi = globalThis.crypto
+ if (typeof cryptoApi?.randomUUID === 'function') {
+ return cryptoApi.randomUUID()
+ }
+
+ const bytes = new Uint8Array(16)
+ if (typeof cryptoApi?.getRandomValues === 'function') {
+ cryptoApi.getRandomValues(bytes)
+ } else {
+ // Why: LAN web clients can run in non-secure browser contexts where
+ // randomUUID is hidden. These are local UI IDs, not auth credentials.
+ for (let index = 0; index < bytes.length; index += 1) {
+ bytes[index] = Math.floor(Math.random() * 256)
+ }
+ }
+
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ return bytesToUuid(bytes)
+}
+
+function bytesToUuid(bytes: Uint8Array): string {
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
+ .slice(6, 8)
+ .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
+}
diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts
index e32c733b54c..e3ec0811c8e 100644
--- a/src/renderer/src/lib/codex-session-restart.test.ts
+++ b/src/renderer/src/lib/codex-session-restart.test.ts
@@ -1,6 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { markLiveCodexSessionsForRestart } from './codex-session-restart'
+import {
+ createCompatibleRuntimeStatusResponseIfNeeded,
+ type RuntimeEnvironmentCallRequest
+} from '@/runtime/runtime-compatibility-test-fixture'
+import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
const ACCOUNT_A = 'account-a@example.com'
const ACCOUNT_B = 'account-b@example.com'
@@ -8,8 +13,16 @@ const ACCOUNT_C = 'account-c@example.com'
describe('markLiveCodexSessionsForRestart', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
+ const runtimeEnvironmentCall = vi.fn()
+ const runtimeEnvironmentTransportCall = vi.fn()
beforeEach(() => {
+ clearRuntimeCompatibilityCacheForTests()
+ runtimeEnvironmentCall.mockReset()
+ runtimeEnvironmentTransportCall.mockReset()
+ runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
+ return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
+ })
useAppStore.setState({
tabsByWorktree: {
wt1: [
@@ -39,7 +52,12 @@ describe('markLiveCodexSessionsForRestart', () => {
...originalWindow?.api,
pty: {
...originalWindow?.api?.pty,
- getForegroundProcess: vi.fn()
+ getForegroundProcess: vi.fn(),
+ hasChildProcesses: vi.fn().mockResolvedValue(false)
+ },
+ runtimeEnvironments: {
+ ...originalWindow?.api?.runtimeEnvironments,
+ call: runtimeEnvironmentTransportCall
}
}
} as unknown as typeof window
@@ -143,4 +161,52 @@ describe('markLiveCodexSessionsForRestart', () => {
nextAccountLabel: ACCOUNT_C
})
})
+
+ it('inspects remote runtime PTYs through the active runtime environment', async () => {
+ useAppStore.setState({
+ settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
+ tabsByWorktree: {
+ wt1: [
+ {
+ id: 'tab-1',
+ ptyId: 'remote:term-1',
+ worktreeId: 'wt1',
+ title: 'orca-1',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1
+ }
+ ]
+ },
+ ptyIdsByTabId: {
+ 'tab-1': ['remote:term-1']
+ }
+ })
+ runtimeEnvironmentCall.mockResolvedValue({
+ id: 'rpc-1',
+ ok: true,
+ result: {
+ process: { foregroundProcess: 'codex', hasChildProcesses: true }
+ },
+ _meta: { runtimeId: 'remote-runtime' }
+ })
+
+ await markLiveCodexSessionsForRestart({
+ previousAccountLabel: ACCOUNT_A,
+ nextAccountLabel: ACCOUNT_B
+ })
+
+ expect(window.api.pty.getForegroundProcess).not.toHaveBeenCalled()
+ expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.inspectProcess',
+ params: { terminal: 'term-1' },
+ timeoutMs: 15_000
+ })
+ expect(useAppStore.getState().codexRestartNoticeByPtyId['remote:term-1']).toEqual({
+ previousAccountLabel: ACCOUNT_A,
+ nextAccountLabel: ACCOUNT_B
+ })
+ })
})
diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts
index a3a71f2d669..6e7a46bb156 100644
--- a/src/renderer/src/lib/codex-session-restart.ts
+++ b/src/renderer/src/lib/codex-session-restart.ts
@@ -1,5 +1,6 @@
import type { AppState } from '@/store'
import { useAppStore } from '@/store'
+import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
function normalizeProcessName(processName: string | null): string | null {
if (!processName) {
@@ -34,7 +35,11 @@ async function getLiveCodexSessionPtyIds(state: AppState): Promise {
// does not always do that. The foreground PTY process is the stable
// source of truth for whether this live tab is actually running Codex.
const foregroundProcesses = await Promise.all(
- ptyIds.map((ptyId) => window.api.pty.getForegroundProcess(ptyId))
+ ptyIds.map((ptyId) =>
+ inspectRuntimeTerminalProcess(state.settings, ptyId).then(
+ (inspection) => inspection.foregroundProcess
+ )
+ )
)
return ptyIds.filter((_, index) => isCodexForegroundProcess(foregroundProcesses[index]))
})
diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts
index 0b43bfcea72..e5768ffede4 100644
--- a/src/renderer/src/lib/create-untitled-markdown.test.ts
+++ b/src/renderer/src/lib/create-untitled-markdown.test.ts
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createUntitledMarkdownFile } from './create-untitled-markdown'
+import {
+ createCompatibleRuntimeStatusResponseIfNeeded,
+ type RuntimeEnvironmentCallRequest
+} from '@/runtime/runtime-compatibility-test-fixture'
+import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
describe('createUntitledMarkdownFile', () => {
afterEach(() => {
@@ -7,7 +12,13 @@ describe('createUntitledMarkdownFile', () => {
})
it('retries with the next untitled name when createFile loses the EEXIST race', async () => {
- const pathExists = vi.fn(async (filePath: string) => filePath.endsWith('untitled.md'))
+ const pathExists = vi.fn()
+ const stat = vi.fn(async (args: { filePath: string }) => {
+ if (args.filePath.endsWith('untitled.md')) {
+ return { size: 0, isDirectory: false, mtime: 1 }
+ }
+ throw new Error('ENOENT: no such file')
+ })
const createFile = vi
.fn()
.mockRejectedValueOnce(new Error('EEXIST: file already exists'))
@@ -16,7 +27,7 @@ describe('createUntitledMarkdownFile', () => {
vi.stubGlobal('window', {
api: {
shell: { pathExists },
- fs: { createFile }
+ fs: { createFile, stat }
}
})
@@ -31,16 +42,18 @@ describe('createUntitledMarkdownFile', () => {
expect(createFile).toHaveBeenNthCalledWith(1, { filePath: '/repo/untitled-2.md' })
expect(createFile).toHaveBeenNthCalledWith(2, { filePath: '/repo/untitled-3.md' })
+ expect(pathExists).not.toHaveBeenCalled()
})
it('throws a descriptive error when untitled names are exhausted', async () => {
const pathExists = vi.fn(async () => true)
+ const stat = vi.fn().mockResolvedValue({ size: 0, isDirectory: false, mtime: 1 })
const createFile = vi.fn()
vi.stubGlobal('window', {
api: {
shell: { pathExists },
- fs: { createFile }
+ fs: { createFile, stat }
}
})
@@ -49,16 +62,18 @@ describe('createUntitledMarkdownFile', () => {
)
expect(createFile).not.toHaveBeenCalled()
+ expect(pathExists).not.toHaveBeenCalled()
})
- it('passes connectionId to createFile and skips the local pathExists probe for SSH worktrees', async () => {
+ it('passes connectionId to stat and createFile for SSH worktrees', async () => {
const pathExists = vi.fn(async () => false)
+ const stat = vi.fn().mockRejectedValue(new Error('ENOENT: no such file'))
const createFile = vi.fn().mockResolvedValueOnce(undefined)
vi.stubGlobal('window', {
api: {
shell: { pathExists },
- fs: { createFile }
+ fs: { createFile, stat }
}
})
@@ -66,13 +81,71 @@ describe('createUntitledMarkdownFile', () => {
filePath: '/repo/untitled.md'
})
- // Why: shell.pathExists is main-process local-only; probing it on SSH
- // worktrees always reports "not found" or succeeds against the wrong
- // filesystem, so the probe must be skipped when a connectionId is set.
+ // Why: shell.pathExists is main-process local-only; SSH worktrees must
+ // probe through the same filesystem API that receives the connectionId.
expect(pathExists).not.toHaveBeenCalled()
+ expect(stat).toHaveBeenCalledWith({
+ filePath: '/repo/untitled.md',
+ connectionId: 'conn-1'
+ })
expect(createFile).toHaveBeenCalledWith({
filePath: '/repo/untitled.md',
connectionId: 'conn-1'
})
})
+
+ it('creates untitled files through the selected runtime environment', async () => {
+ clearRuntimeCompatibilityCacheForTests()
+ const stat = vi.fn()
+ const createFile = vi.fn()
+ const runtimeEnvironmentCall = vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: 'rpc-1',
+ ok: false,
+ error: { message: 'ENOENT: no such file' },
+ _meta: { runtimeId: 'remote-runtime' }
+ })
+ .mockResolvedValueOnce({
+ id: 'rpc-2',
+ ok: true,
+ result: { ok: true },
+ _meta: { runtimeId: 'remote-runtime' }
+ })
+ const runtimeEnvironmentTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => {
+ return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
+ })
+
+ vi.stubGlobal('window', {
+ api: {
+ shell: { pathExists: vi.fn() },
+ fs: { createFile, stat },
+ runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
+ }
+ })
+
+ await expect(
+ createUntitledMarkdownFile('/remote/repo', 'wt-1', undefined, {
+ activeRuntimeEnvironmentId: 'env-1'
+ })
+ ).resolves.toMatchObject({
+ filePath: '/remote/repo/untitled.md',
+ relativePath: 'untitled.md'
+ })
+
+ expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
+ selector: 'env-1',
+ method: 'files.stat',
+ params: { worktree: 'wt-1', relativePath: 'untitled.md' },
+ timeoutMs: 15_000
+ })
+ expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
+ selector: 'env-1',
+ method: 'files.createFile',
+ params: { worktree: 'wt-1', relativePath: 'untitled.md' },
+ timeoutMs: 15_000
+ })
+ expect(stat).not.toHaveBeenCalled()
+ expect(createFile).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/lib/create-untitled-markdown.ts b/src/renderer/src/lib/create-untitled-markdown.ts
index ee3023dd712..3d41674f160 100644
--- a/src/renderer/src/lib/create-untitled-markdown.ts
+++ b/src/renderer/src/lib/create-untitled-markdown.ts
@@ -1,3 +1,5 @@
+import type { GlobalSettings } from '../../../shared/types'
+import { createRuntimePath, runtimePathExists } from '../runtime/runtime-file-client'
import { detectLanguage } from './language-detect'
import { joinPath } from './path'
@@ -11,7 +13,8 @@ import { joinPath } from './path'
export async function createUntitledMarkdownFile(
worktreePath: string,
worktreeId: string,
- connectionId?: string
+ connectionId?: string,
+ settings?: Pick | null
): Promise<{
filePath: string
relativePath: string
@@ -30,21 +33,19 @@ export async function createUntitledMarkdownFile(
// nearly the same time. Retrying EEXIST keeps "New Markdown" advancing to
// the next untitled-N name instead of surfacing a spurious error toast.
//
- // Why (SSH): window.api.shell.pathExists is a local-only main-process probe
- // and cannot see files on a remote host. For SSH worktrees we skip the probe
- // and rely solely on the EEXIST retry loop; otherwise every attempt reports
- // "not found" locally, then fails in the main process when createFile tries
- // to authorize the remote path against local allowed roots.
+ // Why: existence probing must go through the same runtime/SSH-aware file
+ // surface as creation; the shell probe only sees the client filesystem.
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const fileName = attempt === 1 ? `${baseName}${ext}` : `${baseName}-${attempt}${ext}`
const filePath = joinPath(worktreePath, fileName)
+ const context = { settings, worktreeId, worktreePath, connectionId }
- if (!connectionId && (await window.api.shell.pathExists(filePath))) {
+ if (await runtimePathExists(context, filePath)) {
continue
}
try {
- await window.api.fs.createFile({ filePath, connectionId })
+ await createRuntimePath(context, filePath, 'file')
return {
filePath,
diff --git a/src/renderer/src/lib/diff-comment-compat.test.ts b/src/renderer/src/lib/diff-comment-compat.test.ts
new file mode 100644
index 00000000000..48b6e3b22b3
--- /dev/null
+++ b/src/renderer/src/lib/diff-comment-compat.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import type { DiffComment } from '../../../shared/types'
+import {
+ getDiffCommentLineLabel,
+ getDiffCommentSource,
+ isDiffComment,
+ isMarkdownComment
+} from './diff-comment-compat'
+
+function makeComment(overrides: Partial = {}): DiffComment {
+ return {
+ id: 'c1',
+ worktreeId: 'wt1',
+ filePath: 'README.md',
+ lineNumber: 4,
+ body: 'note',
+ createdAt: 0,
+ side: 'modified',
+ ...overrides
+ }
+}
+
+describe('diff comment compatibility helpers', () => {
+ it('routes legacy comments with no source as diff comments', () => {
+ const comment = makeComment()
+ expect(getDiffCommentSource(comment)).toBe('diff')
+ expect(isDiffComment(comment)).toBe(true)
+ expect(isMarkdownComment(comment)).toBe(false)
+ })
+
+ it('routes markdown comments by explicit source', () => {
+ const comment = makeComment({ source: 'markdown' })
+ expect(getDiffCommentSource(comment)).toBe('markdown')
+ expect(isMarkdownComment(comment)).toBe(true)
+ expect(isDiffComment(comment)).toBe(false)
+ })
+
+ it('formats compact and full range labels', () => {
+ const comment = makeComment({ startLine: 2, lineNumber: 4 })
+ expect(getDiffCommentLineLabel(comment)).toBe('Lines 2-4')
+ expect(getDiffCommentLineLabel(comment, true)).toBe('L2-L4')
+ })
+})
diff --git a/src/renderer/src/lib/diff-comment-compat.ts b/src/renderer/src/lib/diff-comment-compat.ts
new file mode 100644
index 00000000000..ed88baf0dd0
--- /dev/null
+++ b/src/renderer/src/lib/diff-comment-compat.ts
@@ -0,0 +1,25 @@
+import type { DiffComment, DiffCommentSource } from '../../../shared/types'
+
+export function getDiffCommentSource(comment: Pick): DiffCommentSource {
+ return comment.source === 'markdown' ? 'markdown' : 'diff'
+}
+
+export function isDiffComment(comment: Pick): boolean {
+ return getDiffCommentSource(comment) === 'diff'
+}
+
+export function isMarkdownComment(comment: Pick): boolean {
+ return getDiffCommentSource(comment) === 'markdown'
+}
+
+export function getDiffCommentLineLabel(
+ comment: Pick,
+ compact = false
+): string {
+ if (comment.startLine !== undefined && comment.startLine !== comment.lineNumber) {
+ return compact
+ ? `L${comment.startLine}-L${comment.lineNumber}`
+ : `Lines ${comment.startLine}-${comment.lineNumber}`
+ }
+ return compact ? `L${comment.lineNumber}` : `Line ${comment.lineNumber}`
+}
diff --git a/src/renderer/src/lib/diff-comments-format.test.ts b/src/renderer/src/lib/diff-comments-format.test.ts
index 2b492ed7a24..5a692727165 100644
--- a/src/renderer/src/lib/diff-comments-format.test.ts
+++ b/src/renderer/src/lib/diff-comments-format.test.ts
@@ -23,6 +23,32 @@ describe('formatDiffComment', () => {
)
})
+ it('keeps explicit diff comments without ranges in the legacy format', () => {
+ const out = formatDiffComment(makeComment({ source: 'diff' }))
+ expect(out).toBe(
+ ['File: src/app.ts', 'Line: 10', 'User comment: "Needs validation"'].join('\n')
+ )
+ })
+
+ it('formats persisted ranges when startLine is present', () => {
+ const out = formatDiffComment(makeComment({ source: 'diff', startLine: 7 }))
+ expect(out).toBe(
+ ['File: src/app.ts', 'Lines: 7-10', 'User comment: "Needs validation"'].join('\n')
+ )
+ })
+
+ it('adds markdown source metadata for markdown notes', () => {
+ const out = formatDiffComment(makeComment({ source: 'markdown', startLine: 8 }))
+ expect(out).toBe(
+ [
+ 'File: src/app.ts',
+ 'Source: markdown',
+ 'Lines: 8-10',
+ 'User comment: "Needs validation"'
+ ].join('\n')
+ )
+ })
+
it('escapes embedded quotes in the body', () => {
const out = formatDiffComment(makeComment({ body: 'why "this" path?' }))
expect(out).toContain('User comment: "why \\"this\\" path?"')
diff --git a/src/renderer/src/lib/diff-comments-format.ts b/src/renderer/src/lib/diff-comments-format.ts
index 0afd4c8dd7f..1af7ea9dcd7 100644
--- a/src/renderer/src/lib/diff-comments-format.ts
+++ b/src/renderer/src/lib/diff-comments-format.ts
@@ -1,4 +1,5 @@
import type { DiffComment } from '../../../shared/types'
+import { isMarkdownComment } from './diff-comment-compat'
// Why: the pasted format is the contract between this feature and whatever
// agent consumes it. Keep it stable and deterministic — quote escaping matters
@@ -10,7 +11,16 @@ export function formatDiffComment(c: DiffComment): string {
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
- return [`File: ${c.filePath}`, `Line: ${c.lineNumber}`, `User comment: "${escaped}"`].join('\n')
+ const lineLabel =
+ c.startLine !== undefined && c.startLine !== c.lineNumber
+ ? `Lines: ${c.startLine}-${c.lineNumber}`
+ : `Line: ${c.lineNumber}`
+ if (!isMarkdownComment(c)) {
+ return [`File: ${c.filePath}`, lineLabel, `User comment: "${escaped}"`].join('\n')
+ }
+ return [`File: ${c.filePath}`, 'Source: markdown', lineLabel, `User comment: "${escaped}"`].join(
+ '\n'
+ )
}
export function formatDiffComments(comments: DiffComment[]): string {
diff --git a/src/renderer/src/lib/ensure-hooks-confirmed.ts b/src/renderer/src/lib/ensure-hooks-confirmed.ts
index 63f060b3ec6..eeb1f4a71a7 100644
--- a/src/renderer/src/lib/ensure-hooks-confirmed.ts
+++ b/src/renderer/src/lib/ensure-hooks-confirmed.ts
@@ -1,6 +1,7 @@
import type { AppState } from '@/store/types'
import type { OrcaHooks } from '../../../shared/types'
import { hashOrcaHookScript, type OrcaHookScriptKind } from './orca-hook-trust'
+import { checkRuntimeHooks, readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
export type HookScriptKind = OrcaHookScriptKind
@@ -31,13 +32,13 @@ export async function ensureHooksConfirmed(
try {
if (scriptKind === 'issueCommand') {
// Local overrides are user-owned; only shared orca.yaml commands need repo trust.
- const result = await window.api.hooks.readIssueCommand({ repoId })
+ const result = await readRuntimeIssueCommand(state.settings, repoId)
if (result.source !== 'shared') {
return 'run'
}
scriptContent = (result.sharedContent ?? '').trim()
} else {
- const result = await window.api.hooks.check({ repoId })
+ const result = await checkRuntimeHooks(state.settings, repoId)
const yamlHooks = (result.hooks as OrcaHooks | null) ?? null
scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim()
}
diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts
index ab7f9e70f2a..060bd8feff8 100644
--- a/src/renderer/src/lib/focus-terminal-tab-surface.ts
+++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts
@@ -5,16 +5,27 @@
* double-rAF waits for that commit so focus lands on the new tab instead of
* whatever surface (menu trigger, body, previous tab) just relinquished it.
*/
-export function focusTerminalTabSurface(tabId: string): void {
+function cssAttributeString(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+}
+
+export function focusTerminalTabSurface(tabId: string, leafId?: string | null): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
- const scoped = document.querySelector(
- `[data-terminal-tab-id="${tabId}"] .xterm-helper-textarea`
- ) as HTMLElement | null
+ const escapedTabId = cssAttributeString(tabId)
+ const scopedSelector = leafId
+ ? `[data-terminal-tab-id="${escapedTabId}"] [data-leaf-id="${cssAttributeString(leafId)}"] .xterm-helper-textarea`
+ : `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea`
+ const scoped = document.querySelector(scopedSelector) as HTMLElement | null
if (scoped) {
scoped.focus()
return
}
+ if (leafId) {
+ // Why: exact mobile split-pane focus must not silently focus a sibling
+ // pane when the requested UUID leaf has not mounted yet.
+ return
+ }
const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null
fallback?.focus()
})
diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts
index f62994e49ad..bdafd7e9981 100644
--- a/src/renderer/src/lib/http-link-routing.test.ts
+++ b/src/renderer/src/lib/http-link-routing.test.ts
@@ -6,7 +6,9 @@ const setActiveWorktreeMock = vi.fn()
const createBrowserTabMock = vi.fn()
const storeState = {
- settings: undefined as { openLinksInApp?: boolean } | undefined,
+ settings: undefined as
+ | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
+ | undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock
}
@@ -59,6 +61,16 @@ describe('openHttpLink', () => {
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
+ it('routes to the system browser when a remote runtime environment is active', () => {
+ storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' }
+
+ openHttpLink('https://example.com/', { worktreeId: 'wt-1' })
+
+ expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+ expect(setActiveWorktreeMock).not.toHaveBeenCalled()
+ })
+
it('routes to the system browser when no worktree id is provided', () => {
storeState.settings = { openLinksInApp: true }
diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts
index 541106718d3..5204be0daec 100644
--- a/src/renderer/src/lib/http-link-routing.ts
+++ b/src/renderer/src/lib/http-link-routing.ts
@@ -4,7 +4,7 @@ export type OpenHttpLinkOptions = {
}
type StoreAccessor = () => {
- settings?: { openLinksInApp?: boolean } | null
+ settings?: { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null } | null
setActiveWorktree: (worktreeId: string) => void
createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown
}
@@ -28,8 +28,12 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void {
export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void {
const { worktreeId, forceSystemBrowser } = opts
const state = storeAccessor?.()
+ const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim())
const routeToOrca =
- !forceSystemBrowser && Boolean(worktreeId) && state?.settings?.openLinksInApp !== false
+ !remoteRuntimeActive &&
+ !forceSystemBrowser &&
+ Boolean(worktreeId) &&
+ state?.settings?.openLinksInApp !== false
if (routeToOrca && worktreeId && state) {
// Why: http clicks from inside a worktree should not push a worktree-switch
diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts
index aacf55857db..af5e113cf45 100644
--- a/src/renderer/src/lib/launch-agent-background-session.test.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.test.ts
@@ -1,17 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createCompatibleRuntimeStatusResponseIfNeeded,
+ type RuntimeEnvironmentCallRequest
+} from '@/runtime/runtime-compatibility-test-fixture'
+import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
const mockSpawn = vi.fn()
+const mockRuntimeEnvironmentCall = vi.fn()
+const mockRuntimeEnvironmentTransportCall = vi.fn()
+const mockRuntimeEnvironmentSubscribe = vi.fn()
const mockCreateTab = vi.fn()
const mockSetTabCustomTitle = vi.fn()
const mockUpdateTabPtyId = vi.fn()
const mockCloseTab = vi.fn()
+const mockSetTabLayout = vi.fn()
const mockRegisterEagerPtyBuffer = vi.fn()
const mockSubscribeToPtyData = vi.fn()
const mockSubscribeToPtyExit = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+
+function expectStablePaneSpawn(): string {
+ const spawnArgs = mockSpawn.mock.calls[0]?.[0]
+ const paneKey = spawnArgs?.env?.ORCA_PANE_KEY
+ const leafId = spawnArgs?.leafId
+ expect(typeof paneKey).toBe('string')
+ expect(typeof leafId).toBe('string')
+ expect(leafId).toMatch(UUID_RE)
+ expect(paneKey).toBe(`tab-1:${leafId}`)
+ return paneKey
+}
const state = {
- settings: { agentCmdOverrides: {} },
+ settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null },
repos: [{ id: 'repo-1', connectionId: null }],
allWorktrees: vi.fn(() => [
{ id: 'wt-1', repoId: 'repo-1', path: '/repo/worktree', displayName: 'main' }
@@ -20,6 +41,7 @@ const state = {
setTabCustomTitle: mockSetTabCustomTitle,
updateTabPtyId: mockUpdateTabPtyId,
closeTab: mockCloseTab,
+ setTabLayout: mockSetTabLayout,
clearTabPtyId: vi.fn(),
setAgentStatus: vi.fn()
}
@@ -47,15 +69,39 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
describe('launchAgentBackgroundSession', () => {
beforeEach(() => {
+ clearRuntimeCompatibilityCacheForTests()
vi.clearAllMocks()
+ mockRuntimeEnvironmentTransportCall.mockImplementation(
+ (args: RuntimeEnvironmentCallRequest) => {
+ return (
+ createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args)
+ )
+ }
+ )
+ state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null }
mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' })
mockSpawn.mockResolvedValue({ id: 'pty-1' })
+ mockRuntimeEnvironmentCall.mockResolvedValue({
+ ok: true,
+ result: { terminal: { handle: 'terminal-1', worktreeId: 'wt-1', title: null } }
+ })
+ mockRuntimeEnvironmentSubscribe.mockImplementation(async (_args, callbacks) => {
+ queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } }))
+ return { unsubscribe: vi.fn(), sendBinary: vi.fn() }
+ })
mockSubscribeToPtyData.mockReturnValue(vi.fn())
mockSubscribeToPtyExit.mockReturnValue(vi.fn())
vi.stubGlobal('window', {
api: {
pty: {
spawn: mockSpawn
+ },
+ runtime: {
+ call: vi.fn()
+ },
+ runtimeEnvironments: {
+ call: mockRuntimeEnvironmentTransportCall,
+ subscribe: mockRuntimeEnvironmentSubscribe
}
}
})
@@ -76,15 +122,24 @@ describe('launchAgentBackgroundSession', () => {
expect.objectContaining({
cwd: '/repo/worktree',
command: "claude 'run the automation'",
- env: {
- ORCA_PANE_KEY: 'tab-1:1',
+ env: expect.objectContaining({
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
- },
+ }),
connectionId: null,
worktreeId: 'wt-1',
- tabId: 'tab-1',
- leafId: 'pane:1'
+ tabId: 'tab-1'
+ })
+ )
+ const paneKey = expectStablePaneSpawn()
+ const leafId = paneKey.slice('tab-1:'.length)
+ expect(mockSetTabLayout).toHaveBeenCalledWith(
+ 'tab-1',
+ expect.objectContaining({
+ root: { type: 'leaf', leafId },
+ activeLeafId: leafId,
+ ptyIdsByLeafId: { [leafId]: 'pty-1' },
+ titlesByLeafId: { [leafId]: 'Nightly audit' }
})
)
expect(mockSetTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Nightly audit')
@@ -109,8 +164,9 @@ describe('launchAgentBackgroundSession', () => {
const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void
dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07')
+ const paneKey = expectStablePaneSpawn()
expect(state.setAgentStatus).toHaveBeenCalledWith(
- 'tab-1:1',
+ paneKey,
expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }),
undefined
)
@@ -175,4 +231,57 @@ describe('launchAgentBackgroundSession', () => {
})
)
})
+
+ it('creates background sessions on the active runtime environment', async () => {
+ state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' }
+ const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
+
+ const result = await launchAgentBackgroundSession({
+ agent: 'claude',
+ worktreeId: 'wt-1',
+ prompt: 'run the automation'
+ })
+
+ expect(mockSpawn).not.toHaveBeenCalled()
+ const params = mockRuntimeEnvironmentCall.mock.calls[0]?.[0]?.params
+ const paneKey = params?.env?.ORCA_PANE_KEY
+ const leafId = typeof paneKey === 'string' ? paneKey.slice('tab-1:'.length) : ''
+ expect(leafId).toMatch(UUID_RE)
+ expect(mockSetTabLayout).toHaveBeenCalledWith(
+ 'tab-1',
+ expect.objectContaining({
+ root: { type: 'leaf', leafId },
+ activeLeafId: leafId,
+ ptyIdsByLeafId: { [leafId]: 'remote:env-1@@terminal-1' }
+ })
+ )
+ expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith({
+ selector: 'env-1',
+ method: 'terminal.create',
+ params: expect.objectContaining({
+ worktree: 'wt-1',
+ command: "claude 'run the automation'",
+ env: expect.objectContaining({
+ ORCA_PANE_KEY: `tab-1:${leafId}`,
+ ORCA_TAB_ID: 'tab-1',
+ ORCA_WORKTREE_ID: 'wt-1'
+ }),
+ tabId: 'tab-1',
+ leafId,
+ focus: false
+ }),
+ timeoutMs: 15_000
+ })
+ expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'remote:env-1@@terminal-1')
+ expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled()
+ expect(mockRuntimeEnvironmentSubscribe).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selector: 'env-1',
+ method: 'terminal.multiplex',
+ params: {}
+ }),
+ expect.any(Object)
+ )
+ expect(result).toMatchObject({ tabId: 'tab-1', ptyId: 'remote:env-1@@terminal-1' })
+ })
})
diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts
index d0ae944bb18..6240ad8b19f 100644
--- a/src/renderer/src/lib/launch-agent-background-session.ts
+++ b/src/renderer/src/lib/launch-agent-background-session.ts
@@ -7,14 +7,22 @@ import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
import type { LaunchSource } from '../../../shared/telemetry-events'
-import { FIRST_PANE_ID } from '../../../shared/pane-key'
+import { makePaneKey } from '../../../shared/stable-pane-id'
import {
registerEagerPtyBuffer,
subscribeToPtyData,
subscribeToPtyExit
} from '@/components/terminal-pane/pty-dispatcher'
import { createAgentStatusOscProcessor } from '@/components/terminal-pane/agent-status-osc'
+import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
+import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
+import {
+ getRemoteRuntimeTerminalHandle,
+ subscribeToRuntimeTerminalData,
+ toRemoteRuntimePtyId
+} from '@/runtime/runtime-terminal-stream'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
+import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
export type LaunchAgentBackgroundSessionArgs = {
agent: TuiAgent
@@ -77,38 +85,63 @@ export async function launchAgentBackgroundSession(
if (title) {
store.setTabCustomTitle(tab.id, title)
}
- const paneKey = `${tab.id}:${FIRST_PANE_ID}`
// Why: agent hook callbacks are keyed by pane, and background automation
// tabs never mount a TerminalPane to inject this env for us.
+ const leafId = globalThis.crypto.randomUUID()
+ const paneKey = makePaneKey(tab.id, leafId)
+ store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, undefined, title))
const paneEnv = {
...startupPlan.env,
ORCA_PANE_KEY: paneKey,
ORCA_TAB_ID: tab.id,
ORCA_WORKTREE_ID: worktreeId
}
- let result: Awaited>
+ const runtimeTarget = getActiveRuntimeTarget(store.settings)
+ let ptyId: string
try {
- result = await window.api.pty.spawn({
- cols: 120,
- rows: 40,
- cwd: worktree.path,
- command: startupPlan.launchCommand,
- env: paneEnv,
- connectionId: repo?.connectionId ?? null,
- worktreeId,
- tabId: tab.id,
- leafId: 'pane:1',
- telemetry: {
- agent_kind: tuiAgentToAgentKind(agent),
- launch_source: launchSource ?? 'unknown',
- request_kind: 'new'
- }
- })
+ if (runtimeTarget.kind === 'environment') {
+ // Why: runtime environments execute on the server; using local pty.spawn
+ // would silently run automation on the client for a remote workspace.
+ const created = await callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>(
+ runtimeTarget,
+ 'terminal.create',
+ {
+ worktree: worktreeId,
+ command: startupPlan.launchCommand,
+ env: paneEnv,
+ title,
+ tabId: tab.id,
+ leafId,
+ focus: false
+ },
+ { timeoutMs: 15_000 }
+ )
+ ptyId = toRemoteRuntimePtyId(created.terminal.handle, runtimeTarget.environmentId)
+ } else {
+ const result = await window.api.pty.spawn({
+ cols: 120,
+ rows: 40,
+ cwd: worktree.path,
+ command: startupPlan.launchCommand,
+ env: paneEnv,
+ connectionId: repo?.connectionId ?? null,
+ worktreeId,
+ tabId: tab.id,
+ leafId,
+ telemetry: {
+ agent_kind: tuiAgentToAgentKind(agent),
+ launch_source: launchSource ?? 'unknown',
+ request_kind: 'new'
+ }
+ })
+ ptyId = result.id
+ }
} catch (error) {
store.closeTab(tab.id)
throw error
}
- store.updateTabPtyId(tab.id, result.id)
+ store.updateTabPtyId(tab.id, ptyId)
+ store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId, title))
let exitHandled = false
let unsubscribeExit = (): void => {}
let unsubscribeData = (): void => {}
@@ -122,19 +155,41 @@ export async function launchAgentBackgroundSession(
useAppStore.getState().clearTabPtyId(tab.id, ptyId)
onExit?.(ptyId, code)
}
- registerEagerPtyBuffer(result.id, handleExit)
const processAgentStatus = createAgentStatusOscProcessor()
- unsubscribeData = subscribeToPtyData(result.id, (data) => {
+ const handleData = (data: string): void => {
const processed = processAgentStatus(data)
for (const payload of processed.payloads) {
useAppStore.getState().setAgentStatus(paneKey, payload, undefined)
onAgentStatus?.(payload)
}
- })
- // Why: opening the workspace attaches a real terminal transport and disposes
- // the eager exit handler. This sidecar keeps automation completion tracking
- // alive regardless of whether the tab is hidden or mounted.
- unsubscribeExit = subscribeToPtyExit(result.id, (code) => handleExit(result.id, code))
+ }
+ if (runtimeTarget.kind === 'environment') {
+ unsubscribeData = await subscribeToRuntimeTerminalData(
+ store.settings,
+ ptyId,
+ `desktop:background:${tab.id}`,
+ handleData
+ )
+ const terminal = getRemoteRuntimeTerminalHandle(ptyId)
+ if (!terminal) {
+ throw new Error('Runtime terminal id is invalid.')
+ }
+ void callRuntimeRpc<{ wait: { exitCode?: number | null } }>(
+ runtimeTarget,
+ 'terminal.wait',
+ { terminal, for: 'exit' },
+ { timeoutMs: 24 * 60 * 60 * 1000 }
+ )
+ .then((result) => handleExit(ptyId, result.wait.exitCode ?? 0))
+ .catch(() => {})
+ } else {
+ registerEagerPtyBuffer(ptyId, handleExit)
+ unsubscribeData = subscribeToPtyData(ptyId, handleData)
+ // Why: opening the workspace attaches a real terminal transport and disposes
+ // the eager exit handler. This sidecar keeps automation completion tracking
+ // alive regardless of whether the tab is hidden or mounted.
+ unsubscribeExit = subscribeToPtyExit(ptyId, (code) => handleExit(ptyId, code))
+ }
if (pasteDraftAfterLaunch !== null) {
void pasteDraftWhenAgentReady({
@@ -152,5 +207,5 @@ export async function launchAgentBackgroundSession(
})
}
- return { tabId: tab.id, ptyId: result.id, startupPlan }
+ return { tabId: tab.id, ptyId, startupPlan }
}
diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts
index 6a0ec8b3a5a..1914f8d7879 100644
--- a/src/renderer/src/lib/launch-work-item-direct.ts
+++ b/src/renderer/src/lib/launch-work-item-direct.ts
@@ -12,6 +12,7 @@ import {
getWorkspaceSeedName
} from '@/lib/new-workspace'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
+import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import type {
OrcaHooks,
@@ -91,7 +92,7 @@ async function resolveSetupDecision(
): Promise<{ kind: 'decided'; decision: SetupDecision } | { kind: 'needs-modal' }> {
let yamlHooks: OrcaHooks | null = null
try {
- const result = await window.api.hooks.check({ repoId })
+ const result = await checkRuntimeHooks(useAppStore.getState().settings, repoId)
yamlHooks = (result.hooks as OrcaHooks | null) ?? null
} catch {
yamlHooks = null
@@ -222,26 +223,13 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
telemetrySource,
item.title,
item.type === 'issue' && item.number ? item.number : undefined,
- item.type === 'pr' && item.number ? item.number : undefined
+ item.type === 'pr' && item.number ? item.number : undefined,
+ undefined,
+ undefined,
+ item.linearIdentifier
)
worktreeId = result.worktree.id
const worktreePath = result.worktree.path
- const meta: {
- linkedLinearIssue?: string
- } = {}
- if (item.linearIdentifier) {
- meta.linkedLinearIssue = item.linearIdentifier
- }
- if (Object.keys(meta).length > 0) {
- // Why: Project direct-launch activates the new workspace immediately.
- // GitHub issue/PR links are persisted during create; Linear metadata
- // still uses a best-effort follow-up because create args do not carry it.
- // Best-effort: the worktree is already created on disk, so a meta
- // write failure must not abort activation and orphan it.
- void store.updateWorktreeMeta(worktreeId, meta).catch(() => {
- // Non-critical: continue into activation without the link metadata.
- })
- }
const detectedIds = new Set(await detectedAgentsPromise)
effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
diff --git a/src/renderer/src/lib/local-path-open-guard.test.ts b/src/renderer/src/lib/local-path-open-guard.test.ts
new file mode 100644
index 00000000000..011757af112
--- /dev/null
+++ b/src/renderer/src/lib/local-path-open-guard.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import { isLocalPathOpenBlocked } from './local-path-open-guard'
+
+describe('isLocalPathOpenBlocked', () => {
+ it('allows local paths without a runtime or SSH connection', () => {
+ expect(isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: null })).toBe(false)
+ })
+
+ it('blocks paths while a runtime environment is active', () => {
+ expect(isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: 'env-1' })).toBe(true)
+ })
+
+ it('blocks SSH-backed paths', () => {
+ expect(
+ isLocalPathOpenBlocked({ activeRuntimeEnvironmentId: null }, { connectionId: 'ssh-1' })
+ ).toBe(true)
+ })
+})
diff --git a/src/renderer/src/lib/local-path-open-guard.ts b/src/renderer/src/lib/local-path-open-guard.ts
new file mode 100644
index 00000000000..3b34a7747f3
--- /dev/null
+++ b/src/renderer/src/lib/local-path-open-guard.ts
@@ -0,0 +1,15 @@
+import { toast } from 'sonner'
+import type { GlobalSettings } from '../../../shared/types'
+
+export function isLocalPathOpenBlocked(
+ settings: Pick | null | undefined,
+ context?: { connectionId?: string | null }
+): boolean {
+ return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || context?.connectionId?.trim())
+}
+
+export function showLocalPathOpenBlockedToast(): void {
+ // Why: local OS reveal/open actions receive client filesystem paths. Remote
+ // runtime and SSH paths belong to another machine, not this client.
+ toast.error('Opening remote paths in the local OS is not available.')
+}
diff --git a/src/renderer/src/lib/markdown-comment-blocks.test.ts b/src/renderer/src/lib/markdown-comment-blocks.test.ts
new file mode 100644
index 00000000000..f3b0443d6ea
--- /dev/null
+++ b/src/renderer/src/lib/markdown-comment-blocks.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import type { DiffComment } from '../../../shared/types'
+import { mapMarkdownCommentsToBlocks, type MarkdownCommentBlock } from './markdown-comment-blocks'
+
+function makeComment(overrides: Partial = {}): DiffComment {
+ return {
+ id: 'c1',
+ worktreeId: 'wt1',
+ filePath: 'README.md',
+ source: 'markdown',
+ lineNumber: 4,
+ body: 'note',
+ createdAt: 0,
+ side: 'modified',
+ ...overrides
+ }
+}
+
+describe('mapMarkdownCommentsToBlocks', () => {
+ it('maps comments to the rendered block that owns their anchor line', () => {
+ const blocks: MarkdownCommentBlock[] = [
+ { key: 'p:2-3', startLine: 2, endLine: 3 },
+ { key: 'p:4-6', startLine: 4, endLine: 6 }
+ ]
+ const mapped = mapMarkdownCommentsToBlocks([makeComment({ id: 'a', lineNumber: 5 })], blocks)
+
+ expect(mapped.byBlockKey.get('p:4-6')?.map((comment) => comment.id)).toEqual(['a'])
+ expect(mapped.unresolved).toEqual([])
+ })
+
+ it('keeps comments without a rendered block unresolved for shelf fallback', () => {
+ const blocks: MarkdownCommentBlock[] = [{ key: 'p:2-3', startLine: 2, endLine: 3 }]
+ const mapped = mapMarkdownCommentsToBlocks(
+ [makeComment({ id: 'stale', lineNumber: 20 })],
+ blocks
+ )
+
+ expect(mapped.byBlockKey.size).toBe(0)
+ expect(mapped.unresolved.map((comment) => comment.id)).toEqual(['stale'])
+ })
+})
diff --git a/src/renderer/src/lib/markdown-comment-blocks.ts b/src/renderer/src/lib/markdown-comment-blocks.ts
new file mode 100644
index 00000000000..59d11ed4402
--- /dev/null
+++ b/src/renderer/src/lib/markdown-comment-blocks.ts
@@ -0,0 +1,40 @@
+import type { DiffComment } from '../../../shared/types'
+
+export type MarkdownCommentBlock = {
+ key: string
+ startLine: number
+ endLine: number
+}
+
+export type MarkdownCommentBlockMapping = {
+ byBlockKey: Map
+ unresolved: DiffComment[]
+}
+
+export function mapMarkdownCommentsToBlocks(
+ comments: DiffComment[],
+ blocks: MarkdownCommentBlock[]
+): MarkdownCommentBlockMapping {
+ const byBlockKey = new Map()
+ const unresolved: DiffComment[] = []
+
+ for (const comment of comments) {
+ const block = blocks.find(
+ (candidate) =>
+ candidate.startLine <= comment.lineNumber && comment.lineNumber <= candidate.endLine
+ )
+ if (!block) {
+ unresolved.push(comment)
+ continue
+ }
+ const list = byBlockKey.get(block.key) ?? []
+ list.push(comment)
+ byBlockKey.set(block.key, list)
+ }
+
+ for (const list of byBlockKey.values()) {
+ list.sort((a, b) => a.lineNumber - b.lineNumber)
+ }
+
+ return { byBlockKey, unresolved }
+}
diff --git a/src/renderer/src/lib/markdown-review-notes.test.ts b/src/renderer/src/lib/markdown-review-notes.test.ts
new file mode 100644
index 00000000000..b00b6e5e5cc
--- /dev/null
+++ b/src/renderer/src/lib/markdown-review-notes.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest'
+import type { DiffComment } from '../../../shared/types'
+import {
+ formatMarkdownReviewNotes,
+ getMarkdownReviewExcerpt,
+ sortMarkdownReviewNotes,
+ type MarkdownReviewNote
+} from './markdown-review-notes'
+
+function note(overrides: Partial> = {}): MarkdownReviewNote {
+ return {
+ id: 'n1',
+ worktreeId: 'wt1',
+ filePath: 'README.md',
+ source: 'markdown',
+ lineNumber: 2,
+ body: 'needs detail',
+ createdAt: 0,
+ side: 'modified',
+ ...overrides
+ }
+}
+
+describe('markdown review notes', () => {
+ it('sorts by file path, source line, then creation time', () => {
+ const sorted = sortMarkdownReviewNotes([
+ note({ id: 'later', lineNumber: 4, createdAt: 2 }),
+ note({ id: 'other-file', filePath: 'docs/a.md', lineNumber: 10 }),
+ note({ id: 'earlier', lineNumber: 4, createdAt: 1 }),
+ note({ id: 'first', lineNumber: 1 })
+ ])
+
+ expect(sorted.map((item) => item.id)).toEqual(['other-file', 'first', 'earlier', 'later'])
+ })
+
+ it('extracts the annotated markdown lines as quoted context', () => {
+ const excerpt = getMarkdownReviewExcerpt(
+ 'one\ntwo\nthree',
+ note({ startLine: 2, lineNumber: 3 })
+ )
+
+ expect(excerpt).toBe('> two\n> three')
+ })
+
+ it('formats a deterministic prompt for terminal agents', () => {
+ const formatted = formatMarkdownReviewNotes(
+ [note({ startLine: 2, lineNumber: 3, body: 'replace "maybe"\nwith specifics' })],
+ 'one\ntwo\nthree'
+ )
+
+ expect(formatted).toBe(
+ [
+ 'File: README.md',
+ 'Source: markdown',
+ 'Lines 2-3',
+ 'Excerpt:',
+ '> two',
+ '> three',
+ 'User comment: "replace \\"maybe\\"\\nwith specifics"'
+ ].join('\n')
+ )
+ })
+})
diff --git a/src/renderer/src/lib/markdown-review-notes.ts b/src/renderer/src/lib/markdown-review-notes.ts
new file mode 100644
index 00000000000..be9167d4cd4
--- /dev/null
+++ b/src/renderer/src/lib/markdown-review-notes.ts
@@ -0,0 +1,74 @@
+import type { DiffComment } from '../../../shared/types'
+import { getDiffCommentLineLabel } from './diff-comment-compat'
+
+const MAX_EXCERPT_LINES = 8
+
+export type MarkdownReviewNote = DiffComment & { source: 'markdown' }
+
+export function sortMarkdownReviewNotes(
+ notes: readonly MarkdownReviewNote[]
+): MarkdownReviewNote[] {
+ return [...notes].sort((a, b) => {
+ const pathCompare = a.filePath.localeCompare(b.filePath)
+ if (pathCompare !== 0) {
+ return pathCompare
+ }
+ const startA = a.startLine ?? a.lineNumber
+ const startB = b.startLine ?? b.lineNumber
+ if (startA !== startB) {
+ return startA - startB
+ }
+ if (a.lineNumber !== b.lineNumber) {
+ return a.lineNumber - b.lineNumber
+ }
+ return a.createdAt - b.createdAt
+ })
+}
+
+export function getMarkdownReviewExcerpt(
+ content: string,
+ note: Pick
+): string {
+ const lines = content.split(/\r?\n/)
+ const startLine = Math.max(1, note.startLine ?? note.lineNumber)
+ const endLine = Math.max(startLine, note.lineNumber)
+ const selected = lines.slice(startLine - 1, endLine)
+ if (selected.length === 0) {
+ return ''
+ }
+
+ const excerpt =
+ selected.length <= MAX_EXCERPT_LINES
+ ? selected
+ : [
+ ...selected.slice(0, Math.ceil(MAX_EXCERPT_LINES / 2)),
+ '...',
+ ...selected.slice(selected.length - Math.floor(MAX_EXCERPT_LINES / 2))
+ ]
+
+ return excerpt.map((line) => `> ${line}`).join('\n')
+}
+
+export function formatMarkdownReviewNotes(
+ notes: readonly MarkdownReviewNote[],
+ content: string
+): string {
+ return sortMarkdownReviewNotes(notes)
+ .map((note) => {
+ const escapedBody = note.body
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/\r/g, '\\r')
+ .replace(/\n/g, '\\n')
+ const excerpt = getMarkdownReviewExcerpt(content, note)
+ const parts = [
+ `File: ${note.filePath}`,
+ 'Source: markdown',
+ getDiffCommentLineLabel(note),
+ excerpt ? `Excerpt:\n${excerpt}` : null,
+ `User comment: "${escapedBody}"`
+ ]
+ return parts.filter((part): part is string => part !== null).join('\n')
+ })
+ .join('\n\n')
+}
diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts
new file mode 100644
index 00000000000..cdb59cc02e1
--- /dev/null
+++ b/src/renderer/src/lib/migration-unsupported-agent-entry.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { migrationUnsupportedToAgentStatusEntry } from './migration-unsupported-agent-entry'
+import type { MigrationUnsupportedPtyEntry } from '../../../shared/agent-status-types'
+
+describe('migrationUnsupportedToAgentStatusEntry', () => {
+ it('returns a stable synthetic entry for the same migration record', () => {
+ const unsupported: MigrationUnsupportedPtyEntry = {
+ ptyId: 'pty-1',
+ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
+ reason: 'legacy-numeric-pane-key',
+ source: 'local',
+ updatedAt: 1234
+ }
+
+ const first = migrationUnsupportedToAgentStatusEntry(unsupported)
+ const second = migrationUnsupportedToAgentStatusEntry(unsupported)
+
+ expect(second).toBe(first)
+ expect(first?.updatedAt).toBe(Number.MAX_SAFE_INTEGER)
+ })
+
+ it('caches null for records that cannot be projected to a pane key', () => {
+ const unsupported: MigrationUnsupportedPtyEntry = {
+ ptyId: 'pty-1',
+ reason: 'legacy-numeric-pane-key',
+ source: 'ssh',
+ updatedAt: 1234
+ }
+
+ expect(migrationUnsupportedToAgentStatusEntry(unsupported)).toBeNull()
+ expect(migrationUnsupportedToAgentStatusEntry(unsupported)).toBeNull()
+ })
+})
diff --git a/src/renderer/src/lib/migration-unsupported-agent-entry.ts b/src/renderer/src/lib/migration-unsupported-agent-entry.ts
new file mode 100644
index 00000000000..20033a486c5
--- /dev/null
+++ b/src/renderer/src/lib/migration-unsupported-agent-entry.ts
@@ -0,0 +1,39 @@
+import type {
+ AgentStatusEntry,
+ MigrationUnsupportedPtyEntry
+} from '../../../shared/agent-status-types'
+
+const cachedMigrationUnsupportedEntries = new WeakMap<
+ MigrationUnsupportedPtyEntry,
+ AgentStatusEntry | null
+>()
+
+export function migrationUnsupportedToAgentStatusEntry(
+ entry: MigrationUnsupportedPtyEntry
+): AgentStatusEntry | null {
+ const cached = cachedMigrationUnsupportedEntries.get(entry)
+ if (cached !== undefined) {
+ return cached
+ }
+
+ const converted: AgentStatusEntry | null = !entry.paneKey
+ ? null
+ : {
+ state: 'blocked',
+ prompt: 'Agent unavailable after pane identity migration',
+ // Why: this synthetic row represents a persistent migration block. Keep
+ // it "fresh" without Date.now() so Zustand selectors can return a stable
+ // cached object for the same store snapshot.
+ updatedAt: Number.MAX_SAFE_INTEGER,
+ stateStartedAt: entry.updatedAt,
+ agentType: 'unknown',
+ paneKey: entry.paneKey,
+ terminalTitle: 'Migration unsupported',
+ stateHistory: [],
+ lastAssistantMessage:
+ 'Restart this terminal so Orca can attach a stable UUID pane key to agent hooks.'
+ }
+
+ cachedMigrationUnsupportedEntries.set(entry, converted)
+ return converted
+}
diff --git a/src/renderer/src/lib/new-workspace-create-gates.test.ts b/src/renderer/src/lib/new-workspace-create-gates.test.ts
new file mode 100644
index 00000000000..d23102ddb3b
--- /dev/null
+++ b/src/renderer/src/lib/new-workspace-create-gates.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getFullComposerCreateDisabled,
+ getQuickComposerCreateDisabled,
+ type ComposerCreateGateInput
+} from './new-workspace-create-gates'
+
+const readyInput: ComposerCreateGateInput = {
+ repoId: 'repo-1',
+ workspaceSeedName: 'feature',
+ creating: false,
+ shouldWaitForSetupCheck: false,
+ shouldWaitForIssueAutomationCheck: false,
+ requiresExplicitSetupChoice: false,
+ hasSetupDecision: false,
+ sparseError: null
+}
+
+describe('new workspace create gates', () => {
+ it('keeps the full composer disabled while setup and issue automation probes are pending', () => {
+ expect(
+ getFullComposerCreateDisabled({
+ ...readyInput,
+ shouldWaitForSetupCheck: true
+ })
+ ).toBe(true)
+
+ expect(
+ getFullComposerCreateDisabled({
+ ...readyInput,
+ shouldWaitForIssueAutomationCheck: true
+ })
+ ).toBe(true)
+ })
+
+ it('lets quick create submit while background setup and issue probes are pending', () => {
+ expect(
+ getQuickComposerCreateDisabled({
+ ...readyInput,
+ shouldWaitForSetupCheck: true,
+ shouldWaitForIssueAutomationCheck: true
+ })
+ ).toBe(false)
+ })
+
+ it('still blocks quick create for missing form state and explicit setup choices', () => {
+ expect(getQuickComposerCreateDisabled({ ...readyInput, repoId: '' })).toBe(true)
+ expect(getQuickComposerCreateDisabled({ ...readyInput, workspaceSeedName: '' })).toBe(true)
+ expect(getQuickComposerCreateDisabled({ ...readyInput, creating: true })).toBe(true)
+ expect(
+ getQuickComposerCreateDisabled({
+ ...readyInput,
+ requiresExplicitSetupChoice: true,
+ hasSetupDecision: false
+ })
+ ).toBe(true)
+ expect(getQuickComposerCreateDisabled({ ...readyInput, sparseError: 'Bad sparse path' })).toBe(
+ true
+ )
+ })
+})
diff --git a/src/renderer/src/lib/new-workspace-create-gates.ts b/src/renderer/src/lib/new-workspace-create-gates.ts
new file mode 100644
index 00000000000..e5ad63484ef
--- /dev/null
+++ b/src/renderer/src/lib/new-workspace-create-gates.ts
@@ -0,0 +1,36 @@
+export type ComposerCreateGateInput = {
+ repoId: string
+ workspaceSeedName: string
+ creating: boolean
+ shouldWaitForSetupCheck: boolean
+ shouldWaitForIssueAutomationCheck: boolean
+ requiresExplicitSetupChoice: boolean
+ hasSetupDecision: boolean
+ sparseError: string | null
+}
+
+function hasBlockingCreateState(input: ComposerCreateGateInput): boolean {
+ return (
+ !input.repoId ||
+ !input.workspaceSeedName ||
+ input.creating ||
+ (input.requiresExplicitSetupChoice && !input.hasSetupDecision) ||
+ input.sparseError !== null
+ )
+}
+
+export function getFullComposerCreateDisabled(input: ComposerCreateGateInput): boolean {
+ return (
+ hasBlockingCreateState(input) ||
+ input.shouldWaitForSetupCheck ||
+ input.shouldWaitForIssueAutomationCheck
+ )
+}
+
+export function getQuickComposerCreateDisabled(input: ComposerCreateGateInput): boolean {
+ // Why: Cmd/Ctrl+N quick create can resolve setup hooks inside the submit
+ // handler, and it never runs issue-command automation. Keeping those
+ // background probes out of the disabled gate makes the primary action usable
+ // as soon as the form has enough local state to submit.
+ return hasBlockingCreateState(input)
+}
diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts
index 9327f240647..583d0b5f9e3 100644
--- a/src/renderer/src/lib/new-workspace.ts
+++ b/src/renderer/src/lib/new-workspace.ts
@@ -1,5 +1,9 @@
import { useAppStore } from '@/store'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
+import {
+ inspectRuntimeTerminalProcess,
+ sendRuntimePtyInput
+} from '@/runtime/runtime-terminal-inspection'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
@@ -45,6 +49,9 @@ export type LinkedWorkItemSummary = {
number: number
title: string
url: string
+ /** Linear identifier (for example ENG-123) when this linked item came from
+ * Linear rather than GitHub. */
+ linearIdentifier?: string
}
// Why: when a repo has no `orca.yaml` issueCommand and no per-user override,
@@ -242,7 +249,7 @@ export async function ensureAgentStartupInTerminal(args: {
// session and submitted. Wait until the agent owns the PTY before writing.
if (startup.followupPrompt) {
await waitForAgentForeground(ptyId, startup.expectedProcess)
- window.api.pty.write(ptyId, `${startup.followupPrompt}\r`)
+ sendRuntimePtyInput(useAppStore.getState().settings, ptyId, `${startup.followupPrompt}\r`)
}
// Why: draftPrompt uses bracketed-paste so the URL lands atomically in the
@@ -267,7 +274,8 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P
await new Promise((resolve) => window.setTimeout(resolve, 150))
}
try {
- const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? ''
+ const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId)
+ const foreground = process.foregroundProcess?.toLowerCase() ?? ''
const owns =
foreground === expectedProcess ||
foreground.startsWith(`${expectedProcess}.`) ||
@@ -276,8 +284,7 @@ async function waitForAgentForeground(ptyId: string, expectedProcess: string): P
return
}
if (attempt >= 4 && !isShellProcess(foreground)) {
- const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId)
- if (hasChildProcesses) {
+ if (process.hasChildProcesses) {
return
}
}
diff --git a/src/renderer/src/lib/notes-events.ts b/src/renderer/src/lib/notes-events.ts
deleted file mode 100644
index ede0a9b9647..00000000000
--- a/src/renderer/src/lib/notes-events.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const NOTES_ACTIVE_CHANGED_EVENT = 'orca:notes-active-changed'
diff --git a/src/renderer/src/lib/open-project-notes-tab.ts b/src/renderer/src/lib/open-project-notes-tab.ts
deleted file mode 100644
index f072ecd22ea..00000000000
--- a/src/renderer/src/lib/open-project-notes-tab.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useAppStore } from '@/store'
-import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
-
-export function getProjectNotesEntityId(projectId: string, noteId?: string): string {
- if (noteId) {
- return `notes:${projectId}:note:${noteId}`
- }
- return `notes:${projectId}:new:${globalThis.crypto.randomUUID()}`
-}
-
-export function getProjectNoteIdFromEntityId(entityId: string): string | null {
- const [, , kind, noteId] = entityId.split(':')
- return kind === 'note' && noteId ? noteId : null
-}
-
-export function isNewProjectNoteEntityId(entityId: string): boolean {
- const [, , kind] = entityId.split(':')
- return kind === 'new'
-}
-
-export async function openProjectNotesTab(worktreeId: string, noteId?: string): Promise {
- const state = useAppStore.getState()
- const targetGroupId =
- state.activeGroupIdByWorktree[worktreeId] ?? (state.groupsByWorktree[worktreeId] ?? [])[0]?.id
- const worktree = Object.values(state.worktreesByRepo)
- .flat()
- .find((candidate) => candidate.id === worktreeId)
- const repo = state.repos.find((candidate) => candidate.id === worktree?.repoId)
- const projectId = repo?.id ?? worktree?.repoId ?? null
-
- if (noteId && projectId) {
- await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' })
- }
-
- let label = 'Project Notes'
- if (noteId && projectId) {
- try {
- const result = await window.api.notes.show({ projectId, worktreeId, note: noteId })
- label = result.note.title
- } catch {
- label = 'Project Notes'
- }
- }
-
- state.setActiveView('terminal')
-
- const tab = state.createUnifiedTab(worktreeId, 'notes', {
- targetGroupId,
- label,
- entityId: getProjectNotesEntityId(projectId ?? worktree?.repoId ?? worktreeId, noteId)
- })
- state.focusGroup(worktreeId, tab.groupId)
- state.activateTab(tab.id)
- state.setActiveTabType('notes')
- if (noteId) {
- notifyProjectNotesSelectionChanged()
- }
-}
-
-export function notifyProjectNotesSelectionChanged(): void {
- window.dispatchEvent(new CustomEvent(NOTES_ACTIVE_CHANGED_EVENT))
-}
diff --git a/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts
new file mode 100644
index 00000000000..10f332f49ac
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/mint-stable-pane-id.ts
@@ -0,0 +1,25 @@
+import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
+
+// Why: Electron/test runtimes can lack crypto.randomUUID. The fallback still
+// produces a UUID-shaped v4 id so pane-key validation remains deterministic.
+export function mintStablePaneId(): TerminalLeafId {
+ const cryptoApi = globalThis.crypto as Crypto | undefined
+ if (cryptoApi?.randomUUID) {
+ return cryptoApi.randomUUID() as TerminalLeafId
+ }
+ const bytes = new Uint8Array(16)
+ if (cryptoApi?.getRandomValues) {
+ cryptoApi.getRandomValues(bytes)
+ } else {
+ for (let i = 0; i < bytes.length; i += 1) {
+ bytes[i] = Math.floor(Math.random() * 256)
+ }
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
+ 16,
+ 20
+ )}-${hex.slice(20)}` as TerminalLeafId
+}
diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts
new file mode 100644
index 00000000000..94b6e15c98b
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ getDriverForPty,
+ hydrateDrivers,
+ isPtyLocked,
+ onDriverChange,
+ setDriverForPty
+} from './mobile-driver-state'
+
+afterEach(() => {
+ hydrateDrivers([])
+})
+
+describe('mobile-driver-state', () => {
+ it('stores and clears driver state keyed by PTY id', () => {
+ setDriverForPty('pty-1', { kind: 'mobile', clientId: 'phone-1' })
+
+ expect(getDriverForPty('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-1' })
+ expect(isPtyLocked('pty-1')).toBe(true)
+
+ setDriverForPty('pty-1', { kind: 'idle' })
+
+ expect(getDriverForPty('pty-1')).toEqual({ kind: 'idle' })
+ expect(isPtyLocked('pty-1')).toBe(false)
+ })
+
+ it('hydrates driver snapshots and notifies affected listeners', () => {
+ setDriverForPty('pty-old', { kind: 'mobile', clientId: 'phone-old' })
+ const listener = vi.fn()
+ const unsub = onDriverChange(listener)
+
+ hydrateDrivers([{ ptyId: 'pty-new', driver: { kind: 'mobile', clientId: 'phone-new' } }])
+
+ expect(getDriverForPty('pty-old')).toEqual({ kind: 'idle' })
+ expect(getDriverForPty('pty-new')).toEqual({ kind: 'mobile', clientId: 'phone-new' })
+ expect(listener).toHaveBeenCalledWith({
+ ptyId: 'pty-old',
+ driver: { kind: 'idle' }
+ })
+ expect(listener).toHaveBeenCalledWith({
+ ptyId: 'pty-new',
+ driver: { kind: 'mobile', clientId: 'phone-new' }
+ })
+
+ unsub()
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts
index f6e066dd040..3e97491e7ef 100644
--- a/src/renderer/src/lib/pane-manager/mobile-driver-state.ts
+++ b/src/renderer/src/lib/pane-manager/mobile-driver-state.ts
@@ -9,10 +9,9 @@
//
// See docs/mobile-presence-lock.md.
-export type DriverState =
- | { kind: 'idle' }
- | { kind: 'desktop' }
- | { kind: 'mobile'; clientId: string }
+import type { RuntimeTerminalDriverState } from '../../../../shared/runtime-types'
+
+export type DriverState = RuntimeTerminalDriverState
const driverByPtyId = new Map()
@@ -50,3 +49,21 @@ export function getDriverForPty(ptyId: string): DriverState {
export function isPtyLocked(ptyId: string): boolean {
return driverByPtyId.get(ptyId)?.kind === 'mobile'
}
+
+export function hydrateDrivers(drivers: { ptyId: string; driver: DriverState }[]): void {
+ const affectedPtyIds = new Set(driverByPtyId.keys())
+ driverByPtyId.clear()
+
+ for (const { ptyId, driver } of drivers) {
+ affectedPtyIds.add(ptyId)
+ if (driver.kind !== 'idle') {
+ driverByPtyId.set(ptyId, driver)
+ }
+ }
+
+ // Why: startup hydration can arrive after TerminalPane has mounted. Notify
+ // all affected PTYs so the pane-local overlay cannot miss an active lock.
+ for (const ptyId of affectedPtyIds) {
+ notifyChange({ ptyId, driver: getDriverForPty(ptyId) })
+ }
+}
diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts
new file mode 100644
index 00000000000..882d2b8ea15
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides-hydration.test.ts
@@ -0,0 +1,25 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { hydrateOverrides, onOverrideChange, setFitOverride } from './mobile-fit-overrides'
+
+afterEach(() => {
+ hydrateOverrides([])
+})
+
+describe('hydrateOverrides notifications', () => {
+ it('notifies listeners for hydrated and cleared overrides', () => {
+ setFitOverride('pty-old', 'mobile-fit', 49, 20)
+ const events: { ptyId: string; mode: string; priorCols: number | null }[] = []
+ const unsub = onOverrideChange((event) => {
+ events.push({ ptyId: event.ptyId, mode: event.mode, priorCols: event.priorCols })
+ })
+
+ hydrateOverrides([{ ptyId: 'pty-new', mode: 'mobile-fit', cols: 80, rows: 30 }])
+
+ expect(events).toEqual([
+ { ptyId: 'pty-new', mode: 'mobile-fit', priorCols: null },
+ { ptyId: 'pty-old', mode: 'desktop-fit', priorCols: 49 }
+ ])
+
+ unsub()
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts
index e489069624c..3ab4937c4d2 100644
--- a/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts
+++ b/src/renderer/src/lib/pane-manager/mobile-fit-overrides.ts
@@ -10,9 +10,13 @@ type FitOverride = {
}
const overridesByPtyId = new Map()
-// Why: keyed by 'tabId:paneId' composite to avoid collisions when different
-// tabs have panes with the same numeric ID (pane IDs are per-tab, not global).
-const ptyIdByPaneKey = new Map()
+// Why: this is an in-memory renderer fit binding, not an agent paneKey.
+// Numeric pane ids are valid here because fit overrides never cross replay.
+const ptyIdByFitBindingKey = new Map