Show Source Control for folder workspaces (#38831)

* Separate editor tab placement from content ownership

* Show scoped Source Control sections for attached worktrees

* Preserve active diff refresh and validate section catalogs

* Complete folder section test worktree metadata

* Use contextual types for scoped Source Control actions

* Preserve optional scoped action parameters

* Preserve editor ownership through restore, mirroring and workspace removal

* Retire Source Control drafts only after confirmed owner removal

* Limit section work to visible expanded repositories

* Keep editor follow-up actions and bulk closes in their invoking views

* Track the consolidated editor order writer in its owning module

* Use complete typed fixtures for repair contracts

* Preserve live editor placement during layout readback

* Pass projected editor placement directly to serialization

* Read attached worktree lineage from shared graph module

* Retain inferred owner checks and scope placement retirement

* Preserve selection in untouched workspaces during purge

* Exercise editor placements in existing launch and lifecycle fixtures

* Preserve shared buffer prompts and editor strip order

* Pause inactive section context subscriptions

* Keep section recovery and file actions in the folder workspace

* Describe attached Source Control in folder workspace CLI help

* Attribute Git metadata signals once per dispatch

* Watch mounted source control sections outside the warm budget

* Resolve section watches only for served filesystem roots

* Restore restart-stable editor identity across SSH opens

* Apply section terminal routing to explicit Chat choices

* Include cross-store descendants in folder workspace panels

* Keep cross-host recovery on its existing workspace route

* Start cross-host section agents in their workspace without navigation

* Update memoized Source Control labels on language changes

* Remove unused terminal close-to-right implementation

* Suspend inactive Source Control body observers

* Retain base ref query while section observation pauses

* Cover retained base ref search across section visibility

* Resolve note menu ownership from the retained snapshot

* Preserve section agent working directories and background placement

* Keep Source Control sections active while their overlays are open

* Read current settings when Source Control commands execute

* Adapt editor and browser test stores to view subscriptions

* Preserve bound store hooks for ordinary app views

* Trust agent starts outside the placement workspace

* Trust the section worktree through transient launch ownership

* Extract editor follow-up file actions from the panel

* Fail section launches when trust capability lookup fails

Preserve explicit unsupported-host fallback, propagate lookup failures to the existing launch error path, and let the existing transport retry refresh rejected capability probes.

* Follow the moved pane launch test in web configs

* Preserve section file ownership during refresh and close

* Share tab order removal across editor close paths

* Verify companion editor close through the real store

* Close all workspace split copies for legacy mobile file requests

* Keep unmatched mobile close requests within their workspace

* Reuse the agent menu render snapshot

* Keep attached folder roots scoped to their resolved host

* Apply repository trust decisions to the launch trust owner
This commit is contained in:
Brennan Benson
2026-11-06 18:25:57 -08:00
committed by GitHub
parent d6842fdb5f
commit f308486115
336 changed files with 10844 additions and 3314 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read
# the default host, through defaultCreationHost. Everything else routes by the owner.
# Prune after removing reads: pnpm check:focus-read-ratchet --prune
1 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx
3 src/renderer/src/app-shell/ActiveWorkspaceSyncGate.tsx # existing watch selector now visible through useCallback; includes its import-path occurrence
2 src/renderer/src/app-shell/use-app-shell-services.ts
2 src/renderer/src/App.tsx
1 src/renderer/src/component-fixtures/component-fixture-explorer.tsx # dev-only fixture page seeds default settings
@@ -181,7 +181,7 @@
1 src/renderer/src/components/terminal-pane/controller/use-terminal-pane-lifecycle.ts
1 src/renderer/src/components/terminal-pane/mounting/use-terminal-pane-mount-lifecycle.ts
1 src/renderer/src/components/terminal/terminal-tab-actions.ts
4 src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts
2 src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts
2 src/renderer/src/components/terminal/terminal-tab-create.ts
2 src/renderer/src/components/use-new-linear-issue-projects.ts
4 src/renderer/src/components/task-page/jira/use-task-page-jira-creation-metadata.ts
+7 -7
View File
@@ -50,27 +50,27 @@
1 src/main/persistence/tracking-repos/project-group-operations.ts
4 src/main/persistence/tracking-repos/repo-worktree-pruning.ts
3 src/main/persistence/tracking-repos/worktree-identity-migration.ts
2 src/main/pty/scrollback/terminal-scrollback-snapshots.ts
1 src/main/runtime/browser/client-pages/client-hosted-browser-page-persistence.ts
1 src/main/runtime/client/runtime-client-state.ts
1 src/main/runtime/mobile-session-editor-projection.ts
3 src/main/runtime/terminal/surface/mobile-session-layout-projection.ts
4 src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
2 src/main/runtime/terminal/launch/runtime-pane-admission.ts
7 src/main/runtime/terminal/adoption/runtime-terminal-orphan-session-adoption.ts
1 src/main/runtime/workspaces/session-hosts/runtime-workspace-session-controller.ts
2 src/main/runtime/session-graph/session-graph-promotion.ts
2 src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts
2 src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts
2 src/main/runtime/session-tabs/headless-tab-group-moves.ts
6 src/main/runtime/session-tabs/headless-tab-properties.ts
2 src/main/runtime/session-tabs/headless-terminal-tab-titles.ts
1 src/main/runtime/session-tabs/structured-session-tab-close.ts
1 src/main/runtime/session-tabs/terminal-surface-retirements.ts
2 src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts
7 src/main/runtime/terminal/adoption/runtime-terminal-orphan-session-adoption.ts
1 src/main/runtime/terminal/driver/terminal-driver-resources.ts
3 src/main/runtime/terminal/stop/worktree-terminal-stop.ts
4 src/main/runtime/terminal/identity/workspace-session-worktree-id.ts
2 src/main/runtime/terminal/launch/runtime-pane-admission.ts
3 src/main/runtime/terminal/stop/worktree-terminal-stop.ts
3 src/main/runtime/terminal/surface/mobile-session-layout-projection.ts
1 src/main/runtime/workspaces/session-hosts/get-runtime-id.ts
2 src/main/runtime/session-tabs/headless-terminal-tab-titles.ts
1 src/main/runtime/workspaces/session-hosts/runtime-workspace-session-controller.ts
1 src/main/ssh/orcad-migration-source-retention.ts
1 src/main/ssh/targets/ssh-target-id-migration.ts
2 src/main/pty/scrollback/terminal-scrollback-snapshots.ts
+13 -9
View File
@@ -27,6 +27,7 @@
7 src/renderer/src/lib/workspace/persistence/workspace-session-hydration-keys.ts
25 src/renderer/src/lib/workspace/persistence/workspace-session-patch.ts
3 src/renderer/src/lib/workspace/persistence/workspace-session-staged-browser-tabs.ts
1 src/renderer/src/lib/workspace/persistence/workspace-session-unified-tabs.ts
17 src/renderer/src/lib/workspace/persistence/workspace-session.ts
3 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts
3 src/renderer/src/runtime/structured-agent-session-tab-retirement.ts
@@ -52,21 +53,21 @@
5 src/renderer/src/store/slices/browser/browser-page-focus-actions.ts
2 src/renderer/src/store/slices/browser/browser-page-state-actions.ts
1 src/renderer/src/store/slices/browser/browser-tab-actions.ts
5 src/renderer/src/store/slices/browser/browser-tab-open-view.ts # extracted from browser-tab-actions.ts
4 src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts
5 src/renderer/src/store/slices/browser/browser-tab-open-view.ts # extracted from browser-tab-actions.ts
2 src/renderer/src/store/slices/browser/create-browser-slice.ts
9 src/renderer/src/store/slices/editor/actions/check-run-details-actions.ts
7 src/renderer/src/store/slices/editor/actions/close-file-action.ts
6 src/renderer/src/store/slices/editor/actions/close-file-action.ts
1 src/renderer/src/store/slices/editor/actions/git-status-actions.ts
4 src/renderer/src/store/slices/editor/actions/hydrated-editor-session-state.ts
3 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts
27 src/renderer/src/store/slices/editor/actions/open-combined-diff.ts
13 src/renderer/src/store/slices/editor/actions/open-conflict-file.ts
14 src/renderer/src/store/slices/editor/actions/open-conflict-review.ts
4 src/renderer/src/store/slices/editor/actions/open-file-apply.ts
3 src/renderer/src/store/slices/editor/actions/open-combined-diff.ts
1 src/renderer/src/store/slices/editor/actions/open-conflict-file.ts
6 src/renderer/src/store/slices/editor/actions/open-conflict-review.ts
3 src/renderer/src/store/slices/editor/actions/open-file-apply.ts
3 src/renderer/src/store/slices/editor/actions/open-file-mutations.ts
7 src/renderer/src/store/slices/editor/actions/open-file-state.ts
26 src/renderer/src/store/slices/editor/actions/open-history-diff.ts
2 src/renderer/src/store/slices/editor/actions/open-history-diff.ts
1 src/renderer/src/store/slices/editor/actions/open-unstaged-diff.ts
10 src/renderer/src/store/slices/editor/actions/recently-closed-editor-tabs.ts
2 src/renderer/src/store/slices/editor/actions/rekey-open-files-action.ts
@@ -76,14 +77,17 @@
2 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts
1 src/renderer/src/store/slices/editor/file-ids/open-file-path-rekey.ts
4 src/renderer/src/store/slices/editor/tabs/editor-open-target-group.ts
1 src/renderer/src/store/slices/editor/tabs/workspace-editor-files.ts
2 src/renderer/src/store/slices/tab-group-reference-repair.ts
4 src/renderer/src/store/slices/tab-group-state.ts
1 src/renderer/src/store/slices/tabs-hydration.ts
4 src/renderer/src/store/slices/tabs/create-tabs-slice.ts
17 src/renderer/src/store/slices/tabs/tabs-close-actions.ts
12 src/renderer/src/store/slices/tabs/rename-worktree-layout.ts
9 src/renderer/src/store/slices/tabs/tabs-close-actions.ts
14 src/renderer/src/store/slices/tabs/tabs-create-actions.ts
23 src/renderer/src/store/slices/tabs/tabs-drag-preview-actions.ts
11 src/renderer/src/store/slices/tabs/tabs-drop-actions.ts
8 src/renderer/src/store/slices/tabs/tabs-empty-workspace.ts
4 src/renderer/src/store/slices/tabs/tabs-focus-actions.ts
12 src/renderer/src/store/slices/tabs/tabs-group-actions.ts
1 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts
@@ -93,7 +97,7 @@
4 src/renderer/src/store/slices/tabs/tabs-session-actions.ts
1 src/renderer/src/store/slices/tabs/tabs-split-ratio-actions.ts
5 src/renderer/src/store/slices/tabs/tabs-surface.ts
33 src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts
24 src/renderer/src/store/slices/tabs/worktree-layout-lifecycle.ts
6 src/renderer/src/store/slices/terminal-orphan-helpers.ts
4 src/renderer/src/store/slices/terminal-tab-title-batch.ts
6 src/renderer/src/store/slices/terminals.ts
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.web.json",
"exclude": [
"../src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts",
"../src/renderer/src/components/native-chat/NativeChatAcpQuestionCard.test.tsx",
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts",
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.web.json",
"include": [
"../src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts",
"../src/renderer/src/components/native-chat/NativeChatAcpQuestionCard.test.tsx",
"../src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-chat-coordinator-mail.test.ts",
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
+1
View File
@@ -11,6 +11,7 @@
"../src/shared/**/*"
],
"exclude": [
"../src/renderer/src/lib/agent-session/launch/new-tab/launch-agent-in-new-tab-main-surface.test.ts",
"../src/renderer/src/components/native-chat/NativeChatAcpQuestionCard.test.tsx",
"../src/renderer/src/components/task-page/linear/task-page-linear-detail-query.test.tsx",
"../src/renderer/src/components/terminal-pane/pty-agent-pane-launch.test.ts",
+1 -1
View File
@@ -120,7 +120,7 @@ ORCA worktree set --worktree active --pr null --gitlab-mr null --json
ORCA worktree rm --worktree id:<repoId>::<worktreePath> --force --json
```
`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; folder workspaces have no Git worktrees or source control. With `--environment`, pass an absolute path on that server.
`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; worktrees attached to a folder workspace appear in its Source Control panel, one section each. With `--environment`, pass an absolute path on that server.
Ports: `ORCA ports --json` lists the listening ports Orca tied to the current workspace (`--worktree <selector>` for another one, `--all` for every listener on that workspace's host). Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, and `kind` (`workspace`, `container`, or `external`). Only `workspace` rows have `owner` (with `owner.confidence`: `cwd` or `command`) and, when the dev server printed one in an Orca terminal, `advertisedUrl`. Use this to find a dev server's port instead of running `lsof` yourself. If `unavailableReason` is set, the scan could not run on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. SSH workspaces opened directly from this app report `unavailableReason` for now; a workspace on a managed Orca server is scanned on that server.
+1 -1
View File
@@ -348,7 +348,7 @@ const ORCA_CLI_MARKDOWN = [
"ORCA worktree rm --worktree id:<repoId>::<worktreePath> --force --json",
"```",
"",
"`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; folder workspaces have no Git worktrees or source control. With `--environment`, pass an absolute path on that server.",
"`repo add` defaults to Git. Use `--kind folder` for an ordinary directory or a parent of separate Git repositories; worktrees attached to a folder workspace appear in its Source Control panel, one section each. With `--environment`, pass an absolute path on that server.",
"",
"Ports: `ORCA ports --json` lists the listening ports Orca tied to the current workspace (`--worktree <selector>` for another one, `--all` for every listener on that workspace's host). Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, and `kind` (`workspace`, `container`, or `external`). Only `workspace` rows have `owner` (with `owner.confidence`: `cwd` or `command`) and, when the dev server printed one in an Orca terminal, `advertisedUrl`. Use this to find a dev server's port instead of running `lsof` yourself. If `unavailableReason` is set, the scan could not run on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. SSH workspaces opened directly from this app report `unavailableReason` for now; a workspace on a managed Orca server is scanned on that server.",
"",
+1 -1
View File
@@ -14,7 +14,7 @@ export const REPO_COMMAND_SPECS: CommandSpec[] = [
usage: 'orca repo add --path <path> [--kind git|folder] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'path', 'kind'],
notes: [
'Defaults to git. Use --kind folder for an ordinary directory, including a parent of separate Git repositories; folder workspaces have no Git worktrees or source control.',
'Defaults to git. Use --kind folder for an ordinary directory, including a parent of separate Git repositories; worktrees attached to a folder workspace appear in its Source Control panel, one section each.',
'With --environment, --path must be an absolute path on that Orca server.'
],
examples: ['orca repo add --path /abs/path/to/folder --kind folder --json']
@@ -12,6 +12,7 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { splitWorktreeId } from '../../../shared/worktree/id'
import {
shouldPreTrustAgentWorkspace,
resolveAgentWorkspaceTrustOwner,
type AgentWorkspaceTrustDecision
} from '../../../shared/agent-workspace-trust-decision'
@@ -44,8 +45,10 @@ export function applyAgentWorkspaceTrustToSpawn(
launchAgent: unknown
/** The user chose "let agents ask" for this launch; it wins even if saving that answer failed. */
skipForThisLaunch?: boolean
/** Worktree or folder workspace id; its root is the folder the agent is trusted in. */
/** Placement workspace id; its root may differ from the agent’s starting folder. */
worktreeId: string | undefined
/** Section root to pre-trust for this launch; never part of the terminal identity. */
trustWorktreeId?: string
/** The resolved folder the agent starts in; floating terminals have only this. */
cwd: string | undefined
store:
@@ -58,12 +61,13 @@ export function applyAgentWorkspaceTrustToSpawn(
spawnOptions: AgentTrustSpawnFields
} & AgentTrustLaunchContext
): Promise<void> | null {
const trustWorkspaceId = resolveAgentWorkspaceTrustOwner(args)
if (
!args.isFreshLaunch ||
args.skipForThisLaunch === true ||
!shouldPreTrustAgentWorkspace(
args.settings?.agentWorkspaceTrustEnabled,
agentWorkspaceTrustDecisionForWorkspace(args.worktreeId, args.store)
agentWorkspaceTrustDecisionForWorkspace(trustWorkspaceId, args.store)
)
) {
return null
@@ -75,7 +79,7 @@ export function applyAgentWorkspaceTrustToSpawn(
return null
}
const workspaceRoot = resolveTerminalWorkspacePath(
args.worktreeId,
trustWorkspaceId,
(folderWorkspaceId) => args.store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
)
const workspacePath = AGENT_TRUST_KEYED_BY_START_FOLDER[preset]
@@ -1,6 +1,15 @@
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as NodePath from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ClaudeFolderTrustFile from '../../claude/claude-folder-trust-file'
@@ -17,6 +26,16 @@ const mocks = vi.hoisted(() => ({
claudeGrant: vi.fn<typeof ClaudeFolderTrustFile.grantClaudeWorkspaceTrust>()
}))
// The host filesystem uses Windows path resolution for WSL UNC identities.
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof NodePath>()
return {
...actual,
resolve: (...paths: string[]) =>
paths[0]?.startsWith('\\\\wsl.') ? actual.win32.resolve(...paths) : actual.resolve(...paths)
}
})
vi.mock('./agent-trust-presets', () => ({
markCodexProjectTrusted: mocks.codex,
markCursorWorkspaceTrusted: mocks.cursor,
@@ -35,6 +54,7 @@ vi.mock('../../claude/claude-folder-trust-file', async (importOriginal) => {
})
import { applyAgentWorkspaceTrust, type AgentTrustLaunchContext } from './agent-workspace-trust'
import { applyAgentWorkspaceTrustToSpawn } from './agent-workspace-trust-spawn'
import { clearWslHomeCache, rememberWslHome } from '../../wsl/discovery/wsl-home-cache'
import {
AGENT_TRUST_WRITE_DEADLINE_MS,
@@ -124,6 +144,118 @@ describe('applyAgentWorkspaceTrust for Claude', () => {
rmSync(root, { recursive: true, force: true })
})
it('respects the section repo choice and preserves ordinary root trust for outside starts', async () => {
const workspaceRoot = join(root, 'folder')
const outsideCwd = join(root, 'attached-worktree')
mkdirSync(workspaceRoot)
mkdirSync(outsideCwd)
const config = join(root, '.claude.json')
writeFileSync(config, '{}')
const spawn = {
...local,
env: { CLAUDE_CONFIG_DIR: root },
launchAgent: 'claude',
worktreeId: 'folder:section-parent',
cwd: outsideCwd,
store: { getFolderWorkspace: () => ({ folderPath: workspaceRoot }) },
isFreshLaunch: true,
settings: {},
spawnOptions: {}
}
await applyAgentWorkspaceTrustToSpawn(spawn)
expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({
projects: { [workspaceRoot]: { hasTrustDialogAccepted: true } }
})
await applyAgentWorkspaceTrustToSpawn({
...spawn,
trustWorktreeId: `repo::${outsideCwd}`,
store: {
...spawn.store,
getRepo: () => ({ agentWorkspaceTrust: 'agent-asks' })
}
})
expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({
projects: { [workspaceRoot]: { hasTrustDialogAccepted: true } }
})
await applyAgentWorkspaceTrustToSpawn({
...spawn,
trustWorktreeId: `repo::${outsideCwd}`,
store: {
...spawn.store,
getRepo: () => ({ agentWorkspaceTrust: 'pre-trust' })
}
})
expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({
projects: {
[workspaceRoot]: { hasTrustDialogAccepted: true },
[outsideCwd]: { hasTrustDialogAccepted: true }
}
})
})
it('trusts a section root through a contained junction without granting the placement root', async () => {
const workspaceRoot = join(root, 'folder')
const outside = join(root, 'attached-worktree')
mkdirSync(workspaceRoot)
mkdirSync(outside)
const linked = join(workspaceRoot, 'linked-child')
symlinkSync(outside, linked, 'junction')
const config = join(root, '.claude.json')
writeFileSync(config, '{}')
await applyAgentWorkspaceTrustToSpawn({
...local,
env: { CLAUDE_CONFIG_DIR: root },
launchAgent: 'claude',
worktreeId: 'folder:section-parent',
trustWorktreeId: `repo::${linked}`,
cwd: linked,
store: { getFolderWorkspace: () => ({ folderPath: workspaceRoot }) },
isFreshLaunch: true,
settings: {},
spawnOptions: {}
})
expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({
projects: {
[linked]: { hasTrustDialogAccepted: true },
[outside]: { hasTrustDialogAccepted: true }
}
})
})
it('writes the mounted-drive section root in the prepared WSL Claude config', async () => {
const config = join(root, '.claude.json')
writeFileSync(config, '{}')
rememberWslHome('Ubuntu', '\\\\wsl.localhost\\Ubuntu\\home\\u')
try {
await applyAgentWorkspaceTrustToSpawn({
...local,
launchAgent: 'claude',
worktreeId: 'folder:section-parent',
trustWorktreeId: 'repo::\\\\wsl.localhost\\Ubuntu\\mnt\\c\\repos\\child',
cwd: '/mnt/c/repos/child',
store: {
getFolderWorkspace: () => ({ folderPath: '\\\\wsl.localhost\\Ubuntu\\home\\u\\folder' })
},
claudeAuth: {
runtime: 'wsl',
configDir: root,
wslLinuxConfigDir: '/home/u/.claude',
envPatch: { CLAUDE_CONFIG_DIR: '/home/u/.claude' },
provenance: 'test'
},
wslDistro: 'Ubuntu',
isFreshLaunch: true,
settings: {},
spawnOptions: {}
})
expect(JSON.parse(readFileSync(config, 'utf8'))).toEqual({
projects: { '/mnt/c/repos/child': { hasTrustDialogAccepted: true } }
})
} finally {
clearWslHomeCache()
}
})
it('grants in the config file the final spawn env names', async () => {
writeFileSync(join(root, '.claude.json'), '{}')
await applyAgentWorkspaceTrust('claude', root, {
@@ -6,6 +6,10 @@ import {
store,
WORKTREE_FEATURE_PATH,
discardChangesMock,
getStatusMock,
readFileMock,
statMock,
openMock,
resetFilesystemIpcMocks
} from './filesystem-test-harness'
@@ -110,6 +114,7 @@ vi.mock(
)
import { registerFilesystemHandlers } from './filesystem'
import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error'
import { invalidateAuthorizedRootsCache } from '../worktrees/removal/registered-worktree-roots-cache'
describe('registerFilesystemHandlers', () => {
@@ -131,4 +136,25 @@ describe('registerFilesystemHandlers', () => {
expect(discardChangesMock).not.toHaveBeenCalled()
})
it('rejects direct SSH status and reads before any local access', async () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the existing fixture supplies the store API exercised by these handlers.
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:status')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
connectionId: 'ssh-unserved'
})
).rejects.toThrow(SshHostNotServedError)
await expect(
handlers.get('fs:readFile')!(null, {
filePath: `${WORKTREE_FEATURE_PATH}/a.ts`,
connectionId: 'ssh-unserved'
})
).rejects.toThrow(SshHostNotServedError)
expect(getStatusMock).not.toHaveBeenCalled()
expect(readFileMock).not.toHaveBeenCalled()
expect(statMock).not.toHaveBeenCalled()
expect(openMock).not.toHaveBeenCalled()
})
})
+1
View File
@@ -38,6 +38,7 @@ export type PtySpawnIpcArgs = {
resumeOrigin?: unknown
launchToken?: unknown
launchAgent?: TuiAgent
trustWorktreeId?: string
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
@@ -16,7 +16,10 @@ export async function planQueuedPaneAgentLaunch(
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111'
},
pane: Pick<PtySpawnIpcArgs, 'connectionId' | 'commandDelivery' | 'startupCommandDelivery'> = {}
pane: Pick<
PtySpawnIpcArgs,
'connectionId' | 'commandDelivery' | 'startupCommandDelivery' | 'trustWorktreeId' | 'cwd'
> = {}
): Promise<PtySpawnIpcArgs> {
const runtime = new OrcaRuntimeService(partialRuntimeStore({ getSettings: () => settings }))
const discovery = vi.spyOn(runtime, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
+1
View File
@@ -118,6 +118,7 @@ export type RuntimePtySpawnArgs = {
cwd?: string
command?: string
launchAgent?: TuiAgent
trustWorktreeId?: string
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
commandDelivery?: 'renderer' | 'provider'
@@ -78,6 +78,7 @@ export type PtySpawnLaunchOptionsInput = {
commandDelivery?: PtySpawnOptions['commandDelivery']
startupCommandDelivery?: PtySpawnOptions['startupCommandDelivery']
worktreeId?: string
trustWorktreeId?: string
tabId?: unknown
connectionId?: string | null
shellOverride?: string
@@ -198,6 +199,7 @@ export async function assemblePtySpawnLaunchOptions(input: PtySpawnLaunchOptions
const trustWrite = applyAgentWorkspaceTrustToSpawn({
launchAgent: args.launchAgent,
worktreeId: args.worktreeId,
trustWorktreeId: args.trustWorktreeId,
skipForThisLaunch: args.skipAgentWorkspaceTrust === true,
cwd: input.cwd,
store: input.store,
@@ -88,6 +88,7 @@ export const SESSION_TAB_METHODS = [
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.trustWorktreeId ? { trustWorktreeId: params.trustWorktreeId } : {}),
...(params.viewMode ? { viewMode: params.viewMode } : {}),
activate: params.activate,
select: params.select,
@@ -74,6 +74,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.trustWorktreeId ? { trustWorktreeId: params.trustWorktreeId } : {}),
terminalImageViewerSupported: terminalImageViewerSupported(context)
}
)
@@ -101,6 +102,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [
...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.trustWorktreeId ? { trustWorktreeId: params.trustWorktreeId } : {}),
...(params.terminalKittyKeyboardProtocol === true
? { terminalKittyKeyboardProtocol: true }
: {}),
@@ -75,6 +75,7 @@ export type RuntimePtyController = {
cwd?: string
command?: string
launchAgent?: TuiAgent
trustWorktreeId?: string
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
commandDelivery?: 'renderer' | 'provider'
@@ -0,0 +1,52 @@
import './orca-runtime-tests/runtime-test-module-registration.spec'
import { describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { OrcaRuntimeService } from './orca-runtime'
import {
TEST_FOLDER_WORKSPACE_KEY,
createFolderWorkspaceRuntimeStore,
makeFolderWorkspace
} from './orca-runtime-tests/orca-runtime-test-fixtures.spec'
describe('section trust ownership', () => {
it.each([false, true])(
'carries transient section trust ownership through host creation (override %s)',
async (section) => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-section-trust' })
const folderPath = await mkdtemp(join(tmpdir(), 'orca-pr38831-trust-root-'))
const childPath = await mkdtemp(join(tmpdir(), 'orca-pr38831-trust-child-'))
const trustWorktreeId = `repo::${childPath}`
const folder = makeFolderWorkspace({ folderPath })
const runtime = new OrcaRuntimeService(createFolderWorkspaceRuntimeStore(folder))
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
try {
await runtime.createAgentSession({
clientOperationId: `${Date.now()}-${'ab'.repeat(16)}`,
worktree: `id:${TEST_FOLDER_WORKSPACE_KEY}`,
agent: 'claude',
startupCwd: childPath,
...(section ? { trustWorktreeId } : {})
})
expect(spawn.mock.calls[0]?.[0]).toMatchObject({
worktreeId: TEST_FOLDER_WORKSPACE_KEY,
cwd: childPath
})
if (section) {
expect(spawn.mock.calls[0]?.[0]?.trustWorktreeId).toBe(trustWorktreeId)
} else {
expect(spawn.mock.calls[0]?.[0]).not.toHaveProperty('trustWorktreeId')
}
} finally {
await rm(folderPath, { recursive: true, force: true })
await rm(childPath, { recursive: true, force: true })
}
}
)
})
@@ -78,6 +78,7 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG
: Object.entries(request.sessionOptions).sort(),
request.extraAgentArgs ?? null,
request.startupCwd ?? null,
...(request.trustWorktreeId ? [request.trustWorktreeId] : []),
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
@@ -148,6 +149,7 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG
: Object.entries(request.sessionOptions).sort(),
request.extraAgentArgs ?? null,
startupCwd ?? null,
...(request.trustWorktreeId ? [request.trustWorktreeId] : []),
request.presentation ?? null,
request.placement?.tabId ?? null,
request.placement?.leafId ?? null,
@@ -203,6 +205,7 @@ export abstract class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithG
terminalImageViewerSupported: caller.terminalImageViewerSupported,
terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol,
cwd: startupCwd,
...(request.trustWorktreeId ? { trustWorktreeId: request.trustWorktreeId } : {}),
presentation: request.presentation ?? 'background',
tabId: operationTabId,
leafId: operationLeafId,
@@ -36,6 +36,7 @@ export type MobileTerminalCreateOptions = {
agentPrompt?: string
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
trustWorktreeId?: string
viewMode?: 'terminal' | 'chat'
activate?: boolean
clientNavigationId?: string
@@ -56,6 +57,7 @@ export type OwnedMobileTerminalCreateOptions = {
identity?: { tabId: string; leafId: string; sessionId?: string }
createMutation?: { clientIdentity: string; id: string }
launchAgent?: TuiAgent
trustWorktreeId?: string
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
afterDesktopTabId?: string
@@ -109,6 +111,7 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchAgent?: TuiAgent
trustWorktreeId?: string
terminalImageViewerSupported?: boolean
viewMode?: 'terminal' | 'chat'
activate?: boolean
@@ -119,6 +119,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
...(opts.wslUserAct ? { wslUserAct: true } : {}),
...(opts.resumeOrigin ? { resumeOrigin: opts.resumeOrigin } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}),
...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}),
@@ -111,6 +111,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
// The paired client's own create: its user's act.
wslUserAct: true,
...startupCommand,
...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}),
cwd,
identity: {
tabId: deterministicAgentSessionUuid(`mobile-qoder-tab\0${seed}`),
@@ -139,6 +140,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
envToDelete: startupCommand.envToDelete,
startupCommandDelivery: startupCommand.startupCommandDelivery,
launchAgent: startupCommand.launchAgent,
...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}),
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
...mainPlacedAfter(
@@ -204,6 +206,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
source: 'runtime-session',
@@ -267,6 +270,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca
startupCommandDelivery: startupCommand.startupCommandDelivery,
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
launchAgent: startupCommand.launchAgent,
...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}),
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement,
@@ -65,6 +65,7 @@ export async function createDesktopTerminal(
...(opts.resumeProviderSession ? { resumeProviderSession: opts.resumeProviderSession } : {}),
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.trustWorktreeId ? { trustWorktreeId: opts.trustWorktreeId } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: opts.startupCommandDelivery,
title: opts.title,
@@ -31,6 +31,7 @@ export type TerminalCreateOptions = {
resumeOrigin?: 'saved'
launchToken?: string
launchAgent?: TuiAgent
trustWorktreeId?: string
startupAgent?: TuiAgent
/** Offer initial text only to compatible startupAgent launch commands; otherwise refuse instead of silently dropping it.
* Typed-line carry gates delivery; onStartupPromptCarry reports clean launches whose later delivery belongs to the live PTY owner. */
@@ -141,6 +141,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi
? launchOpts.command
: (agentTeamsPlan?.command ?? launchOpts.command),
launchAgent: launchOpts.launchAgent,
...(launchOpts.trustWorktreeId ? { trustWorktreeId: launchOpts.trustWorktreeId } : {}),
launchConfig: effectiveLaunchConfig,
...(launchToken ? { launchToken } : {}),
commandDelivery: 'provider',
+1
View File
@@ -55,6 +55,7 @@ export type PtyApi = {
resumeOrigin?: 'saved'
launchToken?: string
launchAgent?: TuiAgent
trustWorktreeId?: string
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
@@ -50,6 +50,7 @@ export const ptySessionControlApi = {
resumeOrigin?: 'saved'
launchToken?: string
launchAgent?: TuiAgent
trustWorktreeId?: string
startupCommandDelivery?: StartupCommandDelivery
connectionId?: string | null
worktreeId?: string
@@ -62,7 +62,7 @@ describe('the window shows the same session before and after the shared Loader r
expect(view[`${GIT} active terminal`]).toBe('tab-a')
})
it('restores the terminals of a session from before the tab bar, dropping its file tabs', async () => {
it('restores terminals and saved edit tabs from a session before the tab bar', async () => {
const profile = PROFILES.find(
(entry) => entry.name === 'session from before the tab bar, with an open file'
)!
@@ -71,7 +71,7 @@ describe('the window shows the same session before and after the shared Loader r
{ hostId: profile.hostId, session: profile.build() }
])
)
expect(view[`${GIT} group 0 tabs`]).toEqual(['tab-a', 'tab-b'])
expect(view).not.toHaveProperty([`${GIT} tab /Users/dev/orca/src/app.ts`])
expect(view[`${GIT} group 0 tabs`]).toEqual(['/Users/dev/orca/src/app.ts', 'tab-a', 'tab-b'])
expect(view).toHaveProperty([`${GIT} tab /Users/dev/orca/src/app.ts`])
})
})
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import {
installIpcPtyWindow,
restorePtySpecWindow
} from '../terminal-pane/pty-transport-test-harness'
type StoreState = {
repos: Partial<Repo>[]
@@ -51,6 +55,71 @@ afterEach(async () => {
})
describe('first agent launch in a repo', () => {
it.each([true, false])(
'asks once for concurrent section launches through PTY connect (save succeeds: %s)',
async (saveSucceeds) => {
const originalWindow: typeof window | undefined = globalThis.window
installIpcPtyWindow(originalWindow, {})
if (!saveSucceeds) {
state.updateRepo = vi.fn(async () => false)
}
vi.mocked(window.api.pty.spawn)
.mockResolvedValueOnce({ id: 'section-1' })
.mockResolvedValueOnce({ id: 'section-2' })
const { createIpcPtyTransport } =
await import('../terminal-pane/pty-transport/ipc/pty-transport')
const transports = ['folder:placement', 'folder:other'].map((worktreeId) =>
createIpcPtyTransport({
worktreeId,
trustWorktreeId: WORKTREE,
cwd: '/repos/app/feature',
command: 'claude',
launchAgent: 'claude'
})
)
const connecting = Promise.all(
transports.map((transport) => transport.connect({ url: '', callbacks: {} }))
)
try {
expect(getAgentWorkspaceTrustDecisionRequest()).toMatchObject({
repoId: 'repo-1',
repoName: 'app',
hostId: 'local'
})
expect(window.api.pty.spawn).not.toHaveBeenCalled()
await answerAgentWorkspaceTrustDecision('agent-asks')
await connecting
expect(state.updateRepo).toHaveBeenCalledExactlyOnceWith(
'repo-1',
{ agentWorkspaceTrust: 'agent-asks' },
{ hostId: 'local' }
)
expect(getAgentWorkspaceTrustDecisionRequest()).toBeNull()
expect(vi.mocked(window.api.pty.spawn).mock.calls.map(([args]) => args)).toMatchObject([
{
worktreeId: 'folder:placement',
trustWorktreeId: WORKTREE,
cwd: '/repos/app/feature',
skipAgentWorkspaceTrust: true
},
{
worktreeId: 'folder:other',
trustWorktreeId: WORKTREE,
cwd: '/repos/app/feature',
skipAgentWorkspaceTrust: true
}
])
} finally {
await answerAgentWorkspaceTrustDecision('agent-asks')
await connecting
for (const transport of transports) {
transport.destroy?.()
}
restorePtySpecWindow(originalWindow)
}
}
)
it('holds the launch until answered, asking once for all its worktrees', async () => {
const first = launch()
const second = launch('repo-1::/repos/app/other')
@@ -1,4 +1,6 @@
import React from 'react'
import { useAppStateValue, useAppStateViewActive } from '@/store/app-state-view'
import React, { useCallback } from 'react'
import { useAppStore } from '@/store'
import { getOverlayFocusOwners, overlayFocusContains } from '@/lib/overlay-focus-ownership'
import {
Dialog,
@@ -8,7 +10,6 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { AgentsPane } from '@/components/settings/agents/AgentsPane'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
isWindowsTerminalCapabilityHost,
@@ -29,10 +30,17 @@ export default function AgentSettingsDialog({
onOpenChange,
onCloseAutoFocus
}: AgentSettingsDialogProps): React.JSX.Element | null {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated)
const viewActive = useAppStateViewActive()
const settings = useAppStateValue((s) => s.settings)
const updateSettings = useCallback(
(...args: Parameters<ReturnType<typeof useAppStore.getState>['updateSettings']>) =>
useAppStore.getState().updateSettings(...args),
[]
)
const runtimeEnvironments = useAppStateValue((s) => s.runtimeEnvironments)
const runtimeEnvironmentCatalogHydrated = useAppStateValue(
(s) => s.runtimeEnvironmentCatalogHydrated
)
const isWindowsRenderer =
typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows')
const isWebClient = isWebClientLocation()
@@ -41,7 +49,7 @@ export default function AgentSettingsDialog({
)
const localCapabilityOwnerKey = isWebClient ? runtimeCapabilityOwnerKey : 'local'
const localWindowsTerminalCapabilities = useLocalWindowsTerminalCapabilities(
open && (isWindowsRenderer || isWebClient),
viewActive && open && (isWindowsRenderer || isWebClient),
false,
localCapabilityOwnerKey
)
@@ -1,4 +1,4 @@
import { useSyncExternalStore } from 'react'
import { useAppViewExternalStore } from '@/store/app-state-view'
import { shallow } from 'zustand/shallow'
import type { AgentPickerEntry } from '../../../../shared/agent-launch/picker/agent-launch-picker'
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
@@ -292,5 +292,5 @@ export function createAgentPickerInventorySource(
export function useAgentPickerInventoryEntries(
source: AgentPickerInventorySource
): AgentPickerEntry[] {
return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot)
return useAppViewExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot)
}
@@ -1,3 +1,4 @@
import { useAppStateView, useAppStateViewActive } from '@/store/app-state-view'
import { hostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import {
@@ -8,7 +9,6 @@ import {
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { readHostAgentLaunchDefaults } from '../../runtime/agent-launch-settings/agent-launch-settings-transport'
import type { NewTabAgentMenuModel } from '../tab-bar/use-new-tab-agent-menu-rows'
import { useAppStore } from '@/store'
import { buildAgentLaunchRouteInput } from '@/lib/agent-session/launch/routing/agent-launch-route-input'
import { workspaceKindForWorktreeId } from '../../../../shared/workspace-launch-kind'
import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner'
@@ -56,15 +56,17 @@ export function useAgentLaunchPickerInventorySource(
open: boolean,
selected?: AgentPickerEntry['agent']
): AgentPickerInventorySource {
const store = useAppStateView()
const viewActive = useAppStateViewActive()
const selectedRef = useRef(selected)
useLayoutEffect(() => {
selectedRef.current = selected
}, [selected])
const context = model.state === 'settled' ? model.pickerContext : undefined
const settings = context ? context.settings : useAppStore.getState().settings
const settings = context ? context.settings : store.getState().settings
const executionHostId =
context?.executionHostId ??
buildAgentLaunchRouteInput(useAppStore.getState(), {
buildAgentLaunchRouteInput(store.getState(), {
agent: 'claude',
workspace: context?.workspace ?? {
kind: worktreeId ? workspaceKindForWorktreeId(worktreeId) : 'floating',
@@ -77,11 +79,11 @@ export function useAgentLaunchPickerInventorySource(
target?.kind === 'local' ? null : context?.runtimeId
)
const repo = context?.workspace.repoId
? useAppStore.getState().repos.find((candidate) => candidate.id === context.workspace.repoId)
? store.getState().repos.find((candidate) => candidate.id === context.workspace.repoId)
: undefined
const workspaceId = worktreeId ?? context?.workspace.worktreeId
const selector = workspaceId ? `id:${workspaceId}` : repo ? `path:${repo.path}` : undefined
const state = useAppStore.getState()
const state = store.getState()
const hostId = parseExecutionHostId(executionHostId)?.id
const worktrees = selectWorktreesByRepo(state)
const folders = selectFolderWorkspaces(state)
@@ -194,7 +196,7 @@ export function useAgentLaunchPickerInventorySource(
[key, open, fallback]
)
useEffect(() => {
if (!open || !target || model.state !== 'settled') {
if (!viewActive || !open || !target || model.state !== 'settled') {
return
}
const controller = new AbortController()
@@ -264,6 +266,6 @@ export function useAgentLaunchPickerInventorySource(
}
)
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only semantic inputs and visibility own observation; selected priority publishes independently after commit.
}, [key, open, source])
}, [key, open, source, viewActive])
return source
}
@@ -1,8 +1,8 @@
import { useAppStateValue } from '@/store/app-state-view'
import { getAgentRowConversationName } from '../../../../shared/agent-status/presentation/agent-row-conversation-name'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name'
import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title'
import { useAppStore } from '@/store'
import { structuredChatTabById } from '@/lib/structured-chat-tab-index'
import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name'
import type { AppState } from '@/store/types'
@@ -34,15 +34,15 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string |
parentPaneKey !== undefined &&
parsePaneKey(parentPaneKey)?.tabId === agent.tab.id
const cannotOwnTabName = agent.rowSource === 'subagent' || usesParentTab
const generatedTitlesEnabled = useAppStore(
const generatedTitlesEnabled = useAppStateValue(
(s) => !cannotOwnTabName && s.settings?.tabAutoGenerateTitle === true
)
const liveTab = useAppStore((s) =>
const liveTab = useAppStateValue((s) =>
cannotOwnTabName
? undefined
: getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id)
)
const structuredTab = useAppStore((s) =>
const structuredTab = useAppStateValue((s) =>
cannotOwnTabName
? undefined
: structuredChatTabById(s.unifiedTabsByWorktree, agent.tab.worktreeId, agent.tab.id)
@@ -51,7 +51,7 @@ export function useAgentRowConversationName(agent: DashboardAgentRow): string |
// Parse once per render; selectors run on every store write and must stay allocation-free.
const ownLeafId = cannotOwnTabName ? null : parsePaneKey(agent.paneKey)?.leafId
// Select this split pane’s primitive title so sibling focus/title changes cannot rename or re-render its row.
const paneLiveTitle = useAppStore((s) =>
const paneLiveTitle = useAppStateValue((s) =>
cannotOwnTabName
? undefined
: resolveAgentRowPaneLiveTitle(
@@ -1,3 +1,4 @@
import { useAppStateValue } from '@/store/app-state-view'
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { useAppStore } from '@/store'
@@ -23,7 +24,7 @@ export function useDiffCommentView(
const select = useMemo(() => createDiffCommentViewSelector(), [])
const [selection, runtimeEnvironmentId, externalSshTargetId, operationProvenance] =
sourceParts(source)
return useAppStore((state) =>
return useAppStateValue((state) =>
select(
state,
worktreeId,
@@ -38,7 +39,7 @@ export function useDiffCommentView(
/** The notes of a captured copy (an open confirmation), through the same cached read. */
export function useOwnedDiffComments(owner: DiffCommentOwner | null): DiffComment[] | undefined {
const select = useMemo(() => createOwnedDiffCommentsSelector(), [])
return useAppStore((state) => select(state, owner))
return useAppStateValue((state) => select(state, owner))
}
/** A composer target whose owner is captured when it opens, so a later submit saves to that copy. */
@@ -1,8 +1,8 @@
import { useAppStateValue } from '@/store/app-state-view'
import { useDiffCommentView } from './use-diff-comment-owner'
import type { DiffCommentOwner } from '@/store/diff-comment-owner'
import type { WorkspaceCopySource } from '@/lib/workspace-copy-owner'
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import {
isMarkdownReviewNotesEnabled,
withoutMarkdownReviewNotes
@@ -24,7 +24,9 @@ export function useVisibleWorktreeDiffComments(
const { owner, comments: listed } = useDiffCommentView(worktreeId, source)
// Unknown copies still need a stable empty list; inline arrays would churn memoized consumers.
const allComments = listed ?? NO_COMMENTS
const markdownReviewNotesEnabled = useAppStore((s) => isMarkdownReviewNotesEnabled(s.settings))
const markdownReviewNotesEnabled = useAppStateValue((s) =>
isMarkdownReviewNotesEnabled(s.settings)
)
const comments = useMemo(
() => (markdownReviewNotesEnabled ? allComments : withoutMarkdownReviewNotes(allComments)),
[allComments, markdownReviewNotesEnabled]
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { TabsDrawnFromMain } from '@/store/slices/tabs-drawn-from-main.test-fixture'
import {
EDITOR_OWNER,
EDITOR_PLACEMENT,
seedEditorPlacementConsumers,
drawEditorPlacementConsumers
} from '@/store/slices/editor-placement-consumers.test-fixture'
import type { EditorToggleValue } from './view-mode/EditorViewToggle'
vi.mock('@/runtime/runtime-rpc-client', async (original) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await original())
)
vi.mock('@/runtime/runtime-layout-client', async (original) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await original())
)
vi.mock('./useEditorPanelContentState', () => ({
useEditorPanelContentState: () => ({
fileContents: {},
diffContents: {},
reloadContent: () => {}
})
}))
vi.mock('./EditorPanelShell', () => ({
EditorPanelShell: ({
onOpenDiffTargetFile,
onEditorToggleChange,
onOpenMarkdownPreview,
onOpenPreviewToSide
}: {
onOpenDiffTargetFile: () => void
onEditorToggleChange: (mode: EditorToggleValue) => void
onOpenMarkdownPreview: () => void
onOpenPreviewToSide: () => void
}) => (
<>
<button onClick={onOpenDiffTargetFile}>Open</button>
<button onClick={() => onEditorToggleChange('rich')}>Rich</button>
<button onClick={onOpenMarkdownPreview}>Markdown preview</button>
<button onClick={onOpenPreviewToSide}>Side preview</button>
</>
)
}))
import EditorPanel from './EditorPanel'
let main: TabsDrawnFromMain | undefined
afterEach(() => {
cleanup()
main?.close()
main = undefined
})
it.each(['Open', 'Rich', 'Markdown preview'])(
'%s from a child diff opens the result in the invoking folder group',
async (action) => {
const file = seedEditorPlacementConsumers()
main = await drawEditorPlacementConsumers()
const screen = render(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
fireEvent.click(screen.getByText(action))
await main.settle()
const state = useAppStore.getState()
const result = state.openFiles.find(
(entry) => entry.mode === (action === 'Markdown preview' ? 'markdown-preview' : 'edit')
)
expect(result).toMatchObject({
worktreeId: EDITOR_OWNER,
filePath: '/repo/notes.md',
runtimeEnvironmentId: null
})
expect(
state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === result?.id)
).toMatchObject({ groupId: 'x-source' })
expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab'])
if (action === 'Rich') {
expect(state.markdownViewMode[result!.id]).toBe('rich')
}
}
)
it('opens an HTML side preview beside its invoking folder group', async () => {
const file = seedEditorPlacementConsumers({
filePath: '/repo/index.html',
relativePath: 'index.html',
language: 'html'
})
main = await drawEditorPlacementConsumers()
const screen = render(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
fireEvent.click(screen.getByText('Side preview'))
await main.settle()
const state = useAppStore.getState()
const browser = state.browserTabsByWorktree[EDITOR_PLACEMENT]?.[0]
expect(browser?.url).toBe('file:///repo/index.html')
expect(
state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === browser?.id)
).toMatchObject({ groupId: 'x-other' })
expect(state.browserTabsByWorktree[EDITOR_OWNER] ?? []).toEqual([])
})
it('places an SSH document preview in the folder while its document location retains the child', async () => {
const file = seedEditorPlacementConsumers({
filePath: '/repo/index.html',
relativePath: 'index.html',
language: 'html'
})
useAppStore.setState((state) => ({
repos: state.repos.map((repo) => ({
...repo,
connectionId: 'child-ssh',
executionHostId: 'ssh:child-ssh'
})),
worktreesByRepo: {
repo1: state.worktreesByRepo.repo1!.map((worktree) => ({
...worktree,
hostId: 'ssh:child-ssh'
}))
},
sshConnectionStates: new Map([
[
'child-ssh',
{
targetId: 'child-ssh',
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration: 1
}
]
])
}))
main = await drawEditorPlacementConsumers()
const screen = render(<EditorPanel activeFileId={file.id} activeViewStateId="x-source-tab" />)
fireEvent.click(screen.getByText('Side preview'))
await main.settle()
const state = useAppStore.getState()
const preview = state.browserTabsByWorktree[EDITOR_PLACEMENT]?.[0]
expect(preview?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: EDITOR_OWNER,
filePath: '/repo/index.html'
})
expect(
state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === preview?.id)
?.groupId
).toBe('x-other')
expect(state.browserTabsByWorktree[EDITOR_OWNER] ?? []).toEqual([])
})
@@ -3,8 +3,7 @@ import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { formatClipboardFilePath } from '@/lib/clipboard-file-path'
import { getOpenFileRevealOwner } from '@/lib/reveal-in-file-manager'
import { detectLanguage } from '@/lib/language-detect'
import { canShowWorkspaceFileBrowserAction, openFilePreviewToSide } from '@/lib/file-preview'
import { canShowWorkspaceFileBrowserAction } from '@/lib/file-preview'
import { getEditorHeaderCopyState } from './editor-header'
import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
import { exportActiveMarkdownToPdf } from './export-active-markdown'
@@ -33,6 +32,7 @@ import { useEditorPanelSave } from './save/commands/useEditorPanelSave'
import { isMarkdownReviewNotesEnabled } from '@/lib/markdown-review-notes-setting'
import { useFileCustomEditors } from '@/store/plugin-custom-editors'
import { applyCustomEditorToggle } from './view-mode/custom-editor-toggle'
import { editorPanelFileActions } from './editor-panel-file-actions'
import { runAfterEditorFramesFlush } from './save/flush/editor-pending-flush'
function EditorPanelInner({
@@ -76,7 +76,6 @@ function EditorPanelInner({
)
const editorViewMode = useAppStore((s) => s.editorViewMode)
const setEditorViewMode = useAppStore((s) => s.setEditorViewMode)
const openFile = useAppStore((s) => s.openFile)
const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview)
const markdownFrontmatterVisible = useAppStore((s) => s.markdownFrontmatterVisible)
const setMarkdownFrontmatterVisible = useAppStore((s) => s.setMarkdownFrontmatterVisible)
@@ -161,7 +160,7 @@ function EditorPanelInner({
)?.kind !== 'local'
)
useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview })
useMarkdownPreviewShortcut({ activeFile, activeViewStateId, panelRef, openMarkdownPreview })
const handleContentChangeForFile = useEditorContentChangeHandler({ fileContents, diffContents })
@@ -246,37 +245,8 @@ function EditorPanelInner({
...customEditors
})
const handleOpenPreviewToSide = (): void => {
const state = useAppStore.getState()
const sourceGroupId = activeViewStateId
? ((state.unifiedTabsByWorktree[activeFile.worktreeId] ?? []).find(
(t) => t.id === activeViewStateId
)?.groupId ?? null)
: null
openFilePreviewToSide({
language: model.resolvedLanguage,
filePath: activeFile.filePath,
worktreeId: activeFile.worktreeId,
sourceGroupId
})
}
const handleOpenDiffTargetFile = (preferredMarkdownViewMode?: 'rich'): void => {
if (!model.openFileState.canOpen) {
return
}
const openedFileId = openFile({
filePath: activeFile.filePath,
relativePath: activeFile.relativePath,
worktreeId: activeFile.worktreeId,
runtimeEnvironmentId: activeFile.runtimeEnvironmentId,
language: detectLanguage(activeFile.relativePath),
mode: 'edit'
})
if (preferredMarkdownViewMode) {
setEditorViewMode(openedFileId, 'edit')
setMarkdownViewMode(openedFileId, preferredMarkdownViewMode)
}
}
const { handleOpenPreviewToSide, handleOpenDiffTargetFile, handleOpenMarkdownPreview } =
editorPanelFileActions(activeFile, activeViewStateId, model)
const handleEditorToggleChange = (next: EditorToggleValue): void => {
// Why: leaving a custom editor unmounts its frame, which may still hold debounced edits.
runAfterEditorFramesFlush(useAppStore.getState(), [activeFile.id], () =>
@@ -299,18 +269,6 @@ function EditorPanelInner({
setMarkdownViewMode(fileId, markdownMode)
}
}
const handleOpenMarkdownPreview = (): void => {
openMarkdownPreview(
{
filePath: activeFile.filePath,
relativePath: activeFile.relativePath,
worktreeId: activeFile.worktreeId,
runtimeEnvironmentId: activeFile.runtimeEnvironmentId,
language: model.resolvedLanguage
},
{ sourceFileId: activeFile.id }
)
}
const disableRenameBrowse = Boolean(
renameDialogFileOffClient ||
(renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null)
@@ -46,7 +46,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
if (!(event instanceof CustomEvent)) {
return
}
const { fileId, onNotClosed }: EditorSaveAndCloseDetail = event.detail
const { fileId, tabId, onNotClosed }: EditorSaveAndCloseDetail = event.detail
const file = store.getState().openFiles.find((openFile) => openFile.id === fileId)
if (!file) {
return
@@ -68,7 +68,11 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
return
}
}
store.getState().closeFile(fileId)
if (tabId) {
store.getState().closeUnifiedTab(tabId)
} else {
store.getState().closeFile(fileId)
}
}
const handleSaveFile = async (event: Event): Promise<void> => {
@@ -64,6 +64,7 @@ export type EditorSaveFileTarget = {
export type EditorSaveAndCloseDetail = {
fileId: string
tabId?: string
/** Called at once when the tab stays open, so a waiting dialog needn't time out. */
onNotClosed?: (reason: 'blocked-stale' | 'not-utf8' | 'not-saved') => void
}
@@ -83,6 +84,7 @@ export type EditorFileSavedDetail = {
export type EditorRequestFileCloseDetail = {
fileId: string
tabId?: string
/** Runs once the file actually closes — after save or discard, never on cancel. */
onClosed?: () => void
}
@@ -227,7 +229,7 @@ export async function requestEditorFileSave(target: EditorSaveFileTarget): Promi
export function requestEditorFileClose(
fileId: string,
options?: Pick<EditorRequestFileCloseDetail, 'onClosed'>
options?: Pick<EditorRequestFileCloseDetail, 'onClosed' | 'tabId'>
): void {
window.dispatchEvent(
new CustomEvent<EditorRequestFileCloseDetail>(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, {
@@ -1,3 +1,4 @@
import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement'
import React, { useCallback, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
@@ -63,9 +64,7 @@ export default function CombinedDiffViewer({
(s) => s.gitBranchChangesByWorktree[file.worktreeId] ?? EMPTY_GIT_BRANCH_ENTRIES
)
const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId])
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const updateSettings = useAppStore((s) => s.updateSettings)
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
// The notes of the copy this diff was read from (the file's server when it names one).
@@ -211,21 +210,28 @@ export default function CombinedDiffViewer({
shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus,
treeMode: entrySet.treeMode
})
const { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } =
useCombinedDiffSectionActions({
activeGroupId,
branchCompare: entrySet.branchCompare,
canOpenWorkspaceFileBrowserForPath,
commitCompare: entrySet.commitCompare,
file,
isAllMode: entrySet.isAllMode,
isBranchMode: entrySet.isBranchMode,
isCommitMode: entrySet.isCommitMode,
sections,
sectionsRef: registry.sectionsRef,
setSectionHeights,
setSections
})
const {
handleSectionSaveRef,
modifiedEditorsRef,
openSection,
openSectionPreview,
openAlternateDiff
} = useCombinedDiffSectionActions({
activeGroupId,
viewStateScopeId,
branchSummary,
branchCompare: entrySet.branchCompare,
canOpenWorkspaceFileBrowserForPath,
commitCompare: entrySet.commitCompare,
file,
isAllMode: entrySet.isAllMode,
isBranchMode: entrySet.isBranchMode,
isCommitMode: entrySet.isCommitMode,
sections,
sectionsRef: registry.sectionsRef,
setSectionHeights,
setSections
})
useCombinedDiffViewPersist({
combinedGitStatusSignature,
@@ -257,23 +263,6 @@ export default function CombinedDiffViewer({
viewStateKey
})
const openAlternateDiff = useCallback(() => {
if (!file.combinedAlternate) {
return
}
if (file.combinedAlternate.source === 'combined-all') {
openAllDiffs(file.worktreeId, file.filePath)
return
}
if (branchSummary && branchSummary.status === 'ready') {
openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, {
source: 'combined-all'
})
}
}, [branchSummary, file, openAllDiffs, openBranchAllDiffs])
const { setScrollSurfaceMounted } = notes
const setScrollContainerRef = useCallback(
(node: HTMLDivElement | null) => {
@@ -297,9 +286,10 @@ export default function CombinedDiffViewer({
path: entry.path,
conflictKind: entry.conflictKind
})),
'combined-diff-exclusion'
'combined-diff-exclusion',
editorViewPlacement(useAppStore.getState(), file, viewStateScopeId)
)
}, [file.filePath, file.worktreeId, openConflictReview, skippedConflicts])
}, [file, viewStateScopeId, openConflictReview, skippedConflicts])
const commitHeader =
entrySet.isCommitMode && entrySet.commitCompare ? (
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { TabsDrawnFromMain } from '@/store/slices/tabs-drawn-from-main.test-fixture'
import {
EDITOR_OWNER,
EDITOR_PLACEMENT,
seedEditorPlacementConsumers,
drawEditorPlacementConsumers
} from '@/store/slices/editor-placement-consumers.test-fixture'
import type { DiffSection } from '../../diff/sections/diff-section-types'
import { useCombinedDiffSectionActions } from './use-combined-diff-section-actions'
vi.mock('@/runtime/runtime-rpc-client', async (original) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await original())
)
vi.mock('@/runtime/runtime-layout-client', async (original) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await original())
)
let main: TabsDrawnFromMain | undefined
afterEach(() => {
cleanup()
main?.close()
main = undefined
})
const branch = {
baseRef: 'main',
compareRef: 'HEAD',
baseOid: 'base',
headOid: 'head',
mergeBase: 'base',
compareVersion: 'branch-version'
}
const commit = {
commitOid: 'commit',
parentOid: 'parent',
compareRef: 'commit',
baseRef: 'parent',
compareVersion: 'commit-version'
}
const section: DiffSection = {
key: 'notes.md',
path: 'notes.md',
status: 'modified',
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: false,
dirty: false,
diffResult: null,
largeDiffRenderLimit: null
}
it.each(['edit', 'branch', 'commit'] as const)(
'opens a combined %s section in its invoking group, preserving the child owner',
async (kind) => {
const file = seedEditorPlacementConsumers({
filePath: '/repo',
relativePath: 'All Changes',
diffSource: 'combined-all'
})
main = await drawEditorPlacementConsumers()
const hook = renderHook(() =>
useCombinedDiffSectionActions({
activeGroupId: 'y-group',
viewStateScopeId: 'x-source-tab',
branchCompare: branch,
commitCompare: commit,
canOpenWorkspaceFileBrowserForPath: () => false,
file,
isAllMode: false,
isBranchMode: kind === 'branch',
isCommitMode: kind === 'commit',
sections: [section],
sectionsRef: { current: [section] },
setSectionHeights: vi.fn(),
setSections: vi.fn()
})
)
act(() => hook.result.current.openSection(0))
await main.settle()
const state = useAppStore.getState()
const opened = state.openFiles.find((entry) => entry.id !== file.id)
expect(opened).toMatchObject({
worktreeId: EDITOR_OWNER,
filePath: '/repo/notes.md',
runtimeEnvironmentId: null,
mode: kind === 'edit' ? 'edit' : 'diff'
})
expect(
state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === opened?.id)
?.groupId
).toBe('x-source')
expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab'])
}
)
it.each(['combined-all', 'combined-branch'] as const)(
'switches the alternate %s diff in the invoking folder group',
async (alternate) => {
const file = seedEditorPlacementConsumers({
filePath: '/repo',
relativePath: 'All Branch Changes',
diffSource: 'combined-branch',
combinedAlternate: { source: alternate }
})
main = await drawEditorPlacementConsumers()
const hook = renderHook(() =>
useCombinedDiffSectionActions({
activeGroupId: 'y-group',
viewStateScopeId: 'x-source-tab',
branchSummary: { ...branch, changedFiles: 1, status: 'ready' },
branchCompare: branch,
commitCompare: null,
canOpenWorkspaceFileBrowserForPath: () => false,
file,
isAllMode: false,
isBranchMode: true,
isCommitMode: false,
sections: [],
sectionsRef: { current: [] },
setSectionHeights: vi.fn(),
setSections: vi.fn()
})
)
act(() => hook.result.current.openAlternateDiff())
await main.settle()
const state = useAppStore.getState()
const opened = state.openFiles.find(
(entry) =>
entry.id !== file.id &&
entry.diffSource ===
(alternate === 'combined-all' ? 'combined-uncommitted' : 'combined-branch')
)
expect(opened?.worktreeId).toBe(EDITOR_OWNER)
expect(
state.unifiedTabsByWorktree[EDITOR_PLACEMENT]?.find((tab) => tab.entityId === opened?.id)
?.groupId
).toBe('x-source')
expect(state.unifiedTabsByWorktree[EDITOR_OWNER]?.map((tab) => tab.id)).toEqual(['y-tab'])
}
)
@@ -1,36 +1,30 @@
import { useCallback, useRef } from 'react'
import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement'
import { useCallback } from 'react'
import { useCombinedDiffSectionSave } from './use-combined-diff-section-save'
import type React from 'react'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { joinPath } from '@/lib/path'
import { openFilePreviewToSide } from '@/lib/file-preview'
import type { AppState } from '@/store/types'
import type { OpenFile } from '@/store/slices/editor'
import type { GitBranchChangeEntry } from '../../../../../../shared/git/diff/git-diff-compare-types'
import { canOpenDiffSectionPreviewToSide } from '../../diff/sections/diff-section-preview'
import { getLargeDiffRenderLimit } from '../../../../../../shared/large-diff-render-limit'
import {
getStoredTextDiffContent,
getStoredTextDiffResult,
isPrunedTextDiffResult
} from '../../diff/large-files/large-diff-section-content'
import { removeDiffSectionMeasuredHeight } from '../../diff/sections/diff-section-height-cache'
import type { DiffSection } from '../../diff/sections/diff-section-types'
import type { DiffSectionItemProps } from '../../diff/sections/diff-section-item-props'
import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature'
import { writeEditorSave } from '../../save/queue/editor-save-disk-precondition'
import { withEditorFileWriteLock } from '../../save/queue/editor-file-write-lock'
import { editorPathHoldKey } from '../../autosave/editor-autosave-holds'
export type CombinedDiffSectionActions = {
handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef']
modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef']
openAlternateDiff: () => void
openSection: (index: number) => void
openSectionPreview: (section: DiffSection) => void
}
export function useCombinedDiffSectionActions({
activeGroupId,
viewStateScopeId,
branchSummary,
branchCompare,
canOpenWorkspaceFileBrowserForPath,
commitCompare,
@@ -43,6 +37,8 @@ export function useCombinedDiffSectionActions({
setSections
}: {
activeGroupId: string | undefined
viewStateScopeId?: string
branchSummary?: AppState['gitBranchCompareSummaryByWorktree'][string]
branchCompare: NonNullable<OpenFile['branchCompare']> | null
canOpenWorkspaceFileBrowserForPath: (path: string) => boolean
commitCompare: NonNullable<OpenFile['commitCompare']> | null
@@ -55,10 +51,17 @@ export function useCombinedDiffSectionActions({
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
}): CombinedDiffSectionActions {
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const openFile = useAppStore((s) => s.openFile)
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
const openCommitDiff = useAppStore((s) => s.openCommitDiff)
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
const { handleSectionSaveRef, modifiedEditorsRef } = useCombinedDiffSectionSave({
file,
sectionsRef,
setSectionHeights,
setSections
})
const openSection = useCallback(
(index: number) => {
@@ -67,6 +70,7 @@ export function useCombinedDiffSectionActions({
return
}
const placement = editorViewPlacement(useAppStore.getState(), file, viewStateScopeId)
const language = detectLanguage(section.path)
const entry: GitBranchChangeEntry = {
path: section.path,
@@ -79,30 +83,38 @@ export function useCombinedDiffSectionActions({
const isBranchEntry = section.area === undefined
if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) {
openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language)
openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language, {
...placement,
runtimeEnvironmentId: file.runtimeEnvironmentId
})
return
}
if (isCommitMode && commitCompare) {
openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language)
openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language, {
...placement,
runtimeEnvironmentId: file.runtimeEnvironmentId
})
return
}
openFile({
filePath: joinPath(file.filePath, section.path),
relativePath: section.path,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language,
mode: 'edit'
})
openFile(
{
filePath: joinPath(file.filePath, section.path),
relativePath: section.path,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language,
mode: 'edit'
},
placement
)
},
[
branchCompare,
commitCompare,
file.filePath,
file.runtimeEnvironmentId,
file.worktreeId,
file,
viewStateScopeId,
isAllMode,
isBranchMode,
isCommitMode,
@@ -130,176 +142,42 @@ export function useCombinedDiffSectionActions({
}
// Use this tab’s split group; the workspace active group may belong to another pane.
const state = useAppStore.getState()
const sourceGroupId =
(state.unifiedTabsByWorktree[file.worktreeId] ?? []).find(
(tab) =>
tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor')
)?.groupId ??
activeGroupId ??
null
const placement = editorViewPlacement(state, file, viewStateScopeId)
openFilePreviewToSide({
language: detectLanguage(section.path),
filePath: joinPath(file.filePath, section.path),
worktreeId: file.worktreeId,
sourceGroupId
placementWorkspaceId: placement.placementWorkspaceId,
sourceGroupId: placement.targetGroupId ?? activeGroupId ?? null
})
},
[
activeGroupId,
canOpenWorkspaceFileBrowserForPath,
file.filePath,
file.id,
file.worktreeId,
isCommitMode
]
[activeGroupId, viewStateScopeId, file, canOpenWorkspaceFileBrowserForPath, isCommitMode]
)
const writtenBySectionRef = useRef(
new Map<string, { operation: DiffSection['operation']; content: string }>()
)
const runSectionSave = useCallback(
async (
sectionKey: string,
mode: 'overwrite' | undefined,
operation: NonNullable<DiffSection['operation']>
) => {
// Why by key: rows can be rebuilt while a save waits; a gone section is not saved.
const index = sectionsRef.current.findIndex((s) => s.key === sectionKey)
const section = sectionsRef.current[index]
if (!section || section.operation !== operation) {
return
}
const modifiedEditor = modifiedEditorsRef.current.get(index)
if (!modifiedEditor && !section.dirty) {
return
}
const content = modifiedEditor?.getValue() ?? section.modifiedContent
const absolutePath = joinPath(file.filePath, section.path)
// Why: edits live in modifiedContent; the diff result keeps the text loaded from (or last
// written to) disk, unless a large diff pruned it, in which case no version is known.
// Why first: a save earlier in this section's chain wrote it, maybe before state caught up.
const previousWrite = writtenBySectionRef.current.get(section.key)
const loaded =
(previousWrite?.operation === operation ? previousWrite.content : undefined) ??
(section.diffResult?.kind === 'text' && !isPrunedTextDiffResult(section.diffResult)
? section.diffResult.modifiedContent
: undefined)
const markConflict = (): void =>
setSections((prev) =>
prev.map((s) =>
s.key === sectionKey && s.operation === operation ? { ...s, saveConflict: true } : s
)
)
if (loaded === undefined && mode !== 'overwrite') {
markConflict()
return
}
try {
operation.assertCurrent()
const writeResult = await withEditorFileWriteLock(
editorPathHoldKey(useAppStore.getState(), operation.file, absolutePath),
() => {
operation.assertCurrent()
return writeEditorSave(
operation.mutationContext(),
absolutePath,
content,
undefined,
mode === 'overwrite' || loaded === undefined
? { kind: 'explicit-overwrite' }
: {
kind: 'guarded',
signature: getDiskBaselineSignature(loaded),
allowMissing: false
}
)
}
)
if (!writeResult.ok) {
markConflict()
return
}
try {
operation.assertCurrent()
} catch {
return
}
// Why: the section list can be rebuilt while the write is pending, so re-resolve
// by key — the captured index may now point at a different file.
const savedIndex = sectionsRef.current.findIndex(
(s) => s.key === sectionKey && s.operation === operation
)
if (savedIndex === -1) {
return
}
writtenBySectionRef.current.set(sectionKey, { operation, content })
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, savedIndex))
setSections((prev) =>
prev.map((s) => {
if (s.key !== sectionKey || s.operation !== operation) {
return s
}
const written =
s.diffResult?.kind === 'text' ? { ...s.diffResult, modifiedContent: content } : null
// Typing during the write is newer intent; advance only the baseline.
if (s.modifiedContent !== content) {
return written ? { ...s, diffResult: written, saveConflict: undefined } : s
}
if (!written) {
return { ...s, dirty: false, saveConflict: undefined }
}
const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({
originalContent: s.originalContent,
modifiedContent: content
})
const storedContent = getStoredTextDiffContent(written, nextLargeDiffRenderLimit)
return {
...s,
modifiedContent: storedContent.modifiedContent,
originalContent: storedContent.originalContent,
dirty: false,
saveConflict: undefined,
diffResult: getStoredTextDiffResult(written, nextLargeDiffRenderLimit),
largeDiffRenderLimit: nextLargeDiffRenderLimit
}
})
)
} catch (err) {
console.error('Save failed:', err)
}
},
[file.filePath, sectionsRef, setSectionHeights, setSections]
)
// Build each precondition after that section’s prior save settles.
const sectionSaveChainsRef = useRef(new Map<string, Promise<void>>())
const handleSectionSave = useCallback(
(index: number, mode?: 'overwrite'): Promise<void> => {
const section = sectionsRef.current[index]
const key = section?.key
const operation = section?.operation
if (key === undefined || !operation) {
return Promise.resolve()
}
const chains = sectionSaveChainsRef.current
const next = (chains.get(key) ?? Promise.resolve()).then(() =>
runSectionSave(key, mode, operation)
const openAlternateDiff = useCallback(() => {
if (!file.combinedAlternate) {
return
}
const placement = editorViewPlacement(useAppStore.getState(), file, viewStateScopeId)
const options = { ...placement, runtimeEnvironmentId: file.runtimeEnvironmentId }
if (file.combinedAlternate.source === 'combined-all') {
openAllDiffs(file.worktreeId, file.filePath, undefined, undefined, undefined, options)
} else if (branchSummary?.status === 'ready') {
openBranchAllDiffs(
file.worktreeId,
file.filePath,
branchSummary,
{ source: 'combined-all' },
options
)
const tracked = next.finally(() => {
if (chains.get(key) === tracked) {
chains.delete(key)
writtenBySectionRef.current.delete(key)
}
})
chains.set(key, tracked)
return tracked
},
[runSectionSave, sectionsRef]
)
}
}, [branchSummary, file, openAllDiffs, openBranchAllDiffs, viewStateScopeId])
const handleSectionSaveRef = useRef(handleSectionSave)
handleSectionSaveRef.current = handleSectionSave
return { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview }
return {
handleSectionSaveRef,
modifiedEditorsRef,
openSection,
openSectionPreview,
openAlternateDiff
}
}
@@ -0,0 +1,182 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import type React from 'react'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { joinPath } from '@/lib/path'
import type { OpenFile } from '@/store/slices/editor'
import type { DiffSection } from '../../diff/sections/diff-section-types'
import { getLargeDiffRenderLimit } from '../../../../../../shared/large-diff-render-limit'
import {
getStoredTextDiffContent,
getStoredTextDiffResult,
isPrunedTextDiffResult
} from '../../diff/large-files/large-diff-section-content'
import { removeDiffSectionMeasuredHeight } from '../../diff/sections/diff-section-height-cache'
import { getDiskBaselineSignature } from '../../../../../../shared/disk-baseline-signature'
import { writeEditorSave } from '../../save/queue/editor-save-disk-precondition'
import { withEditorFileWriteLock } from '../../save/queue/editor-file-write-lock'
import { editorPathHoldKey } from '../../autosave/editor-autosave-holds'
export function useCombinedDiffSectionSave({
file,
sectionsRef,
setSectionHeights,
setSections
}: {
file: OpenFile
sectionsRef: React.RefObject<DiffSection[]>
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
}) {
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
const writtenBySectionRef = useRef(
new Map<string, { operation: DiffSection['operation']; content: string }>()
)
const runSectionSave = useCallback(
async (
sectionKey: string,
mode: 'overwrite' | undefined,
operation: NonNullable<DiffSection['operation']>
) => {
// Why by key: rows can be rebuilt while a save waits; a gone section is not saved.
const index = sectionsRef.current.findIndex((s) => s.key === sectionKey)
const section = sectionsRef.current[index]
if (!section || section.operation !== operation) {
return
}
const modifiedEditor = modifiedEditorsRef.current.get(index)
if (!modifiedEditor && !section.dirty) {
return
}
const content = modifiedEditor?.getValue() ?? section.modifiedContent
const absolutePath = joinPath(file.filePath, section.path)
// Why: edits live in modifiedContent; the diff result keeps the text loaded from (or last
// written to) disk, unless a large diff pruned it, in which case no version is known.
// Why first: a save earlier in this section's chain wrote it, maybe before state caught up.
const previousWrite = writtenBySectionRef.current.get(section.key)
const loaded =
(previousWrite?.operation === operation ? previousWrite.content : undefined) ??
(section.diffResult?.kind === 'text' && !isPrunedTextDiffResult(section.diffResult)
? section.diffResult.modifiedContent
: undefined)
const markConflict = (): void =>
setSections((prev) =>
prev.map((s) =>
s.key === sectionKey && s.operation === operation ? { ...s, saveConflict: true } : s
)
)
if (loaded === undefined && mode !== 'overwrite') {
markConflict()
return
}
try {
operation.assertCurrent()
const writeResult = await withEditorFileWriteLock(
editorPathHoldKey(useAppStore.getState(), operation.file, absolutePath),
() => {
operation.assertCurrent()
return writeEditorSave(
operation.mutationContext(),
absolutePath,
content,
undefined,
mode === 'overwrite' || loaded === undefined
? { kind: 'explicit-overwrite' }
: {
kind: 'guarded',
signature: getDiskBaselineSignature(loaded),
allowMissing: false
}
)
}
)
if (!writeResult.ok) {
markConflict()
return
}
try {
operation.assertCurrent()
} catch {
return
}
// Why: the section list can be rebuilt while the write is pending, so re-resolve
// by key — the captured index may now point at a different file.
const savedIndex = sectionsRef.current.findIndex(
(s) => s.key === sectionKey && s.operation === operation
)
if (savedIndex === -1) {
return
}
writtenBySectionRef.current.set(sectionKey, { operation, content })
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, savedIndex))
setSections((prev) =>
prev.map((s) => {
if (s.key !== sectionKey || s.operation !== operation) {
return s
}
const written =
s.diffResult?.kind === 'text' ? { ...s.diffResult, modifiedContent: content } : null
// Typing during the write is newer intent; advance only the baseline.
if (s.modifiedContent !== content) {
return written ? { ...s, diffResult: written, saveConflict: undefined } : s
}
if (!written) {
return { ...s, dirty: false, saveConflict: undefined }
}
const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({
originalContent: s.originalContent,
modifiedContent: content
})
const storedContent = getStoredTextDiffContent(written, nextLargeDiffRenderLimit)
return {
...s,
modifiedContent: storedContent.modifiedContent,
originalContent: storedContent.originalContent,
dirty: false,
saveConflict: undefined,
diffResult: getStoredTextDiffResult(written, nextLargeDiffRenderLimit),
largeDiffRenderLimit: nextLargeDiffRenderLimit
}
})
)
} catch (err) {
console.error('Save failed:', err)
}
},
[file.filePath, sectionsRef, setSectionHeights, setSections]
)
// Build each precondition after that section’s prior save settles.
const sectionSaveChainsRef = useRef(new Map<string, Promise<void>>())
const handleSectionSave = useCallback(
(index: number, mode?: 'overwrite'): Promise<void> => {
const section = sectionsRef.current[index]
const key = section?.key
const operation = section?.operation
if (key === undefined || !operation) {
return Promise.resolve()
}
const chains = sectionSaveChainsRef.current
const next = (chains.get(key) ?? Promise.resolve()).then(() =>
runSectionSave(key, mode, operation)
)
const tracked = next.finally(() => {
if (chains.get(key) === tracked) {
chains.delete(key)
writtenBySectionRef.current.delete(key)
}
})
chains.set(key, tracked)
return tracked
},
[runSectionSave, sectionsRef]
)
const handleSectionSaveRef = useRef(handleSectionSave)
useLayoutEffect(() => {
handleSectionSaveRef.current = handleSectionSave
}, [handleSectionSave])
return { handleSectionSaveRef, modifiedEditorsRef }
}
@@ -1,7 +1,8 @@
import { useAppStore } from '@/store'
import { useAppStateValue } from '@/store/app-state-view'
import type { DiffCommentOwner } from '@/store/diff-comment-owner'
import React, { useCallback, useMemo } from 'react'
import type { DiffComment } from '../../../../../../shared/diff-comment-types'
import { useAppStore } from '@/store/index'
import { formatDiffComments } from '../../../../../../shared/diff-comments-format'
import { NotesSendMenu, type NotesSendMenuScope } from '../../review-note-delivery/NotesSendMenu'
import { translate } from '@/i18n/i18n'
@@ -42,9 +43,11 @@ export function DiffNotesSendMenu({
respondToOpenRequest?: boolean
}): React.JSX.Element {
const languageRevision = useUiLanguageRevision()
const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments)
const openRequest = useAppStore((s) => s.diffNotesSendMenuOpenRequest)
const consumeOpenRequest = useAppStore((s) => s.consumeDiffNotesSendMenuOpenRequest)
const openRequest = useAppStateValue((s) =>
respondToOpenRequest && s.diffNotesSendMenuOpenRequest?.worktreeId === worktreeId
? s.diffNotesSendMenuOpenRequest
: null
)
// Enforce TTL when opening commits in NotesSendMenu; render-time clocks are stale or require polling.
const hasPendingOpenRequest = respondToOpenRequest && openRequest?.worktreeId === worktreeId
const openRequestNonce = hasPendingOpenRequest ? (openRequest?.nonce ?? null) : null
@@ -52,8 +55,8 @@ export function DiffNotesSendMenu({
? (openRequest?.issuedAt ?? 0) + OPEN_REQUEST_TTL_MS
: null
const handleOpenRequestHandled = useCallback(
() => consumeOpenRequest(worktreeId),
[consumeOpenRequest, worktreeId]
() => useAppStore.getState().consumeDiffNotesSendMenuOpenRequest(worktreeId),
[worktreeId]
)
const unsentNotes = useMemo(() => comments.filter((comment) => !comment.sentAt), [comments])
const fileNotes = useMemo(
@@ -102,7 +105,9 @@ export function DiffNotesSendMenu({
openRequestNonce={openRequestNonce}
openRequestExpiresAt={openRequestExpiresAt}
onOpenRequestHandled={handleOpenRequestHandled}
onDelivered={(notes) => void (owner && clearDeliveredDiffComments(owner, notes))}
onDelivered={(notes) =>
void (owner && useAppStore.getState().clearDeliveredDiffComments(owner, notes))
}
/>
)
}
@@ -0,0 +1,61 @@
import { useAppStore } from '@/store'
import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement'
import { openFilePreviewToSide } from '@/lib/file-preview'
import { detectLanguage } from '@/lib/language-detect'
import type { OpenFile } from '@/store/slices/editor/types/open-file'
import type { getEditorPanelRenderModel } from './editor-panel-render-model'
export function editorPanelFileActions(
file: OpenFile,
viewStateId: string | null,
model: Pick<ReturnType<typeof getEditorPanelRenderModel>, 'openFileState' | 'resolvedLanguage'>
) {
const handleOpenPreviewToSide = (): void => {
const state = useAppStore.getState()
const placement = editorViewPlacement(state, file, viewStateId)
openFilePreviewToSide({
language: model.resolvedLanguage,
filePath: file.filePath,
worktreeId: file.worktreeId,
placementWorkspaceId: placement.placementWorkspaceId,
sourceGroupId: placement.targetGroupId ?? null
})
}
const handleOpenDiffTargetFile = (preferredMarkdownViewMode?: 'rich'): void => {
if (!model.openFileState.canOpen) {
return
}
const { openFile, setEditorViewMode, setMarkdownViewMode } = useAppStore.getState()
const openedFileId = openFile(
{
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: detectLanguage(file.relativePath),
mode: 'edit'
},
editorViewPlacement(useAppStore.getState(), file, viewStateId)
)
if (preferredMarkdownViewMode) {
setEditorViewMode(openedFileId, 'edit')
setMarkdownViewMode(openedFileId, preferredMarkdownViewMode)
}
}
const handleOpenMarkdownPreview = (): void => {
useAppStore.getState().openMarkdownPreview(
{
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: model.resolvedLanguage
},
{
sourceFileId: file.id,
...editorViewPlacement(useAppStore.getState(), file, viewStateId)
}
)
}
return { handleOpenPreviewToSide, handleOpenDiffTargetFile, handleOpenMarkdownPreview }
}
@@ -55,6 +55,7 @@ export function createModelLifetimeFixture(register = true) {
const file = modelLifetimeFile(id)
const model = modelLifetimeEditorModel(file.filePath, `${id}\n${'x'.repeat(256 * 1024)}`)
store.setState({ openFiles: [...store.getState().openFiles, file], activeFileId: id })
store.getState().createUnifiedTab(workspace, 'editor', { entityId: id, label: id })
return { file, model }
}
return { store, bridge, attach, add }
@@ -1,7 +1,8 @@
import { useAppStore } from '@/store'
import { useAppStateValue } from '@/store/app-state-view'
import type { DiffCommentOwner } from '@/store/diff-comment-owner'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Send, Sparkles } from 'lucide-react'
import { useAppStore } from '@/store/index'
import type { AgentSendPopoverTargetMode } from '@/store/slices/ui/ui-slice-contract-core'
import {
DropdownMenu,
@@ -94,11 +95,9 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
onOpenRequestHandled,
onDelivered
}: NotesSendMenuProps<TNote>): React.JSX.Element {
const openAgentSendPopoverTargetMode = useAppStore((s) => s.openAgentSendPopoverTargetMode)
const closeAgentSendPopoverTargetMode = useAppStore((s) => s.closeAgentSendPopoverTargetMode)
const activeTargetModeId = useAppStore((s) => s.agentSendPopoverTargetMode?.id ?? null)
const [sendMenuOpen, setSendMenuOpen] = useState(false)
const targetModeId = useMemo(() => buildNotesSendTargetModeId(modeIdParts), [modeIdParts])
const ownsTargetMode = useAppStateValue((s) => s.agentSendPopoverTargetMode?.id === targetModeId)
const inFlightVersion = useNotesInFlightVersion()
const sendableScopes = useMemo<SendableNotesScope<TNote>[]>(() => {
void inFlightVersion
@@ -147,7 +146,7 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
if (scope.notes.length === 0) {
return
}
openAgentSendPopoverTargetMode({
useAppStore.getState().openAgentSendPopoverTargetMode({
id: targetModeId,
worktreeId,
source,
@@ -158,15 +157,7 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
onPromptHandedOff: holdInFlight(scope.notes)
})
},
[
holdInFlight,
markDelivered,
openAgentSendPopoverTargetMode,
source,
targetModeId,
targetModeLabel,
worktreeId
]
[holdInFlight, markDelivered, source, targetModeId, targetModeLabel, worktreeId]
)
const handleOpenChange = useCallback(
@@ -177,23 +168,23 @@ export function NotesSendMenu<TNote extends DiffCommentDeliverySnapshot>({
openTargetMode(defaultScope)
}
} else {
closeAgentSendPopoverTargetMode(targetModeId)
useAppStore.getState().closeAgentSendPopoverTargetMode(targetModeId)
}
},
[closeAgentSendPopoverTargetMode, defaultScope, openTargetMode, targetModeId]
[defaultScope, openTargetMode, targetModeId]
)
const effectiveSendMenuOpen = sendMenuOpen && activeTargetModeId === targetModeId
if (sendMenuOpen && activeTargetModeId !== targetModeId) {
const effectiveSendMenuOpen = sendMenuOpen && ownsTargetMode
if (sendMenuOpen && !ownsTargetMode) {
// Clear stale local open state before painting another target’s menu.
setSendMenuOpen(false)
}
useEffect(
() => () => {
closeAgentSendPopoverTargetMode(targetModeId)
useAppStore.getState().closeAgentSendPopoverTargetMode(targetModeId)
},
[closeAgentSendPopoverTargetMode, targetModeId]
[targetModeId]
)
useEffect(() => {
@@ -1,8 +1,19 @@
import { useAppStateValue, useAppStateView, useAppStateViewActive } from '@/store/app-state-view'
import { toast } from 'sonner'
import { shallow } from 'zustand/shallow'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store/index'
import {
useStructuredChatTabNames,
selectLiveAgentStatusEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from '@/components/sidebar/workspace-card/activity/worktree-agent-row-selectors'
import {
selectRuntimePaneTitlesForWorktree,
selectLivePtyIdsForWorktree
} from '@/components/sidebar/workspace-card/activity/worktree-card-status-inputs'
import { useAppStore } from '@/store/index'
import { selectKnownWorktreeById } from '@/store/selectors'
import {
useWorktreeStructuredChatTabNames,
getStructuredChatTabNames
} from '@/runtime/structured-chat-name-observation'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
@@ -30,7 +41,6 @@ import { track } from '@/lib/telemetry'
import { useNow } from '@/hooks/use-now'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
import { selectLivePtyIdsForWorktree } from '@/components/sidebar/workspace-card/activity/worktree-card-status-inputs'
import { useWorktreeAgentRows } from '@/components/sidebar/workspace-card/activity/useWorktreeAgentRows'
import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
import { getAgentRowPrimaryText } from '../../../../../shared/agent-status/presentation/agent-row-primary-text'
@@ -69,43 +79,52 @@ export function ReviewNotesSendMenuContent({
}): React.JSX.Element {
const hasPrompt = prompt.trim().length > 0
// Include every workspace agent, not just the focused pane; memoize slice-derived targets to avoid fresh selector arrays.
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const ptyIdsByTabId = useAppStore(useShallow((s) => selectLivePtyIdsForWorktree(s, worktreeId)))
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const viewActive = useAppStateViewActive()
const store = useAppStateView()
const agentRows = useWorktreeAgentRows(worktreeId)
const now = useNow(30_000)
const chatNames = useStructuredChatTabNames()
const sendTargets = useMemo(() => {
void agentStatusEpoch
return deriveNotesSendAgentTargets(
{
agentStatusByPaneKey,
tabsByWorktree,
unifiedTabsByWorktree,
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId
},
worktreeId,
chatNames
)
}, [
// Freshness timers can change eligibility through this epoch without replacing the status map.
agentStatusEpoch,
chatNames,
agentStatusByPaneKey,
tabsByWorktree,
unifiedTabsByWorktree,
terminalLayoutsByTabId,
runtimePaneTitlesByTabId,
ptyIdsByTabId,
worktreeId
])
const now = useNow(30_000, viewActive)
const worktree = useAppStateValue(
useShallow((state) => {
const row = selectKnownWorktreeById(state, worktreeId)
return row
? {
id: row.id,
hostId: row.hostId,
runtimeOwnerEnvironmentId: row.runtimeOwnerEnvironmentId
}
: null
})
)
const tabsKey = useAppStateValue((state) => state.unifiedTabsByWorktree[worktreeId])
const chatNames = useWorktreeStructuredChatTabNames(worktree, tabsKey, true)
const selectTargets = useMemo(() => {
let inputs: unknown[] = []
let previous: NotesSendAgentTarget[] = []
return (state: ReturnType<typeof store.getState>) => {
const nextInputs = [
state.tabsByWorktree[worktreeId],
state.unifiedTabsByWorktree[worktreeId],
selectLiveAgentStatusEntriesForWorktree(state, worktreeId),
selectTerminalLayoutsForWorktree(state, worktreeId),
selectLivePtyIdsForWorktree(state, worktreeId),
selectRuntimePaneTitlesForWorktree(state, worktreeId),
state.agentStatusEpoch
]
if (shallow(inputs, nextInputs)) {
return previous
}
inputs = nextInputs
const next = deriveNotesSendAgentTargets(state, worktreeId, chatNames)
if (
next.length !== previous.length ||
next.some((target, i) => !shallow(target, previous[i]))
) {
previous = next
}
return previous
}
}, [worktreeId, chatNames])
const sendTargets = useAppStateValue(selectTargets)
const orderedSendTargets = useMemo(
() => orderSendTargetsByWorktreeAgentRows(sendTargets, agentRows),
[agentRows, sendTargets]
@@ -33,10 +33,17 @@ export async function discardEditorBuffer(fileId: string): Promise<() => void> {
}
/** "Don't Save": drop unsaved edits, let saves already writing finish, then close the tab. */
export async function discardEditorFileChangesAndClose(fileId: string): Promise<void> {
export async function discardEditorFileChangesAndClose(
fileId: string,
tabId?: string
): Promise<void> {
const release = await discardEditorBuffer(fileId)
try {
useAppStore.getState().closeFile(fileId)
if (tabId) {
useAppStore.getState().closeUnifiedTab(tabId)
} else {
useAppStore.getState().closeFile(fileId)
}
} finally {
release()
}
@@ -172,6 +172,10 @@ describe('useEditorPanelContentState', () => {
mocks.getState.mockReturnValue({
...createTestStore().getState(),
settings: null,
repos: [{ id: 'repo-1', path: '/repo', hostId: 'local' }],
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }]
},
openFiles: [],
setLastKnownDiskSignature: vi.fn()
})
@@ -1,7 +1,6 @@
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
import { getEditorFileReadContext } from '@/lib/editor-file-operation-owner'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionIdForFile } from '@/lib/connection-context'
import { useAppStore } from '@/store'
import {
getRuntimeGitBranchDiff,
@@ -71,11 +70,10 @@ export function useEditorPanelDiffContentLoader({
? file.branchCompare
: null
const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null
const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
const fileTarget = requireRuntimeTargetForFileOwner(
const { connectionId, target: fileTarget } = getEditorFileReadContext(
useAppStore.getState(),
file.worktreeId,
file.runtimeEnvironmentId
file,
worktreePath
)
const gitScope = getRuntimeGitScope(fileTarget, connectionId)
const effectiveDiffSource: typeof file.diffSource =
@@ -172,6 +172,10 @@ describe('useEditorPanelContentState visibility', () => {
mocks.getState.mockReturnValue({
...createTestStore().getState(),
settings: null,
repos: [{ id: 'repo-1', path: '/repo', hostId: 'local' }],
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' }]
},
openFiles: [],
setLastKnownDiskSignature: vi.fn()
})
@@ -1,3 +1,4 @@
import { editorViewPlacement } from '@/store/slices/editor/tabs/editor-view-placement'
import { useEffect, type RefObject } from 'react'
import { detectLanguage } from '../../../../../shared/editor-language/editor-language-detect'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
@@ -7,6 +8,7 @@ import { canOpenMarkdownPreview, isMarkdownPreviewShortcut } from './markdown-pr
import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router'
type UseMarkdownPreviewShortcutParams = {
activeViewStateId?: string | null
activeFile: OpenFile | null
panelRef: RefObject<HTMLDivElement | null>
openMarkdownPreview: (
@@ -17,12 +19,13 @@ type UseMarkdownPreviewShortcutParams = {
runtimeEnvironmentId?: string | null
language: string
},
options?: { sourceFileId?: string }
options?: { sourceFileId?: string; placementWorkspaceId?: string; targetGroupId?: string }
) => void
}
export function useMarkdownPreviewShortcut({
activeFile,
activeViewStateId,
panelRef,
openMarkdownPreview
}: UseMarkdownPreviewShortcutParams): void {
@@ -73,12 +76,19 @@ export function useMarkdownPreviewShortcut({
runtimeEnvironmentId: activeFileRuntimeEnvironmentId,
language: shortcutLanguage
},
{ sourceFileId: activeFileId ?? undefined }
{
sourceFileId: activeFileId ?? undefined,
...(activeFile
? editorViewPlacement(useAppStore.getState(), activeFile, activeViewStateId)
: {})
}
)
}
addAppKeyListener('keydown', handleKeyDown)
return () => removeAppKeyListener('keydown', handleKeyDown)
}, [
activeFile,
activeViewStateId,
activeFileDiffSource,
activeFileMode,
activeFilePath,
@@ -1,3 +1,4 @@
import { useAppStateValue } from '@/store/app-state-view'
import React, { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { ExternalLink, FolderOpen } from 'lucide-react'
@@ -8,7 +9,6 @@ import {
ContextMenuSubContent,
ContextMenuSubTrigger
} from '@/components/ui/context-menu'
import { useAppStore } from '@/store/index'
import { getRouteForLocalPathOpenOwner } from '@/lib/local-path-open-guard'
import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog'
import { getWorkspaceEditorSshHost } from '@/lib/workspace-editor-ssh-target'
@@ -33,12 +33,14 @@ export function OpenInContextSubmenu({
workspaceId,
owner
}: OpenInContextSubmenuProps): React.JSX.Element {
const openInApplications = useAppStore(
const openInApplications = useAppStateValue(
(s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS
)
const openInRoute = useMemo(() => getRouteForLocalPathOpenOwner(owner), [owner])
const workspace = useMemo(() => (workspaceId ? { workspaceId } : null), [workspaceId])
const runtimeSshHost = useAppStore(useShallow((s) => getWorkspaceEditorSshHost(s, workspace)))
const runtimeSshHost = useAppStateValue(
useShallow((s) => getWorkspaceEditorSshHost(s, workspace))
)
const handleOpen = useCallback(
(application: OpenInApplication) => {
@@ -1,6 +1,6 @@
import { useAppStateValue } from '@/store/app-state-view'
import { translate } from '@/i18n/i18n'
import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label'
import { useAppStore } from '@/store'
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { Button } from '../ui/button'
@@ -21,7 +21,7 @@ export function ManagedRuntimeRemoveDialog(props: {
onRemove: () => void
}): React.JSX.Element {
const { agentLabel, host, open, onOpenChange, onRemove } = props
const hostLabel = useAppStore((state) =>
const hostLabel = useAppStateValue((state) =>
host.kind === 'environment'
? selectExecutionHostDisplayLabel(state, toRuntimeExecutionHostId(host.environmentId))
: translate('components.managed-runtime.thisComputer', 'this computer')
@@ -0,0 +1,693 @@
import { getDefaultSettings } from '../../../../shared/constants'
// @vitest-environment happy-dom
import { act, Profiler, type ReactNode } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../../shared/worktree/types'
import type * as WorktreeGitCopy from '@/lib/worktree-git-copy'
import type * as GitStatusClient from '@/runtime/runtime-git-status-client'
import type * as GitDiffClient from '@/runtime/runtime-git-diff-client'
import type * as PanelModule from './source-control/panel/panel'
import type * as RepoClient from '@/runtime/runtime-repo-client'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { TooltipProvider } from '@/components/ui/tooltip'
import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel'
import { SourceControlPanel } from './source-control/panel/panel'
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
import { createRunningCommitMessageGenerationRecord } from '@/store/slices/commit-message-generation'
import type { PullRequestGenerationRecord } from '@/store/slices/pull-request-generation-state'
const probes = vi.hoisted(() => ({
status: vi.fn(),
upstreamRef: vi.fn(),
compare: vi.fn(),
history: vi.fn(),
submodules: vi.fn(),
upstream: vi.fn(),
eligibility: vi.fn(),
visible: new Set<string>(),
bodyRenders: new Map<string, number>()
}))
const pending = (): Promise<never> => new Promise(() => {})
vi.mock('./source-control/panel/panel', async (original) => {
const { SourceControlPanel: Panel } = await original<typeof PanelModule>()
const { useSourceControlScope } = await import('./source-control/panel/worktree-scope')
return {
SourceControlPanel: () => {
const id = useSourceControlScope()?.worktreeId ?? 'selected'
return (
<Profiler
id={id}
onRender={() => probes.bodyRenders.set(id, (probes.bodyRenders.get(id) ?? 0) + 1)}
>
<Panel />
</Profiler>
)
}
}
})
vi.mock('./source-control/sync/git-status-refresh', () => ({
refreshGitStatusForWorktree: async (args: {
worktreeId: string
request?: { onStatusAccepted?: (status: unknown) => void }
}) => {
probes.status(args.worktreeId)
args.request?.onStatusAccepted?.({
entries: [],
conflictOperation: 'unknown',
branch: 'refs/heads/feature',
upstreamStatus: { hasUpstream: true, upstreamName: 'origin/feature', ahead: 0, behind: 0 }
})
}
}))
vi.mock('./source-control/sync/use-refresh-on-host-contact', () => ({
useRefreshOnHostContact: () => {}
}))
vi.mock('@/lib/worktree-git-copy', async (original) => ({
...(await original<typeof WorktreeGitCopy>()),
resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({
worktreeId: id,
hostId,
transport: { kind: 'local' }
})
}))
vi.mock('@/runtime/runtime-git-status-client', async (original) => ({
...(await original<typeof GitStatusClient>()),
setRuntimeGitStatusUpstreamRefWatch: async (context: { worktreeId: string }) =>
probes.upstreamRef(context.worktreeId),
getRuntimeGitHistory: (context: { worktreeId: string }) => {
probes.history(context.worktreeId)
return pending()
},
getRuntimeGitSubmoduleStatus: (context: { worktreeId: string }) => {
probes.submodules(context.worktreeId)
return pending()
}
}))
vi.mock('@/runtime/runtime-git-diff-client', async (original) => ({
...(await original<typeof GitDiffClient>()),
getRuntimeGitBranchCompare: async (context: { worktreeId: string }, baseRef: string) => {
probes.compare(context.worktreeId)
return {
summary: {
baseRef,
baseOid: 'base',
compareRef: 'feature',
headOid: 'head',
mergeBase: 'base',
changedFiles: 0,
status: 'ready'
},
entries: []
}
}
}))
vi.mock('@/runtime/runtime-repo-client', async (original) => ({
...(await original<typeof RepoClient>()),
watchRuntimeRepoBaseRefDefault: () => () => {},
searchRuntimeRepoBaseRefs: async () => ['feature/live']
}))
// Report a section on screen only when the test made it visible, as a scrolled sidebar would.
const observed = new Map<Element, IntersectionObserver>()
class ViewportIntersectionObserver implements IntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly scrollMargin = '0px'
readonly thresholds = [0]
constructor(private readonly callback: IntersectionObserverCallback) {}
report(target: Element): void {
const id = target.getAttribute('data-source-control-worktree') ?? ''
const bounds = target.getBoundingClientRect()
const entry: IntersectionObserverEntry = {
target,
isIntersecting: probes.visible.has(id),
boundingClientRect: bounds,
intersectionRect: bounds,
rootBounds: bounds,
intersectionRatio: probes.visible.has(id) ? 1 : 0,
time: performance.now()
}
this.callback([entry], this)
}
observe(target: Element): void {
observed.set(target, this)
this.report(target)
}
unobserve(target: Element): void {
observed.delete(target)
}
disconnect(): void {
for (const [target, observer] of observed) {
if (observer === this) {
observed.delete(target)
}
}
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
async function scrollTo(ids: readonly string[]): Promise<void> {
probes.visible.clear()
for (const id of ids) {
probes.visible.add(id)
}
await act(async () => {
for (const [target, observer] of observed) {
if (observer instanceof ViewportIntersectionObserver) {
observer.report(target)
}
}
})
}
function writeUnrelatedGeneration(): void {
const context = {
worktreeId: 'other::/elsewhere',
worktreePath: '/elsewhere',
requestId: 1,
runtimeTarget: { kind: 'local' as const }
}
const pr: PullRequestGenerationRecord = {
context: { ...context, repoId: 'other', branch: 'other' },
seed: { base: 'main', title: '', body: '', draft: false },
seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 },
requiresPushBeforeCreate: false,
status: 'running',
result: null,
error: null,
hydrated: false
}
useAppStore
.getState()
.setCommitMessageGenerationRecord(
context.worktreeId,
createRunningCommitMessageGenerationRecord(context)
)
useAppStore.getState().setPullRequestGenerationRecord(context.worktreeId, pr)
}
const BODY_OPERATIONS = ['compare', 'history', 'submodules', 'upstream', 'eligibility'] as const
let root: Root
let container: HTMLDivElement
let renders = 0
const refuseConfirmation = async () => false
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal('IntersectionObserver', ViewportIntersectionObserver)
const namespace = new Proxy(
{},
{
get: (_target, method) =>
typeof method === 'string' && method.startsWith('on') ? () => () => {} : pending
}
)
vi.stubGlobal('api', new Proxy({}, { get: () => namespace }))
vi.useFakeTimers()
useAppStore.setState(useAppStore.getInitialState(), true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
for (const probe of Object.values(probes)) {
if (typeof probe === 'function') {
probe.mockClear()
}
}
probes.visible.clear()
observed.clear()
renders = 0
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
function setup(count: number): Worktree[] {
const worktrees: Worktree[] = Array.from({ length: count }, (_, i) => ({
id: `repo::/child-${i}`,
repoId: 'repo',
path: `/child-${i}`,
branch: `refs/heads/feature-${i}`,
head: 'abc123',
isBare: false,
isMainWorktree: false,
displayName: `Child ${i}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: i,
lastActivityAt: 0,
hostId: 'local' as const,
instanceId: `instance-${i}`
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies the catalog, status and probed actions the panels read.
useAppStore.setState({
settings: getDefaultSettings('/tmp'),
activeWorktreeId: 'folder:feature',
activeWorkspaceKey: 'folder:feature',
activeWorkspaceExecutionHostId: 'local',
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
repos: [
{
id: 'repo',
displayName: 'Child',
path: '/repo',
kind: 'git',
worktreeBaseRef: 'origin/main'
}
],
worktreesByRepo: { repo: worktrees },
folderWorkspaces: [
{ id: 'feature', name: 'Feature', folderPath: '/folder', projectGroupId: 'group' }
],
workspaceLineageByChildKey: Object.fromEntries(
worktrees.map((row, i) => [
row.id,
{
childWorkspaceKey: `worktree:${row.id}`,
childInstanceId: row.instanceId,
parentWorkspaceKey: 'folder:feature',
parentInstanceId: null,
createdAt: i
}
])
),
// Changed sections start expanded, so every body would mount without the viewport gate.
gitStatusByWorktree: Object.fromEntries(
worktrees.map((row) => [row.id, [{ path: 'a.ts', status: 'modified', area: 'unstaged' }]])
),
fetchUpstreamStatus: async (worktreeId: string) => {
probes.upstream(worktreeId)
},
getHostedReviewCreationEligibility: (args: { worktreePath?: string }) => {
probes.eligibility(args.worktreePath)
return pending()
},
fetchHostedReviewForBranch: async () => null,
fetchPRForBranch: async () => null
} as unknown as Partial<AppState>)
return worktrees
}
async function render(node: ReactNode): Promise<void> {
await act(async () =>
root.render(
<ConfirmationDialogContext.Provider value={refuseConfirmation}>
<TooltipProvider>
<Profiler id="source-control" onRender={() => (renders += 1)}>
{node}
</Profiler>
</TooltipProvider>
</ConfirmationDialogContext.Provider>
)
)
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
}
function bodyWork(worktrees: readonly Worktree[]) {
const idByPath = new Map(worktrees.map((row) => [row.path, row.id]))
return Object.fromEntries(
BODY_OPERATIONS.map((operation) => {
const ids = probes[operation].mock.calls.map(
([key]: unknown[]) => idByPath.get(String(key)) ?? String(key)
)
return [operation, { calls: ids.length, worktrees: [...new Set(ids)].sort() }]
})
)
}
it('runs expanded body work only for on-screen sections, at mount and over the safety interval', async () => {
const counts: unknown[] = []
for (const count of [3, 30]) {
useAppStore.setState(useAppStore.getInitialState(), true)
const worktrees = setup(count)
const visible = [worktrees[0]!.id, worktrees[1]!.id]
probes.visible.clear()
for (const id of visible) {
probes.visible.add(id)
}
for (const probe of [probes.upstreamRef, ...BODY_OPERATIONS.map((name) => probes[name])]) {
probe.mockClear()
}
await render(<FolderWorkspaceSourceControlPanel />)
const atMount = bodyWork(worktrees)
expect(atMount.upstream!.calls).toBeGreaterThan(0)
expect(atMount.compare!.worktrees).toEqual(visible.toSorted())
expect(atMount.compare!.calls).toBeGreaterThanOrEqual(visible.length)
const intervals: ReturnType<typeof bodyWork>[] = []
for (let tick = 0; tick < 2; tick++) {
for (const operation of BODY_OPERATIONS) {
probes[operation].mockClear()
}
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
intervals.push(bodyWork(worktrees))
}
for (const snapshot of [atMount, ...intervals]) {
for (const work of Object.values(snapshot)) {
expect(work.worktrees.every((id) => visible.includes(id))).toBe(true)
expect(work.calls).toBeLessThanOrEqual(visible.length)
}
}
// Only the selected worktree binds an upstream ref; sections never add resolutions.
expect(probes.upstreamRef).not.toHaveBeenCalled()
counts.push({ atMount, intervals })
await act(async () => root.render(null))
}
// Ten times the attached worktrees costs the same when the same sections are on screen.
expect(counts[1]).toEqual(counts[0])
})
it.each([3, 30])(
'freezes collapsed and off-screen bodies among %i sections on unrelated app writes',
async (count) => {
const worktrees = setup(count)
const [active, collapsed, offScreen] = worktrees
for (const row of worktrees) {
probes.visible.add(row.id)
}
await render(<FolderWorkspaceSourceControlPanel />)
const collapsedSection = container.querySelector(
`[data-source-control-worktree="${collapsed!.id}"]`
)!
await act(async () =>
collapsedSection.querySelector<HTMLButtonElement>('button[aria-expanded]')!.click()
)
await scrollTo(worktrees.filter((row) => row !== offScreen).map((row) => row.id))
const writes = [
() =>
useAppStore.setState((state) => ({
worktreesByRepo: {
repo: state.worktreesByRepo.repo!.map((row) =>
row.id === collapsed!.id ? { ...row, lastActivityAt: 99 } : row
)
}
})),
() =>
useAppStore.setState((state) => ({
settings: {
...state.settings!,
theme: state.settings?.theme === 'dark' ? 'light' : 'dark'
}
})),
() =>
useAppStore.setState((state) => ({
activeGroupIdByWorktree: {
...state.activeGroupIdByWorktree,
'folder:feature': 'other-group'
},
activeTabTypeByWorktree: {
...state.activeTabTypeByWorktree,
'folder:feature': 'terminal'
}
})),
() =>
useAppStore.setState((state) => ({
groupsByWorktree: {
...state.groupsByWorktree,
'folder:feature': [
{ id: 'other-group', worktreeId: 'folder:feature', tabOrder: [], activeTabId: null }
]
}
})),
() =>
useAppStore.setState((state) => ({
openFiles: [
...state.openFiles,
{
id: 'unrelated-file',
worktreeId: 'elsewhere',
filePath: '/elsewhere/other.ts',
relativePath: 'other.ts',
language: 'typescript',
mode: 'edit',
isDirty: false
}
]
})),
() =>
useAppStore.setState((state) => ({
gitStatusByWorktree: {
...state.gitStatusByWorktree,
elsewhere: [{ path: 'other.ts', status: 'modified', area: 'unstaged' }]
}
})),
writeUnrelatedGeneration
]
for (const [writeIndex, write] of writes.entries()) {
probes.bodyRenders.clear()
probes.eligibility.mockClear()
await act(async () => write())
for (const row of worktrees) {
expect(probes.bodyRenders.get(row.id) ?? 0, `write ${writeIndex}, owner ${row.id}`).toBe(0)
}
expect(probes.eligibility).not.toHaveBeenCalled()
}
probes.bodyRenders.clear()
await act(async () =>
useAppStore.setState((state) => ({
openFiles: [
...state.openFiles,
{
id: 'active-file',
worktreeId: active!.id,
filePath: `${active!.path}/a.ts`,
relativePath: 'a.ts',
language: 'typescript',
mode: 'edit',
isDirty: false
}
],
activeFileIdByWorktree: {
...state.activeFileIdByWorktree,
'folder:feature': 'active-file'
},
activeTabTypeByWorktree: { ...state.activeTabTypeByWorktree, 'folder:feature': 'editor' }
}))
)
expect(
container
.querySelector(
`[data-source-control-worktree="${active!.id}"] [data-source-control-path="a.ts"]`
)
?.getAttribute('data-current')
).toBe('true')
expect(probes.bodyRenders.get(active!.id)).toBeGreaterThan(0)
for (const row of worktrees.slice(1)) {
expect(probes.bodyRenders.get(row.id) ?? 0).toBe(0)
}
// The frozen views ignore their own status and owner changes, while a relevant live update is visible.
probes.bodyRenders.clear()
await act(async () =>
useAppStore.setState((state) => ({
gitStatusByWorktree: {
...state.gitStatusByWorktree,
[active!.id]: [
...state.gitStatusByWorktree[active!.id]!,
{ path: 'visible.ts', status: 'modified', area: 'unstaged' }
],
[offScreen!.id]: [
...state.gitStatusByWorktree[offScreen!.id]!,
{ path: 'hidden.ts', status: 'modified', area: 'unstaged' }
]
}
}))
)
expect(probes.bodyRenders.get(active!.id)).toBeGreaterThan(0)
expect(probes.bodyRenders.get(collapsed!.id) ?? 0).toBe(0)
expect(probes.bodyRenders.get(offScreen!.id) ?? 0).toBe(0)
expect(
container.querySelector(`[data-source-control-worktree="${active!.id}"]`)!.textContent
).toContain('visible.ts')
await scrollTo(worktrees.map((row) => row.id))
expect(
container.querySelector(`[data-source-control-worktree="${offScreen!.id}"]`)!.textContent
).toContain('hidden.ts')
}
)
it('keeps the selected workspace panel idle when another worktree writes generation records', async () => {
const [worktree] = setup(1)
useAppStore.setState({
activeWorktreeId: worktree!.id,
activeWorkspaceKey: `worktree:${worktree!.id}`
})
await render(<SourceControlPanel />)
expect(probes.eligibility).toHaveBeenCalled()
probes.eligibility.mockClear()
renders = 0
await act(async () => writeUnrelatedGeneration())
expect(probes.eligibility).not.toHaveBeenCalled()
expect(renders).toBe(0)
})
it.each(['off-screen', 'collapsed'])(
'keeps selection and draft while %s and resumes the current owner snapshot',
async (inactive) => {
const worktrees = setup(3)
const [first] = worktrees
await scrollTo([first!.id])
await render(<FolderWorkspaceSourceControlPanel />)
const section = container.querySelector(`[data-source-control-worktree="${first!.id}"]`)
const row = section?.querySelector('[data-testid="source-control-entry"]')
expect(row).toBeTruthy()
// Both modifiers select on either platform without triggering split open.
await act(async () =>
row!.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true, metaKey: true }))
)
expect(section?.textContent).toContain('1 selected')
const draft = section!.querySelector<HTMLTextAreaElement>('textarea')!
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!
await act(async () => {
setValue.call(draft, 'fix: keep my draft')
draft.dispatchEvent(new Event('input', { bubbles: true }))
draft.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(draft.value).toBe('fix: keep my draft')
await (inactive === 'off-screen'
? scrollTo([])
: act(async () =>
section!.querySelector<HTMLButtonElement>('button[aria-expanded]')!.click()
))
await act(async () =>
useAppStore.setState((state) => ({
worktreesByRepo: {
repo: state.worktreesByRepo.repo!.map((row) =>
row.id === first!.id ? { ...row, branch: 'refs/heads/current-branch' } : row
)
},
gitStatusByWorktree: {
...state.gitStatusByWorktree,
[first!.id]: [
...state.gitStatusByWorktree[first!.id]!,
{ path: 'latest.ts', status: 'modified', area: 'unstaged' }
]
}
}))
)
expect(section!.querySelector('[data-source-control-path="latest.ts"]')).toBeNull()
for (const operation of BODY_OPERATIONS) {
probes[operation].mockClear()
}
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
// A mounted but off-screen body keeps its view and runs nothing.
for (const operation of BODY_OPERATIONS) {
expect(probes[operation]).not.toHaveBeenCalled()
}
await (inactive === 'off-screen'
? scrollTo([first!.id])
: act(async () =>
section!.querySelector<HTMLButtonElement>('button[aria-expanded]')!.click()
))
expect(section?.textContent).toContain('1 selected')
expect(section!.querySelector('[data-source-control-path="latest.ts"]')).toBeTruthy()
expect(section!.querySelector<HTMLTextAreaElement>('textarea')!.value).toBe(
'fix: keep my draft'
)
}
)
it('opens a deleted section entry as its diff from Open in File Explorer', async () => {
const [owner] = setup(1)
useAppStore.setState({
gitStatusByWorktree: {
[owner!.id]: [{ path: 'deleted.ts', status: 'deleted', area: 'unstaged' }]
}
})
probes.visible.add(owner!.id)
const openDiff = vi.spyOn(useAppStore.getState(), 'openDiff').mockImplementation(() => {})
const openFile = vi.spyOn(useAppStore.getState(), 'openFile').mockImplementation(() => 'unused')
await render(<FolderWorkspaceSourceControlPanel />)
const row = container.querySelector('[data-testid="source-control-entry"]')!
await act(async () =>
row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, button: 2 }))
)
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((node) =>
node.textContent?.includes('Open in File Explorer')
)
expect(item).toBeTruthy()
await act(async () => item!.click())
expect(openDiff).toHaveBeenCalledWith(
owner!.id,
'/child-0/deleted.ts',
'deleted.ts',
'typescript',
false,
expect.objectContaining({ placementWorkspaceId: 'folder:feature' })
)
expect(openFile).not.toHaveBeenCalled()
})
it('split-opens child content in the current placement group after a focus change', async () => {
const [owner] = setup(1)
probes.visible.add(owner!.id)
const createSplit = vi.fn(() => 'new-split')
const openDiff = vi.fn()
useAppStore.setState({ createEmptySplitGroup: createSplit, openDiff })
await render(<FolderWorkspaceSourceControlPanel />)
await act(async () =>
useAppStore.setState({
activeGroupIdByWorktree: { 'folder:feature': 'current-group' },
groupsByWorktree: {
'folder:feature': [
{ id: 'current-group', worktreeId: 'folder:feature', tabOrder: [], activeTabId: null }
]
}
})
)
const row = container.querySelector('[data-testid="source-control-entry"]')!
await act(async () => row.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: true })))
expect(createSplit).toHaveBeenCalledWith('folder:feature', 'current-group', 'right')
expect(openDiff).toHaveBeenCalledWith(
owner!.id,
'/child-0/a.ts',
'a.ts',
'typescript',
false,
expect.objectContaining({
placementWorkspaceId: 'folder:feature',
targetGroupId: 'new-split',
preview: false
})
)
})
it('keeps an owned base-ref dialog usable after its section leaves the viewport and collapses', async () => {
const worktrees = setup(3)
probes.visible.add(worktrees[0]!.id)
await render(<FolderWorkspaceSourceControlPanel />)
const changeBase = screen.getByRole('button', { name: /Change base ref/ })
await act(async () => fireEvent.click(changeBase))
await scrollTo([])
const section = container.querySelector('section')!
const collapse = section.querySelector<HTMLButtonElement>('button[aria-expanded="true"]')!
await act(async () => fireEvent.click(collapse))
await act(async () => {
fireEvent.change(screen.getByPlaceholderText('Search branches by name...'), {
target: { value: 'feature' }
})
})
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
expect(screen.getByRole('button', { name: 'feature/live' })).toBeTruthy()
})
@@ -0,0 +1,380 @@
// @vitest-environment happy-dom
import { act } from 'react'
import type { Worktree } from '../../../../shared/worktree/types'
import type * as GitIdentityDisplay from '../../../../shared/worktree/git-identity-display'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { TooltipProvider } from '@/components/ui/tooltip'
import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel'
import { useSourceControlWatchConsumers } from '@/hooks/source-control-watch-consumers'
import {
useSourceControlScope,
type SourceControlScope
} from './source-control/panel/worktree-scope'
import { useWorktreeGitStatusObservation } from './source-control/sync/use-worktree-git-status-observation'
import { ORCA_WORKTREE_FILE_CHANGE_EVENT } from '@/hooks/worktree-file-change-event'
const probes = vi.hoisted(() => ({
status: vi.fn(async (_request: { worktreeId: string }) => {}),
body: vi.fn(),
header: vi.fn(),
watches: new Array<string>()
}))
vi.mock('./source-control/sync/git-status-refresh', () => ({
refreshGitStatusForWorktree: probes.status
}))
vi.mock('./source-control/sync/use-git-status-upstream-ref-watch', () => ({
useGitStatusUpstreamRefWatch: () => undefined
}))
vi.mock('./source-control/sync/use-refresh-on-host-contact', () => ({
useRefreshOnHostContact: () => {}
}))
vi.mock('@/lib/worktree-git-copy', () => ({
resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({
worktreeId: id,
hostId,
transport: { kind: 'local' }
})
}))
vi.mock('../../../../shared/worktree/git-identity-display', async (original) => {
const actual = await original<typeof GitIdentityDisplay>()
return {
...actual,
getWorktreeGitIdentityDisplay: (
...args: Parameters<typeof actual.getWorktreeGitIdentityDisplay>
) => {
probes.header(args[0].branch)
return actual.getWorktreeGitIdentityDisplay(...args)
}
}
})
vi.mock('./source-control/panel/panel', () => ({
SourceControlPanel: () => {
const scope = useSourceControlScope()
probes.body(scope?.worktreeId)
return <div data-commit-owner={scope?.worktreeId} />
}
}))
// Unlike happy-dom, report every observed section as on screen.
class OnScreenIntersectionObserver {
private readonly callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
}
observe(target: Element): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the section hook reads only target and isIntersecting.
this.callback([{ target, isIntersecting: true } as IntersectionObserverEntry], this as never)
}
unobserve(): void {}
disconnect(): void {}
}
let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal('IntersectionObserver', OnScreenIntersectionObserver)
vi.useFakeTimers()
useAppStore.setState(useAppStore.getInitialState(), true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
probes.status.mockClear()
probes.body.mockClear()
probes.header.mockClear()
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function Watches() {
probes.watches = useSourceControlWatchConsumers().map((target) => target.worktreeId)
return null
}
function setup(count: number) {
const worktrees: Worktree[] = Array.from({ length: count }, (_, i) => ({
id: `repo::/child-${i}`,
repoId: 'repo',
path: `/child-${i}`,
branch: `feature-${i}`,
head: 'abc123',
isBare: false,
isMainWorktree: false,
displayName: `Child ${i}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: i,
lastActivityAt: 0,
hostId: 'local' as const,
instanceId: `instance-${i}`
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies the catalog and status fields consumed by the folder panel.
useAppStore.setState({
activeWorktreeId: 'folder:feature',
activeWorkspaceKey: 'folder:feature',
activeWorkspaceExecutionHostId: 'local',
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
repos: [{ id: 'repo', displayName: 'Child', path: '/repo', kind: 'git' }],
worktreesByRepo: { repo: worktrees },
folderWorkspaces: [
{ id: 'feature', name: 'Feature', folderPath: '/folder', projectGroupId: 'group' }
],
workspaceLineageByChildKey: Object.fromEntries(
worktrees.map((row, i) => [
row.id,
{
childWorkspaceKey: `worktree:${row.id}`,
childInstanceId: row.instanceId,
parentWorkspaceKey: 'folder:feature',
parentInstanceId: null,
createdAt: i
}
])
),
gitStatusByWorktree: Object.fromEntries(worktrees.map((row) => [row.id, []]))
} as unknown as Partial<AppState>)
return worktrees
}
async function render(visible = true) {
await act(async () =>
root.render(
<TooltipProvider>
<Watches />
{visible && <FolderWorkspaceSourceControlPanel />}
</TooltipProvider>
)
)
}
it.each([3, 30])(
'bounds observation, watches and unrelated renders for %i attached worktrees',
async (count) => {
const worktrees = setup(count)
await render()
// One status per visible repository is necessary for its badge; collapsed bodies do no compare work.
expect(probes.status).toHaveBeenCalledTimes(count)
expect(probes.body).not.toHaveBeenCalled()
// Every mounted section watches its tree, collapsed ones included (R4).
expect(probes.watches).toEqual(worktrees.map((row) => row.id))
probes.header.mockClear()
await act(async () => useAppStore.setState({ activeFileId: 'unrelated-file' }))
expect(probes.header).not.toHaveBeenCalled()
await act(async () =>
useAppStore.setState({
sshConnectionStates: new Map([
[
'other-host',
{ targetId: 'other-host', status: 'connected', error: null, reconnectAttempt: 0 }
]
])
})
)
expect(probes.header).not.toHaveBeenCalled()
await act(async () =>
useAppStore.setState((state) => ({
gitStatusByWorktree: {
...state.gitStatusByWorktree,
[worktrees[0]!.id]: [{ path: 'a.ts', status: 'modified', area: 'unstaged' }]
}
}))
)
expect(probes.header.mock.calls).toEqual([['feature-0']])
expect(probes.body).not.toHaveBeenCalled()
// Agent changes wake the matching collapsed badge, using the existing scheduler's coalescing.
probes.status.mockClear()
await act(async () => {
window.dispatchEvent(
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: {
runtimeEnvironmentId: null,
payload: {
worktreePath: worktrees[0]!.path,
events: [{ absolutePath: `${worktrees[0]!.path}/a.ts`, kind: 'change' }]
}
}
})
)
await vi.advanceTimersByTimeAsync(4000)
})
expect(probes.status).toHaveBeenCalledTimes(1)
expect(probes.status.mock.calls[0]?.[0]?.worktreeId).toBe(worktrees[0]!.id)
const toggles = container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')
for (const toggle of toggles) {
await act(async () => toggle.click())
}
expect(probes.body).toHaveBeenCalledTimes(count)
expect(probes.watches).toEqual(worktrees.map((row) => row.id))
await render(false)
probes.status.mockClear()
await act(async () => {
await vi.advanceTimersByTimeAsync(120000)
})
expect(probes.status).not.toHaveBeenCalled()
expect(probes.watches).toEqual([])
}
)
function ActiveStatusObserver() {
useWorktreeGitStatusObservation(null)
return null
}
function ScopedStatusObserver({ owner }: { owner: SourceControlScope }) {
useWorktreeGitStatusObservation(owner, { enabled: true })
return null
}
it('filters file events by the section runtime host when a same-id local twin exists', async () => {
const [local] = setup(1)
const remote = { ...local!, hostId: 'runtime:paired' as const, instanceId: 'remote-instance' }
const repo = useAppStore.getState().repos[0]!
const owner: SourceControlScope = {
worktreeId: remote.id,
repoId: remote.repoId,
executionHostId: 'runtime:paired',
worktree: remote,
repo,
attachedAt: 0,
placementWorkspaceId: 'folder:feature'
}
useAppStore.setState({ worktreesByRepo: { repo: [local!, remote] } })
await act(async () => root.render(<ScopedStatusObserver owner={owner} />))
expect(probes.status).toHaveBeenCalledTimes(1)
probes.status.mockClear()
for (const runtimeEnvironmentId of [null, 'other-runtime', 'paired']) {
await act(async () => {
window.dispatchEvent(
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: {
runtimeEnvironmentId,
payload: {
worktreePath: remote.path,
events: [{ absolutePath: `${remote.path}/a.ts`, kind: 'change' }]
}
}
})
)
await vi.advanceTimersByTimeAsync(4000)
})
expect(probes.status).toHaveBeenCalledTimes(runtimeEnvironmentId === 'paired' ? 1 : 0)
}
})
it('refreshes an open diff after a file event with the sidebar closed', async () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mac')
const [worktree] = setup(1)
const path = '/Users/test/Library/Containers/app/worktree'
useAppStore.setState({
activeWorktreeId: worktree!.id,
activeWorkspaceKey: `worktree:${worktree!.id}`,
worktreesByRepo: { repo: [{ ...worktree!, path }] },
rightSidebarOpen: false,
openFiles: [
{
id: 'open-diff',
filePath: `${path}/a.ts`,
relativePath: 'a.ts',
worktreeId: worktree!.id,
language: 'typescript',
isDirty: false,
mode: 'diff'
}
]
})
await act(async () => root.render(<ActiveStatusObserver />))
expect(probes.status).toHaveBeenCalledTimes(1)
probes.status.mockClear()
await act(async () => {
window.dispatchEvent(
new CustomEvent(ORCA_WORKTREE_FILE_CHANGE_EVENT, {
detail: {
runtimeEnvironmentId: null,
payload: {
worktreePath: path,
events: [{ absolutePath: `${path}/a.ts`, kind: 'change' }]
}
}
})
)
await vi.advanceTimersByTimeAsync(4000)
})
expect(probes.status).toHaveBeenCalledTimes(1)
expect(probes.status.mock.calls[0]?.[0]?.worktreeId).toBe(worktree!.id)
})
it('includes a paired-runtime descendant of a local attached worktree as a section', async () => {
const [parent] = setup(1)
const remote = {
...parent!,
id: 'remote-repo::/remote-child',
repoId: 'remote-repo',
path: '/remote-child',
displayName: 'Remote child',
hostId: 'runtime:paired' as const,
runtimeOwnerEnvironmentId: 'paired',
instanceId: 'remote-instance'
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared lineage selector reads only the status descriptor's installation identity.
useAppStore.setState({
repos: [
...useAppStore.getState().repos,
{
id: 'remote-repo',
displayName: 'Remote',
path: '/remote',
kind: 'git',
executionHostId: 'runtime:paired'
}
],
worktreesByRepo: { repo: [parent!], 'remote-repo': [remote] },
ownLineageInstallationId: 'local-install',
runtimeStatusByEnvironmentId: new Map([
[
'paired',
{
status: { hostDescriptor: { installationId: 'remote-install' } },
checkedAt: 1
}
]
]),
crossStoreLineageByStoreKey: {
paired: {
[`worktree:${remote.id}`]: {
childWorkspaceKey: `worktree:${remote.id}`,
childInstanceId: 'remote-instance',
parent: {
installationId: 'local-install',
workspaceKey: `worktree:${parent!.id}`,
instanceId: 'instance-0'
},
origin: 'manual',
capture: { source: 'explicit-ui', confidence: 'explicit' },
createdAt: 2
}
}
},
gitStatusByWorktree: { [parent!.id]: [], [remote.id]: [] }
} as unknown as Partial<AppState>)
await render()
expect(
[...container.querySelectorAll('section')].map((section) =>
section.getAttribute('data-source-control-worktree')
)
).toEqual([parent!.id, 'remote-repo::/remote-child'])
expect(probes.status.mock.calls.map(([request]) => request.worktreeId)).toEqual([
parent!.id,
'remote-repo::/remote-child'
])
})
@@ -0,0 +1,320 @@
import {
selectCrossStoreFolderChildrenById,
selectSidebarLineageParentIdentities
} from '@/store/cross-store-lineage-parents'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowDown, ArrowUp, ArrowUpRight, ChevronRight, GitBranch, RefreshCw } from 'lucide-react'
import { useAppStore } from '@/store'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import {
getAttachedWorktreesForFolderWorkspace,
attachedWorktreeOwnerKey
} from './folder-workspace-attached-worktrees'
import { resolveReviewRepository } from '@/lib/review-owner'
import { translate } from '@/i18n/i18n'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import { OverlayActivityOwner } from '@/components/ui/use-overlay-focus-scope'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getWorktreeGitIdentityDisplay } from '../../../../shared/worktree/git-identity-display'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { SourceControlPanel } from './source-control/panel/panel'
import {
SourceControlScopeProvider,
type SourceControlScope
} from './source-control/panel/worktree-scope'
import { useWorktreeGitStatusObservation } from './source-control/sync/useGitStatusPolling'
import { useSourceControlWatchConsumer } from '@/hooks/source-control-watch-consumers'
import { useSectionOnScreen } from './source-control/panel/section-viewport-presence'
import { sourceControlFileWatchRuntimeEnvironmentId } from './source-control/sync/worktree-git-owner-target'
const EMPTY_ENTRIES: readonly unknown[] = []
type SectionProps = {
owner: SourceControlScope
scrollRoot: Element | null
refreshers: Set<() => void>
}
const WorktreeSection = memo(
function WorktreeSection({ owner, scrollRoot, refreshers }: SectionProps) {
useUiLanguageRevision()
const id = owner.worktreeId
const entries = useAppStore((state) => state.gitStatusByWorktree[id] ?? EMPTY_ENTRIES)
const statusKnown = useAppStore((state) => Object.hasOwn(state.gitStatusByWorktree, id))
const remote = useAppStore((state) => state.remoteStatusesByWorktree[id])
const error = useAppStore((state) => state.gitStatusReadErrorByWorktree[id])
const huge = useAppStore((state) => state.gitStatusHugeByWorktree[id])
const connected = useAppStore(
(state) =>
!owner.repo.connectionId ||
state.sshConnectionStates.get(owner.repo.connectionId)?.status === 'connected'
)
const [expanded, setExpanded] = useState<boolean | undefined>()
const isExpanded = expanded ?? (statusKnown ? entries.length > 0 : true)
// Decide once at the first result; later agent edits update badges without undoing a user's collapse.
if (expanded === undefined && statusKnown) {
setExpanded(entries.length > 0)
}
const refreshNow = useWorktreeGitStatusObservation(owner, { enabled: true })
useEffect(() => {
refreshers.add(refreshNow)
return () => {
refreshers.delete(refreshNow)
}
}, [refreshNow, refreshers])
const [sectionRef, onScreen] = useSectionOnScreen(scrollRoot)
const bodyShown =
isExpanded && connected && (statusKnown || error !== undefined || isFolderRepo(owner.repo))
// Open overlays keep their section live until they close, including while off screen.
const [openOverlays, setOpenOverlays] = useState(() => new Set<HTMLElement>())
const onOverlayActivity = useCallback((overlay: HTMLElement, open: boolean) => {
setOpenOverlays((current) => {
if (current.has(overlay) === open) {
return current
}
const next = new Set(current)
if (open) {
next.add(overlay)
} else {
next.delete(overlay)
}
return next
})
}, [])
const bodyActive = (bodyShown && onScreen) || openOverlays.size > 0
// Mount on first sight, then keep the body so selection, history view and focus survive scrolling and collapse.
const [bodyMounted, setBodyMounted] = useState(false)
if (bodyActive && !bodyMounted) {
setBodyMounted(true)
}
const watchRuntimeEnvironmentId = useAppStore((state) =>
sourceControlFileWatchRuntimeEnvironmentId(state, owner)
)
// Only local or served-runtime roots have the existing watch transport; huge repos keep their pause.
useSourceControlWatchConsumer(
attachedWorktreeOwnerKey(owner),
connected && !huge && !isFolderRepo(owner.repo) && watchRuntimeEnvironmentId !== undefined
? {
worktreeId: id,
worktreePath: owner.worktree.path,
connectionId: owner.repo.connectionId ?? undefined,
runtimeEnvironmentId: watchRuntimeEnvironmentId
}
: null
)
const panel = useMemo(() => <SourceControlPanel />, [])
const identity = getWorktreeGitIdentityDisplay(owner.worktree)
return (
<section
ref={sectionRef}
className="border-b border-border"
data-source-control-worktree={id}
>
<div className="flex items-center gap-1 pr-1">
<button
type="button"
aria-expanded={isExpanded}
onClick={() => setExpanded(!isExpanded)}
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-2 text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<ChevronRight className={`size-3 shrink-0 ${isExpanded ? 'rotate-90' : ''}`} />
<span className="truncate font-medium">{owner.repo.displayName}</span>
<span className="truncate text-muted-foreground">
{owner.worktree.displayName ?? owner.worktree.branch}
</span>
<GitBranch className="ml-auto size-3 shrink-0 text-muted-foreground" />
<span className="truncate text-muted-foreground">
{identity?.kind === 'branch' ? identity.branchName : owner.worktree.branch}
</span>
{!!remote?.ahead && (
<span className="flex items-center text-muted-foreground">
<ArrowUp className="size-3" />
{remote.ahead}
</span>
)}
{!!remote?.behind && (
<span className="flex items-center text-muted-foreground">
<ArrowDown className="size-3" />
{remote.behind}
</span>
)}
{entries.length > 0 && (
<span className="rounded-full bg-muted px-1.5 text-2xs">{entries.length}</span>
)}
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={() => useAppStore.getState().setActiveWorktree(id, owner.executionHostId)}
aria-label={translate(
'auto.components.rightSidebar.FolderWorkspaceSourceControl.openWorktree',
'Open this worktree'
)}
>
<ArrowUpRight className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate(
'auto.components.rightSidebar.FolderWorkspaceSourceControl.openWorktree',
'Open this worktree'
)}
</TooltipContent>
</Tooltip>
</div>
{isExpanded &&
(!connected ? (
<div className="px-4 pb-2 text-xs text-muted-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspaceSourceControl.reconnect',
'Reconnect to see this worktree’s changes.'
)}
</div>
) : !bodyShown ? (
<div className="h-24 space-y-2 p-3" aria-busy="true">
<div className="h-2 w-3/4 rounded bg-muted" />
<div className="h-2 w-1/2 rounded bg-muted" />
</div>
) : (
!bodyMounted && <div className="h-24" aria-hidden="true" />
))}
{bodyMounted && (
// A read error renders the body's existing failure banner with Retry.
<div hidden={!bodyShown}>
<OverlayActivityOwner.Provider value={onOverlayActivity}>
<SourceControlScopeProvider owner={owner} active={bodyActive}>
{panel}
</SourceControlScopeProvider>
</OverlayActivityOwner.Provider>
</div>
)}
</section>
)
},
(left, right) =>
left.scrollRoot === right.scrollRoot &&
left.refreshers === right.refreshers &&
left.owner.worktree === right.owner.worktree &&
left.owner.repo === right.owner.repo &&
left.owner.placementWorkspaceId === right.owner.placementWorkspaceId
)
export default function FolderWorkspaceSourceControlPanel() {
const activeWorkspaceKey = useAppStore((state) => state.activeWorkspaceKey)
const activeWorktreeId = useAppStore((state) => state.activeWorktreeId)
const executionHostId = useAppStore((state) => state.activeWorkspaceExecutionHostId)
const folderWorkspaces = useAppStore(selectFolderWorkspaces)
const worktreesByRepo = useAppStore(selectWorktreesByRepo)
const workspaceLineageByChildKey = useAppStore((state) => state.workspaceLineageByChildKey)
const worktreeLineageById = useAppStore((state) => state.worktreeLineageById)
const lineageParentIdentities = useAppStore(selectSidebarLineageParentIdentities)
const crossStoreChildrenByFolderId = useAppStore(selectCrossStoreFolderChildrenById)
const repos = useAppStore((state) => state.repos)
const resolution = useMemo(
() =>
getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey,
activeWorktreeId,
executionHostId,
folderWorkspaces,
worktreesByRepo,
workspaceLineageByChildKey,
worktreeLineageById,
lineageParentIdentities,
crossStoreChildrenByFolderId
}),
[
activeWorkspaceKey,
activeWorktreeId,
executionHostId,
folderWorkspaces,
worktreesByRepo,
workspaceLineageByChildKey,
worktreeLineageById,
lineageParentIdentities,
crossStoreChildrenByFolderId
]
)
const owners = useMemo(
() =>
resolution.owners.flatMap((owner) => {
const repo = resolveReviewRepository(repos, owner.worktree)
return repo && resolution.folderWorkspace
? [{ ...owner, repo, placementWorkspaceId: `folder:${resolution.folderWorkspace.id}` }]
: []
}),
[resolution, repos]
)
const [scrollRoot, setScrollRoot] = useState<HTMLDivElement | null>(null)
const [refreshers] = useState(() => new Set<() => void>())
// Each section's scheduler applies the status tier, coalescing, abort and stale-result guards.
const refresh = () => {
for (const refreshNow of refreshers) {
refreshNow()
}
}
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{resolution.folderWorkspace?.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.attachedCount',
'{{count}} attached worktrees',
{ count: owners.length, defaultValue_one: '{{count}} attached worktree' }
)}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={refresh}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.refreshAll',
'Refresh all'
)}
>
<RefreshCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.right.sidebar.SourceControl.refreshAll', 'Refresh all')}
</TooltipContent>
</Tooltip>
</div>
{owners.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<div className="text-sm font-medium">
{translate(
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyTitle',
'No attached worktrees yet'
)}
</div>
<div className="mt-2 max-w-64 text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.rightSidebar.FolderWorkspaceWorktreesPanel.emptyCopy',
'Worktrees created from this workspace will show up here.'
)}
</div>
</div>
) : (
<div ref={setScrollRoot} className="scrollbar-sleek min-h-0 flex-1 overflow-y-auto">
{owners.map((owner) => (
<WorktreeSection
key={attachedWorktreeOwnerKey(owner)}
owner={owner}
scrollRoot={scrollRoot}
refreshers={refreshers}
/>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,341 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { Worktree } from '../../../../shared/worktree/types'
import type { FsChangedPayload } from '../../../../shared/filesystem-entry-types'
import type * as WorktreeGitCopy from '@/lib/worktree-git-copy'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useEditorExternalWatch } from '@/hooks/useEditorExternalWatch'
import FolderWorkspaceSourceControlPanel from './FolderWorkspaceSourceControlPanel'
const probes = vi.hoisted(() => ({
status: vi.fn(async (_request: { worktreeId: string }) => {})
}))
vi.mock('./source-control/sync/git-status-refresh', () => ({
refreshGitStatusForWorktree: probes.status
}))
vi.mock('./source-control/sync/use-git-status-upstream-ref-watch', () => ({
useGitStatusUpstreamRefWatch: () => undefined
}))
vi.mock('./source-control/sync/use-refresh-on-host-contact', () => ({
useRefreshOnHostContact: () => {}
}))
vi.mock('@/lib/worktree-git-copy', async (original) => ({
...(await original<typeof WorktreeGitCopy>()),
resolveWorktreeGitCopyNow: (id: string, hostId: string) => ({
worktreeId: id,
hostId,
transport: { kind: 'local' }
})
}))
// Body work is covered elsewhere; this contract is about header-tier watch coverage.
vi.mock('./source-control/panel/panel', () => ({ SourceControlPanel: () => null }))
type RuntimeStream = {
args: { selector: string; method: string; params: { worktree: string } }
callbacks: { onResponse: (response: unknown) => void }
unsubscribe: ReturnType<typeof vi.fn>
}
const fsListeners = new Set<(payload: FsChangedPayload) => void>()
let runtimeStreams: RuntimeStream[] = []
let watchWorktree: ReturnType<typeof vi.fn>
let unwatchWorktree: ReturnType<typeof vi.fn>
let root: Root
let container: HTMLDivElement
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.useFakeTimers()
fsListeners.clear()
runtimeStreams = []
watchWorktree = vi.fn(async () => {})
unwatchWorktree = vi.fn(async () => {})
vi.stubGlobal('api', {
fs: {
watchWorktree,
unwatchWorktree,
onFsChanged: (listener: (payload: FsChangedPayload) => void) => {
fsListeners.add(listener)
return () => fsListeners.delete(listener)
}
},
runtimeEnvironments: {
subscribe: async (
args: RuntimeStream['args'],
callbacks: RuntimeStream['callbacks']
): Promise<{ unsubscribe: () => void }> => {
const stream = { args, callbacks, unsubscribe: vi.fn() }
runtimeStreams.push(stream)
return { unsubscribe: stream.unsubscribe }
},
call: async () => ({ ok: true, result: {} })
},
worktrees: {
onChanged: () => () => {},
onGitStatusMetadataChanged: () => () => {}
}
})
useAppStore.setState(useAppStore.getInitialState(), true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
probes.status.mockClear()
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
function worktreeRow(repoId: string, path: string, index: number, extra: Partial<Worktree> = {}) {
return {
id: `${repoId}::${path}`,
repoId,
path,
branch: `refs/heads/feature-${index}`,
head: 'abc123',
isBare: false,
isMainWorktree: false,
displayName: `Child ${index}`,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: index,
lastActivityAt: 0,
hostId: 'local' as const,
instanceId: `instance-${repoId}-${index}`,
...extra
} satisfies Worktree
}
function setup(state: {
children: Worktree[]
others?: Worktree[]
openFiles?: AppState['openFiles']
lastVisitedAtByWorktreeId?: Record<string, number>
changed?: ReadonlySet<string>
}) {
const byRepo: Record<string, Worktree[]> = {}
for (const row of [...state.children, ...(state.others ?? [])]) {
;(byRepo[row.repoId] ??= []).push(row)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture supplies the catalog, lineage and status fields the panel and watch owner read.
useAppStore.setState({
activeWorktreeId: 'folder:feature',
activeWorkspaceKey: 'folder:feature',
activeWorkspaceExecutionHostId: 'local',
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
repos: [
{ id: 'repo', displayName: 'Child', path: '/repo', kind: 'git' },
{ id: 'other', displayName: 'Other', path: '/other', kind: 'git' },
{
id: 'rrepo',
displayName: 'Remote',
path: '/remote',
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
sshConnectionStates: new Map([
[
'nested-ssh',
{ targetId: 'nested-ssh', status: 'connected', error: null, reconnectAttempt: 0 }
]
]),
worktreesByRepo: byRepo,
folderWorkspaces: [
{ id: 'feature', name: 'Feature', folderPath: '/folder', projectGroupId: 'group' }
],
workspaceLineageByChildKey: Object.fromEntries(
state.children.map((row, i) => [
row.id,
{
childWorkspaceKey: `worktree:${row.id}`,
childInstanceId: row.instanceId,
parentWorkspaceKey: 'folder:feature',
parentInstanceId: null,
createdAt: i
}
])
),
// Unchanged sections start collapsed; changed ones expand.
gitStatusByWorktree: Object.fromEntries(
state.children.map((row) => [
row.id,
state.changed?.has(row.id) ? [{ path: 'a.ts', status: 'modified', area: 'unstaged' }] : []
])
),
openFiles: state.openFiles ?? [],
lastVisitedAtByWorktreeId: state.lastVisitedAtByWorktreeId ?? {}
} as unknown as Partial<AppState>)
}
function WatchOwner() {
useEditorExternalWatch()
return null
}
async function render(panel = true) {
await act(async () =>
root.render(
<TooltipProvider>
<WatchOwner />
{panel && <FolderWorkspaceSourceControlPanel />}
</TooltipProvider>
)
)
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
}
async function settleAfter(send: () => void) {
probes.status.mockClear()
await act(async () => {
send()
// Debounce plus the existing 3 s activity floor, well inside the 60 s safety poll.
await vi.advanceTimersByTimeAsync(4000)
})
return probes.status.mock.calls.map(([request]) => request.worktreeId)
}
const watchedPaths = (calls: { worktreePath: string }[][]) =>
calls.map(([args]) => args!.worktreePath)
it('wakes only the owning collapsed or expanded section from file events beyond the former three watches', async () => {
const children = Array.from({ length: 30 }, (_, i) => worktreeRow('repo', `/child-${i}`, i))
setup({ children, changed: new Set([children[5]!.id]) })
await render()
// Every mounted local section holds a recursive root, collapsed and off-screen ones included.
expect(watchedPaths(watchWorktree.mock.calls).sort()).toEqual(
children.map((row) => row.path).sort()
)
const local = (path: string, kind: 'create' | 'update' | 'delete', file: string) => () => {
for (const listener of fsListeners) {
listener({ worktreePath: path, events: [{ kind, absolutePath: `${path}/${file}` }] })
}
}
for (const target of [children[29]!, children[5]!]) {
expect(await settleAfter(local(target.path, 'update', 'src/deep/tracked.ts'))).toEqual([
target.id
])
expect(await settleAfter(local(target.path, 'create', 'untracked.txt'))).toEqual([target.id])
expect(await settleAfter(local(target.path, 'delete', 'untracked.txt'))).toEqual([target.id])
}
})
it.each(['explicit', 'repository'] as const)(
'watches an SSH section with %s runtime ownership through files.watch',
async (ownership) => {
const runtimeChild = worktreeRow(
'rrepo',
'/remote/child',
0,
ownership === 'explicit'
? { hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' }
: { hostId: undefined }
)
setup({ children: [runtimeChild] })
useAppStore.setState({
settings: {
...useAppStore.getState().settings!,
activeRuntimeEnvironmentId: 'selected-elsewhere'
}
})
await render()
expect(watchWorktree).not.toHaveBeenCalled()
expect(runtimeStreams.map((stream) => [stream.args.selector, stream.args.method])).toEqual([
['env-1', 'files.watch']
])
const [stream] = runtimeStreams
await act(async () =>
stream!.callbacks.onResponse({ ok: true, result: { type: 'ready', subscriptionId: 'sub-1' } })
)
const recipients = await settleAfter(() =>
stream!.callbacks.onResponse({
ok: true,
result: {
type: 'changed',
worktree: runtimeChild.id,
events: [{ kind: 'create', absolutePath: '/remote/child/nested/new.txt' }]
}
})
)
expect(recipients).toEqual([runtimeChild.id])
await render(false)
expect(stream!.unsubscribe).toHaveBeenCalled()
}
)
it('keeps ordinary warm watches, shares roots and releases only section-owned ones', async () => {
const children = Array.from({ length: 30 }, (_, i) => worktreeRow('repo', `/child-${i}`, i))
const others = [1, 2].map((i) => worktreeRow('other', `/other-${i}`, i))
const openFile = (worktree: Worktree) => ({
id: `${worktree.id}::file`,
filePath: `${worktree.path}/file.ts`,
relativePath: 'file.ts',
worktreeId: worktree.id,
language: 'typescript',
isDirty: false,
mode: 'edit' as const
})
setup({
children,
others,
openFiles: [...others, children[0]!].map(openFile),
// The three recently used workspaces: two ordinary ones and one that is also a section.
lastVisitedAtByWorktreeId: Object.fromEntries(
[...others, children[0]!].map((row, i) => [row.id, 100 + i])
)
})
await render()
const watched = watchedPaths(watchWorktree.mock.calls)
// Thirty sections never take the recently used workspaces' slots.
for (const row of others) {
expect(watched).toContain(row.path)
}
// The section and an open file in the same worktree share one root.
expect(watched.filter((path) => path === children[0]!.path)).toHaveLength(1)
expect(unwatchWorktree).not.toHaveBeenCalled()
await render(false)
const released = watchedPaths(unwatchWorktree.mock.calls).sort()
expect(released).toEqual(
children
.slice(1)
.map((row) => row.path)
.sort()
)
})
it.each(['direct', 'nested'] as const)(
'does not claim a watch for unserved %s SSH',
async (route) => {
const child = worktreeRow('rrepo', '/remote/child', 0, { hostId: 'ssh:nested-ssh' })
setup({ children: [child] })
useAppStore.setState({
repos: useAppStore.getState().repos.map((repo) =>
repo.id === 'rrepo'
? {
...repo,
connectionId: 'nested-ssh',
executionHostId: route === 'nested' ? 'runtime:env-1' : 'ssh:nested-ssh'
}
: repo
)
})
await render()
expect(watchWorktree).not.toHaveBeenCalled()
expect(runtimeStreams).toEqual([])
// Scheduling is preserved; unsupported SSH status still reaches the original remote rejection boundary.
expect(probes.status.mock.calls.map(([request]) => request.worktreeId)).toEqual([child.id])
}
)
@@ -1,3 +1,7 @@
import {
selectSidebarLineageParentIdentities,
selectCrossStoreFolderChildrenById
} from '@/store/cross-store-lineage-parents'
import WorktreeCard from '@/components/sidebar/workspace-card/surface/WorktreeCard'
import { stopNestedWorktreeCardBubble } from '@/components/sidebar/worktree-list/rows/header-event-guards'
import {
@@ -6,18 +10,21 @@ import {
} from '@/components/sidebar/worktree-list/rows/indentation'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { resolveReviewRepository } from '@/lib/review-owner'
import type { Worktree } from '../../../../shared/worktree/types'
import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees'
import { useState } from 'react'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { selectCrossStoreFolderChildrenById } from '@/store/cross-store-lineage-parents'
export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
const executionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey)
const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s))
const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey)
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const lineageParentIdentities = useAppStore(selectSidebarLineageParentIdentities)
const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s))
const crossStoreChildrenByFolderId = useAppStore(selectCrossStoreFolderChildrenById)
const repos = useAppStore((s) => s.repos)
@@ -25,13 +32,14 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
ReadonlySet<string>
>(() => new Set())
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
const { folderWorkspace, childWorktrees, lineageChildrenByParentId, rootChildWorktrees } =
getAttachedWorktreesForFolderWorkspace({
executionHostId,
activeWorkspaceKey,
activeWorktreeId,
folderWorkspaces,
workspaceLineageByChildKey,
lineageParentIdentities,
worktreeLineageById,
worktreesByRepo,
crossStoreChildrenByFolderId
@@ -53,16 +61,19 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
worktree: Worktree,
ancestorIds: ReadonlySet<string> = new Set()
): React.JSX.Element => {
const lineageChildren = lineageChildrenByParentId.get(worktree.id) ?? []
const lineageCollapsed = collapsedLineageWorktreeIds.has(worktree.id)
const nextAncestorIds = new Set([...ancestorIds, worktree.id])
const safeLineageChildren = lineageChildren.filter((child) => !nextAncestorIds.has(child.id))
const identity = getWorktreeHostIdentity(worktree)
const lineageChildren = lineageChildrenByParentId.get(identity) ?? []
const lineageCollapsed = collapsedLineageWorktreeIds.has(identity)
const nextAncestorIds = new Set([...ancestorIds, identity])
const safeLineageChildren = lineageChildren.filter(
(child) => !nextAncestorIds.has(getWorktreeHostIdentity(child))
)
const hasSafeLineageChildren = safeLineageChildren.length > 0
return (
<WorktreeCard
key={worktree.id}
key={identity}
worktree={worktree}
repo={repoById.get(worktree.repoId)}
repo={resolveReviewRepository(repos, worktree) ?? undefined}
isActive={activeWorktreeId === worktree.id}
isActiveSurface={false}
hideRepoBadge={false}
@@ -76,7 +87,7 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
!lineageCollapsed && hasSafeLineageChildren
? safeLineageChildren.map((child) => (
<div
key={child.id}
key={getWorktreeHostIdentity(child)}
onClick={stopNestedWorktreeCardBubble}
onDoubleClick={stopNestedWorktreeCardBubble}
onDragStart={stopNestedWorktreeCardBubble}
@@ -96,7 +107,7 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element {
? (event) => {
event.preventDefault()
event.stopPropagation()
toggleLineage(worktree.id)
toggleLineage(identity)
}
: undefined
}
@@ -39,7 +39,6 @@ type ChecksPanelCreateReviewInput = ChecksPanelCreatedReviewInput &
| 'createPrInFlightRef'
| 'createStackedHostedReview'
| 'mountedRef'
| 'ownerSettings'
| 'panelContextKey'
| 'panelContextKeyRef'
| 'setCreatePrError'
@@ -73,7 +72,6 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
hostedReviewCreateCopy,
hostedReviewCreateProvider,
hostedReviewCreation,
ownerSettings,
panelContextKey,
panelContextKeyRef,
prAiGenerationEnabled,
@@ -319,8 +317,7 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders,
generatePullRequestFields: handleGeneratePullRequestFields,
generationKey: activePullRequestGenerationKey,
repo,
settings: ownerSettings
repo
})
return { handlePullRequestCreated, handleCreatePullRequest }
}
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { getFolderWorkspaceWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { resolveSidebarLineageParentIdentities } from '../../../../shared/sidebar-list/worktree-lineage-projection'
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
import type { WorkspaceLineage } from '../../../../shared/worktree/lineage-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
@@ -92,3 +95,166 @@ describe('getAttachedWorktreesForFolderWorkspace', () => {
expect(result.childWorktrees.map((worktree) => worktree.id)).toEqual([visible.id])
})
})
function edge(child: Worktree, parent: Worktree, createdAt: number): WorktreeLineage {
return {
worktreeId: child.id,
worktreeInstanceId: child.instanceId!,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId!,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt
}
}
it('keeps attachment order, descendants and Attached-tab parents on their resolved host and instance', () => {
const root = makeWorktree({
id: 'same-root',
hostId: 'local',
instanceId: 'local-root',
lastActivityAt: 99
})
const twin = makeWorktree({ id: 'same-root', hostId: 'ssh:box', instanceId: 'remote-root' })
const child = makeWorktree({ id: 'same-child', hostId: 'local', instanceId: 'local-child' })
const remoteChild = makeWorktree({
id: 'same-child',
hostId: 'ssh:box',
instanceId: 'remote-child'
})
const remoteGrandchild = makeWorktree({
id: 'grandchild',
hostId: 'ssh:box',
instanceId: 'remote-grandchild'
})
const remoteOnly = makeWorktree({
id: 'remote-only',
hostId: 'ssh:box',
instanceId: 'remote-only-instance'
})
const stale = makeWorktree({ id: 'stale', hostId: 'local', instanceId: 'new-instance' })
const remoteEdge = edge(remoteChild, twin, 40)
Object.assign(remoteChild, { lineage: remoteEdge })
const result = getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey: 'folder:folder-1',
activeWorktreeId: null,
executionHostId: 'local',
folderWorkspaces: [makeFolder()],
workspaceLineageByChildKey: {
remoteOnly: { ...makeWorkspaceLineage(remoteOnly), createdAt: 60 },
first: { ...makeWorkspaceLineage(twin), createdAt: 10 },
second: { ...makeWorkspaceLineage(root), createdAt: 20 },
stale: { ...makeWorkspaceLineage(stale), childInstanceId: 'old-instance' }
},
worktreeLineageById: {
[child.id]: edge(child, root, 30),
[remoteGrandchild.id]: edge(remoteGrandchild, remoteChild, 50)
},
worktreesByRepo: {
remote: [twin, remoteChild, remoteGrandchild, remoteOnly],
local: [root, child, stale]
},
crossStoreChildrenByFolderId: new Map()
})
expect(
result.owners.map((owner) => [
owner.executionHostId,
owner.worktree.instanceId,
owner.attachedAt
])
).toEqual([
['local', 'local-root', 20],
['local', 'local-child', 30],
['ssh:box', 'remote-only-instance', 60]
])
expect(result.lineageChildrenByParentId.get('local|same-root')).toEqual([child])
expect(result.lineageChildrenByParentId.has('ssh:box|same-root')).toBe(false)
expect(result.rootChildWorktrees.map((row) => row.instanceId)).toEqual([
'local-root',
'remote-only-instance'
])
})
it('includes resolved cross-store roots and their descendants without attaching another host copy', () => {
const root = makeWorktree({ id: 'remote-root', hostId: 'ssh:box', instanceId: 'root' })
const twin = makeWorktree({ id: 'remote-root', hostId: 'ssh:other', instanceId: 'twin' })
const child = makeWorktree({ id: 'remote-child', hostId: 'ssh:box', instanceId: 'child' })
const lineage = edge(child, root, 5)
Object.assign(child, { lineage })
const result = getAttachedWorktreesForFolderWorkspace({
activeWorkspaceKey: 'folder:folder-1',
activeWorktreeId: null,
executionHostId: 'local',
folderWorkspaces: [makeFolder()],
workspaceLineageByChildKey: {},
worktreeLineageById: {},
worktreesByRepo: { remote: [root, child], other: [twin] },
lineageParentIdentities: new Map([
['ssh:box|remote-root', 'local|folder:folder-1'],
['ssh:box|remote-child', 'ssh:box|remote-root']
]),
crossStoreChildrenByFolderId: new Map([['folder-1', [root]]])
})
expect(result.owners.map((owner) => owner.worktree.instanceId)).toEqual(['root', 'child'])
expect(result.rootChildWorktrees).toEqual([root])
expect(result.lineageChildrenByParentId.get('ssh:box|remote-root')).toEqual([child])
})
it('attaches cross-store roots only to the resolved host when folder IDs collide', () => {
const localFolder = makeFolder()
const pairedFolder: FolderWorkspace = { ...makeFolder(), executionHostId: 'runtime:paired' }
const root = makeWorktree({ id: 'root', hostId: 'local', instanceId: 'root-instance' })
const child = makeWorktree({ id: 'child', hostId: 'local', instanceId: 'child-instance' })
const worktreeLineageById = { child: edge(child, root, 5) }
const rows = [
root,
child,
...[localFolder, pairedFolder].map((folder) => getFolderWorkspaceWorktree(folder))
]
const lineageParentIdentities = resolveSidebarLineageParentIdentities(
worktreeLineageById,
new Map(rows.map((row) => [getWorktreeHostIdentity(row), row])),
new Map([
[
'',
{
installationId: 'local-installation',
crossStoreLineageByChildKey: {
[worktreeWorkspaceKey(root.id)]: {
childWorkspaceKey: worktreeWorkspaceKey(root.id),
childInstanceId: 'root-instance',
parent: {
installationId: 'paired-installation',
workspaceKey: 'folder:folder-1',
instanceId: 'folder-1'
},
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
}
}
],
['paired', { installationId: 'paired-installation', crossStoreLineageByChildKey: {} }]
])
)
const inputs = {
activeWorkspaceKey: 'folder:folder-1',
activeWorktreeId: null,
folderWorkspaces: [localFolder, pairedFolder],
workspaceLineageByChildKey: {},
worktreeLineageById,
worktreesByRepo: { local: [root, child] },
lineageParentIdentities,
crossStoreChildrenByFolderId: new Map([['folder-1', [root]]])
}
const local = getAttachedWorktreesForFolderWorkspace({ ...inputs, executionHostId: 'local' })
const paired = getAttachedWorktreesForFolderWorkspace({
...inputs,
executionHostId: 'runtime:paired'
})
expect(local.childWorktrees).toEqual([])
expect(paired.owners.map((owner) => owner.worktree.id)).toEqual(['root', 'child'])
})
@@ -1,18 +1,41 @@
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
import { getFolderWorkspaceHostIdentity } from '../../../../shared/folder-workspaces/folder-workspace-worktree'
import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order'
import { getProjectedWorktreeLineageChildrenByParentId } from '../../../../shared/sidebar-list/worktree-lineage-projection'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { getProjectedWorktreeLineage } from '../../../../shared/worktree/workspace-lineage-graph'
import {
getSidebarLineageParentsByIdentity,
type SidebarLineageParentIdentities,
getProjectedWorktreeLineageChildrenByParentId
} from '../../../../shared/sidebar-list/worktree-lineage-projection'
export type AttachedWorktreeResolution = {
folderWorkspace: FolderWorkspace | null
childWorktrees: Worktree[]
owners: AttachedWorktreeOwner[]
lineageChildrenByParentId: Map<string, Worktree[]>
rootChildWorktrees: Worktree[]
}
export type AttachedWorktreeOwner = {
worktreeId: string
repoId: string
executionHostId: ExecutionHostId
worktree: Worktree
attachedAt: number
}
export function attachedWorktreeOwnerKey(owner: AttachedWorktreeOwner): string {
return `${owner.executionHostId}::${owner.worktreeId}::${owner.worktree.instanceId ?? ''}`
}
type AttachedWorktreeResolverArgs = {
executionHostId?: ExecutionHostId | null
lineageParentIdentities?: SidebarLineageParentIdentities
activeWorkspaceKey: string | null
activeWorktreeId: string | null
folderWorkspaces: readonly FolderWorkspace[]
@@ -28,6 +51,8 @@ export function getWorktreeActivityTime(worktree: Worktree): number {
}
export function getAttachedWorktreesForFolderWorkspace({
executionHostId,
lineageParentIdentities,
activeWorkspaceKey,
activeWorktreeId,
folderWorkspaces,
@@ -39,49 +64,144 @@ export function getAttachedWorktreesForFolderWorkspace({
const activeScope = parseWorkspaceKey(activeWorkspaceKey ?? activeWorktreeId ?? '')
const folderWorkspace =
activeScope?.type === 'folder'
? (folderWorkspaces.find((workspace) => workspace.id === activeScope.folderWorkspaceId) ??
null)
? (folderWorkspaces.find(
(workspace) =>
workspace.id === activeScope.folderWorkspaceId &&
(!executionHostId || getRepoExecutionHostId(workspace) === executionHostId)
) ?? null)
: null
if (!folderWorkspace) {
return {
folderWorkspace: null,
childWorktrees: [],
owners: [],
lineageChildrenByParentId: new Map(),
rootChildWorktrees: []
}
}
const folderKey = folderWorkspaceKey(folderWorkspace.id)
const worktreeById = getWorktreeById(worktreesByRepo)
const childWorktrees = Object.values(workspaceLineageByChildKey)
.filter((lineage) => lineage.parentWorkspaceKey === folderKey)
.map((lineage) => getLineageChildWorktree(lineage, worktreeById))
.filter((worktree): worktree is Worktree => worktree !== null)
.concat(crossStoreChildrenByFolderId.get(folderWorkspace.id) ?? [])
.sort(sortWorktreesByRecentActivity)
const childWorktreeIds = new Set(childWorktrees.map((worktree) => worktree.id))
const lineageChildrenByParentId = getLineageChildrenByParentId(
worktreeLineageById,
worktreeById,
childWorktreeIds
const folderHost = getRepoExecutionHostId(folderWorkspace)
const rows = new Map<string, Worktree>(
Object.values(worktreesByRepo).flatMap((copies) =>
copies.map((row) => [getWorktreeHostIdentity(row), row] as const)
)
)
const nestedChildIds = new Set<string>()
for (const children of lineageChildrenByParentId.values()) {
for (const child of children) {
nestedChildIds.add(child.id)
// Status stores are keyed by worktree ID; prefer the folder host, then the legacy selector's first copy.
const selectedById = new Map<string, Worktree>()
for (const row of rows.values()) {
const selected = selectedById.get(row.id)
if (
!selected ||
((selected.hostId ?? 'local') !== folderHost && (row.hostId ?? 'local') === folderHost)
) {
selectedById.set(row.id, row)
}
}
const topLevelChildWorktrees = childWorktrees.filter(
(worktree) => !nestedChildIds.has(worktree.id)
const parentByIdentity = getSidebarLineageParentsByIdentity(
worktreeLineageById,
rows,
lineageParentIdentities
)
const childrenByParent = new Map<string, Worktree[]>()
for (const [identity, parent] of parentByIdentity) {
const child = rows.get(identity)!
if (child.isArchived) {
continue
}
const parentIdentity = getWorktreeHostIdentity(parent)
const children = childrenByParent.get(parentIdentity) ?? []
children.push(child)
childrenByParent.set(parentIdentity, children)
}
const rootLineageByChildId = new Map<string, WorkspaceLineage[]>()
for (const lineage of Object.values(workspaceLineageByChildKey)) {
const child = parseWorkspaceKey(lineage.childWorkspaceKey)
if (lineage.parentWorkspaceKey !== folderKey || child?.type !== 'worktree') {
continue
}
const entries = rootLineageByChildId.get(child.worktreeId) ?? []
entries.push(lineage)
rootLineageByChildId.set(child.worktreeId, entries)
}
const members = new Map<string, AttachedWorktreeOwner>()
const queue: Worktree[] = []
const add = (worktree: Worktree, attachedAt: number) => {
const identity = getWorktreeHostIdentity(worktree)
if (
worktree.isArchived ||
members.has(identity) ||
selectedById.get(worktree.id) !== worktree
) {
return
}
members.set(identity, {
worktreeId: worktree.id,
repoId: worktree.repoId,
executionHostId: worktree.hostId ?? folderHost,
worktree,
attachedAt
})
queue.push(worktree)
}
for (const row of rows.values()) {
const lineage = rootLineageByChildId
.get(row.id)
?.find((edge) =>
edge.childInstanceId
? edge.childInstanceId === row.instanceId
: (row.hostId ?? folderHost) === folderHost
)
if (lineage) {
add(row, lineage.createdAt)
}
}
const folderIdentity = getFolderWorkspaceHostIdentity(folderWorkspace)
for (const child of crossStoreChildrenByFolderId.get(folderWorkspace.id) ?? []) {
if (lineageParentIdentities?.get(getWorktreeHostIdentity(child)) !== folderIdentity) {
continue
}
add(
child,
getProjectedWorktreeLineage(child, worktreeLineageById)?.createdAt ?? child.createdAt ?? 0
)
}
for (let i = 0; i < queue.length; i++) {
for (const child of childrenByParent.get(getWorktreeHostIdentity(queue[i])) ?? []) {
const lineage = getProjectedWorktreeLineage(child, worktreeLineageById)
add(child, lineage?.createdAt ?? child.createdAt ?? 0)
}
}
const lineageChildrenByParentId = new Map<string, Worktree[]>()
const nested = new Set<string>()
for (const identity of members.keys()) {
const children = (childrenByParent.get(identity) ?? []).filter((child) =>
members.has(getWorktreeHostIdentity(child))
)
if (children.length > 0) {
children.sort(sortWorktreesByRecentActivity)
lineageChildrenByParentId.set(identity, children)
for (const child of children) {
nested.add(getWorktreeHostIdentity(child))
}
}
}
const childWorktrees = [...members.values()].map((owner) => owner.worktree)
childWorktrees.sort(sortWorktreesByRecentActivity)
const rootChildWorktrees = childWorktrees.filter(
(row) => !nested.has(getWorktreeHostIdentity(row))
)
const owners = [...members.values()].sort(
(left, right) =>
left.attachedAt - right.attachedAt ||
attachedWorktreeOwnerKey(left).localeCompare(attachedWorktreeOwnerKey(right))
)
const rootChildWorktrees =
topLevelChildWorktrees.length > 0 ? topLevelChildWorktrees : childWorktrees
return {
folderWorkspace,
childWorktrees,
owners,
lineageChildrenByParentId,
rootChildWorktrees
}
@@ -125,16 +245,6 @@ export function getLineageChildrenByParentId(
return descendantsByParentId
}
function getWorktreeById(
worktreesByRepo: Record<string, readonly Worktree[]>
): Map<string, Worktree> {
return new Map(
Object.values(worktreesByRepo)
.flat()
.map((worktree) => [worktree.id, worktree])
)
}
/** Exported so the composer's parent picker offers exactly the roots this view attaches. */
export function getLineageChildWorktree(
lineage: WorkspaceLineage,
@@ -1,5 +1,5 @@
import type { WorkspaceAttachmentMetadata } from '../../../../../../shared/workspace-attachments/workspace-attachment-legacy'
import type { AppState } from '@/store/index'
import type { SourceControlAiSettingsInput } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import type {
RuntimeGeneratePullRequestFieldsOverrides,
@@ -34,7 +34,7 @@ export type UseCreatePullRequestDialogFieldsOptions = {
currentBaseRef?: string | null
repo?: Pick<Repo, 'sourceControlAi'> | null
/** Generation preferences; routing comes from `target`. */
settings: AppState['settings']
settings: SourceControlAiSettingsInput | null
/** The worktree owner's transport. */
target: RuntimeClientTarget | null
submitting: boolean
@@ -1,6 +1,7 @@
import { useAppStateViewActive, useAppStateValue } from '@/store/app-state-view'
import { useSourceControlScope } from '../../source-control/panel/worktree-scope'
import { translate } from '@/i18n/i18n'
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { useAppStore } from '@/store/index'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
import {
@@ -33,19 +34,24 @@ export function useCreatePullRequestBaseRefDiscovery({
setBaseSearchPending,
setBaseSearchError
}: CreatePullRequestBaseRefDiscoveryOptions): string | null {
const viewActive = useAppStateViewActive()
const scope = useSourceControlScope()
// Stamp the repo so an old default cannot suppress stacked-PR lookup after navigation.
const [repoDefault, setRepoDefault] = useState<{ repoId: string; baseRef: string } | null>(null)
const repoDefaultBaseRef = repoDefault?.repoId === repoId ? repoDefault.baseRef : null
// Refs belong to the repo owner, which may differ from the focused host.
const repoHostId = useAppStore((s) => {
const repo = findRepoForHost(s.repos, repoId, { settings: s.settings })
const repoHostId = useAppStateValue((s) => {
const repo = findRepoForHost(s.repos, repoId, {
settings: s.settings,
hostId: scope?.executionHostId
})
return repo ? getRepoExecutionHostId(repo) : null
})
// Resolve the repo default separately; eligibility’s defaultBaseRef can be the worktree’s own base.
useEffect(() => {
// Cache the stable repo default to avoid an RPC on every composer opening.
if (!open || repoDefaultBaseRef || !repoHostId) {
if (!viewActive || !open || repoDefaultBaseRef || !repoHostId) {
return
}
let stale = false
@@ -59,17 +65,17 @@ export function useCreatePullRequestBaseRefDiscovery({
return () => {
stale = true
}
}, [open, repoDefaultBaseRef, repoHostId, repoId])
}, [open, repoDefaultBaseRef, repoHostId, repoId, viewActive])
useEffect(() => {
if (!open || base || !repoDefaultBaseRef) {
if (!viewActive || !open || base || !repoDefaultBaseRef) {
return
}
setBase(repoDefaultBaseRef)
}, [base, open, repoDefaultBaseRef, setBase])
}, [base, open, repoDefaultBaseRef, setBase, viewActive])
useEffect(() => {
if (!open || !repoHostId || baseQuery.trim().length < 2) {
if (!viewActive || !open || !repoHostId || baseQuery.trim().length < 2) {
setBaseResults([])
setBaseSearchPending(false)
setBaseSearchError(null)
@@ -108,6 +114,7 @@ export function useCreatePullRequestBaseRefDiscovery({
}
}, [
baseQuery,
viewActive,
open,
repoHostId,
repoId,
@@ -3,7 +3,7 @@ import { translate } from '@/i18n/i18n'
import { useCallback, useEffect, type Dispatch, type RefObject, type SetStateAction } from 'react'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { getConnectionId } from '@/lib/connection-context'
import { useAppStore, type AppState } from '@/store/index'
import { useAppStore } from '@/store/index'
import {
cancelRuntimeGeneratePullRequestFields,
generateRuntimePullRequestFields
@@ -32,7 +32,6 @@ type CreatePullRequestFieldGenerationOptions = {
draft: boolean
worktreeId: string | null
worktreePath: string
settings: AppState['settings']
target: RuntimeClientTarget | null
eligibility: HostedReviewCreationEligibility | null
resolvedPrDefaults: Required<SourceControlAiPrCreationDefaults>
@@ -76,7 +75,6 @@ export function useCreatePullRequestFieldGeneration({
draft,
worktreeId,
worktreePath,
settings,
target,
eligibility,
resolvedPrDefaults,
@@ -149,7 +147,7 @@ export function useCreatePullRequestFieldGeneration({
const requestContext = {
// Pin generation to the worktree owner so focused-host navigation cannot retarget it.
target,
prefs: settings,
prefs: useAppStore.getState().settings,
worktreeId,
worktreePath,
connectionId
@@ -233,7 +231,6 @@ export function useCreatePullRequestFieldGeneration({
resolvedPrDefaults.useTemplate,
setGenerateError,
setGenerating,
settings,
target,
title,
worktreeId,
@@ -67,8 +67,7 @@ function useComposer(key: string) {
fieldsAreSeedPlaceholders: fields.fieldsAreSeedPlaceholders,
generatePullRequestFields: fields.handleGenerate,
generationKey: key,
repo: null,
settings: null
repo: null
})
return fields
}
@@ -1,5 +1,5 @@
import { useCallback, useLayoutEffect } from 'react'
import { useAppStore, type AppState } from '@/store/index'
import { useAppStore } from '@/store/index'
import {
recordPullRequestGenerationEdits,
type PullRequestFieldRevisions,
@@ -23,8 +23,7 @@ export function useGenerateBeforeCreatePullRequest({
fieldsAreSeedPlaceholders,
generatePullRequestFields,
generationKey,
repo,
settings
repo
}: {
aiGenerationEnabled: boolean
canCreate: boolean
@@ -39,7 +38,6 @@ export function useGenerateBeforeCreatePullRequest({
) => Promise<PullRequestGenerationOutcome | void>
generationKey: string | null
repo: Pick<Repo, 'sourceControlAi'> | null
settings: AppState['settings']
}) {
useLayoutEffect(() => {
if (generationKey && fieldsInitialized) {
@@ -70,7 +68,7 @@ export function useGenerateBeforeCreatePullRequest({
status === 'canceled' ||
!hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings,
settings: useAppStore.getState().settings,
repo
})
) {
@@ -94,8 +92,7 @@ export function useGenerateBeforeCreatePullRequest({
fieldsAreSeedPlaceholders,
generatePullRequestFields,
generationKey,
repo,
settings
repo
]
)
@@ -232,7 +232,6 @@ export function useCreatePullRequestDialogFields({
draft,
worktreeId,
worktreePath,
settings,
target,
eligibility,
resolvedPrDefaults,
@@ -1,3 +1,7 @@
import {
selectSidebarLineageParentIdentities,
selectCrossStoreFolderChildrenById
} from '@/store/cross-store-lineage-parents'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import { useAppStore } from '@/store/index'
@@ -15,7 +19,6 @@ import {
} from './parent-pr-checks-refresh'
import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector'
import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors'
import { selectCrossStoreFolderChildrenById } from '@/store/cross-store-lineage-parents'
type FolderWorkspacePrChecksPanelProps = {
isVisible?: boolean
@@ -29,6 +32,7 @@ export default function FolderWorkspacePrChecksPanel({
const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s))
const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey)
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
const lineageParentIdentities = useAppStore(selectSidebarLineageParentIdentities)
const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s))
const crossStoreChildrenByFolderId = useAppStore(selectCrossStoreFolderChildrenById)
const repos = useAppStore((s) => s.repos)
@@ -50,6 +54,7 @@ export default function FolderWorkspacePrChecksPanel({
activeWorktreeId,
folderWorkspaces,
workspaceLineageByChildKey,
lineageParentIdentities,
worktreeLineageById,
worktreesByRepo,
crossStoreChildrenByFolderId
@@ -59,6 +64,7 @@ export default function FolderWorkspacePrChecksPanel({
activeWorktreeId,
folderWorkspaces,
workspaceLineageByChildKey,
lineageParentIdentities,
worktreeLineageById,
worktreesByRepo,
crossStoreChildrenByFolderId
@@ -13,7 +13,7 @@ export function getVisibleRightSidebarActivityItems(
): ActivityBarItem[] {
return items.filter(
(item) =>
(!item.gitOnly || !isFolder) &&
(!item.gitOnly || !isFolder || (isFolderWorkspace && item.id === 'source-control')) &&
(!item.folderOnly || isFolderWorkspace) &&
(!item.sshOnly || hasSshHost)
)
@@ -1,9 +1,11 @@
import { useAppStore } from '@/store'
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import type { ActiveRightSidebarTab } from '@/store/slices/editor'
import { isPluginPanelTabKey } from '../../../../shared/plugins/manifest/plugin-manifest'
const FileExplorer = lazy(() => import('./file-explorer/panel/FileExplorer'))
const FolderWorkspaceSourceControl = lazy(() => import('./FolderWorkspaceSourceControlPanel'))
const SourceControl = lazy(() => import('./source-control/panel/SourceControl'))
const ChecksPanel = lazy(() => import('./checks-panel/ChecksPanel'))
const PortsPanel = lazy(() => import('./PortsPanel'))
@@ -23,6 +25,9 @@ export function RightSidebarPanelContent({
effectiveTab,
rightSidebarOpen
}: RightSidebarPanelContentProps): React.JSX.Element {
const isFolderWorkspace = useAppStore(
(state) => state.activeWorkspaceKey?.startsWith('folder:') ?? false
)
return (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
@@ -31,7 +36,12 @@ export function RightSidebarPanelContent({
>
<Suspense fallback={null}>
{effectiveTab === 'explorer' && <FileExplorer />}
{effectiveTab === 'source-control' && <SourceControl />}
{effectiveTab === 'source-control' &&
(isFolderWorkspace ? (
rightSidebarOpen && <FolderWorkspaceSourceControl />
) : (
<SourceControl />
))}
{effectiveTab === 'checks' && <ChecksPanel />}
{/* SSH forwarding needs raw ports.detect data absent from the workspace-scoped status popover. */}
{effectiveTab === 'ports' && (
@@ -215,7 +215,7 @@ describe('rendered right sidebar titlebar drag regions', () => {
mockAppState.cachedWorktree = null
})
it('hides git-only activity buttons for folder workspace ids without a backing repo', () => {
it('offers Source Control but hides repository-only activity buttons in a folder workspace', () => {
mockAppState.activeWorktreeId = 'folder:folder-1'
mockAppState.activeRepo = null
@@ -226,7 +226,7 @@ describe('rendered right sidebar titlebar drag regions', () => {
expect(markup).not.toContain('aria-label="Search')
expect(markup).toContain('aria-label="Attached worktrees')
expect(markup).toContain('aria-label="PR Checks')
expect(markup).not.toContain('aria-label="Source Control')
expect(markup).toContain('aria-label="Source Control')
expect(markup).not.toContain('aria-label="Checks')
})
@@ -33,6 +33,8 @@ export type SourceControlAgentActionDialogProps = {
savedAgentArgs?: string | null
worktreeId?: string | null
groupId?: string | null
placementWorkspaceId?: string
initialCwd?: string
connectionId?: string | null
repoId?: string | null
promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready'
@@ -51,7 +53,7 @@ export type SourceControlAgentActionDialogProps = {
onLaunchAccepted?: () => void
/** Fires when an accepted launch later failed to deliver its prompt. */
onLaunchAborted?: () => void
onLaunched?: () => void
onLaunched?: RunSourceControlAgentActionStartArgs['onLaunched']
startLabel?: string
onStart?: RunSourceControlAgentActionStartArgs['onStart']
}
@@ -4,15 +4,15 @@ import type {
GitConflictOperation,
GitStatusEntry
} from '../../../../../../shared/git/status/git-status-types'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { SourceControlRenderSettings } from '../listing/use-worktree-context'
import type { Repo } from '../../../../../../shared/repo-types'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
export type SourceControlAiStoreSnapshot = Pick<AppState, 'settings' | 'repos'> &
WorktreeAgentInventoryState
Omit<WorktreeAgentInventoryState, 'repos'>
export type SourceControlAiControllerParams = {
settings: GlobalSettings | null
settings: SourceControlRenderSettings | null
/** The repo owner's transport; it scopes model discovery. */
target: RuntimeClientTarget | null
activeRepo: Repo | null
@@ -1,3 +1,5 @@
import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { AppState } from '@/store'
import { isCustomAgentId } from '../../../../../../shared/source-control/ai/generation/commit-message-agent-spec'
import type { ResolvedSourceControlAiGenerationParams } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution'
@@ -15,6 +17,7 @@ import { generationParamsToActionRecipe } from './text-generation-defaults'
type SourceControlAiRecipePersistenceStoreSnapshot = Pick<AppState, 'settings' | 'repos'>
export async function saveSourceControlAiActionRecipeForTarget({
hostId,
getStoreState,
updateSettings,
updateRepo,
@@ -23,6 +26,7 @@ export async function saveSourceControlAiActionRecipeForTarget({
recipe,
customAgentCommand
}: {
hostId?: ExecutionHostId
getStoreState: () => SourceControlAiRecipePersistenceStoreSnapshot
updateSettings: AppState['updateSettings']
updateRepo: AppState['updateRepo']
@@ -38,7 +42,11 @@ export async function saveSourceControlAiActionRecipeForTarget({
}
const latestRepo =
target.type === 'repo'
? (state.repos.find((candidate) => candidate.id === target.repoId) ?? null)
? (state.repos.find((candidate) =>
hostId
? repoMatchesHostIdentity(candidate, target.repoId, hostId)
: candidate.id === target.repoId
) ?? null)
: null
const result = saveSourceControlActionRecipe({
target,
@@ -1,3 +1,8 @@
import {
canLaunchSourceControlSectionAgentInPlace,
showSourceControlAgentStartedIn
} from './section-agent-placement'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import { ensureAgentLaunchPreferences } from '@/runtime/agent-launch-settings/use-host-agent-launch-preferences'
import {
@@ -89,6 +94,9 @@ export function getDefaultSourceControlRecoveryLaunchCopy(
export async function launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId,
placementWorkspaceId,
initialCwd,
executionHostId,
activeGroupId,
activeSourceControlLaunchPlatform,
sourceRepoConnectionId,
@@ -100,6 +108,9 @@ export async function launchSourceControlRecoveryAgentWithDefault({
copy
}: {
activeWorktreeId: string
placementWorkspaceId?: string
initialCwd?: string
executionHostId?: ExecutionHostId
activeGroupId: string | null | undefined
activeSourceControlLaunchPlatform: NodeJS.Platform | undefined
sourceRepoConnectionId?: string | null
@@ -157,8 +168,16 @@ export async function launchSourceControlRecoveryAgentWithDefault({
return false
}
const liveStore = useAppStore.getState()
const inPlace =
placementWorkspaceId &&
canLaunchSourceControlSectionAgentInPlace(liveStore, activeWorktreeId, placementWorkspaceId)
? placementWorkspaceId
: undefined
// A saved or default agent may open as a chat without its CLI.
const opensAsChat = (agent: TuiAgent): boolean =>
!placementWorkspaceId &&
agentChoiceMayOpenAsChat(
useAppStore.getState(),
agent,
@@ -196,11 +215,22 @@ export async function launchSourceControlRecoveryAgentWithDefault({
)
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
...(placementWorkspaceId && !inPlace ? { activate: false } : {}),
agent,
worktreeId: activeWorktreeId,
groupId: activeGroupId ?? activeWorktreeId,
worktreeId: inPlace ?? activeWorktreeId,
...(inPlace && inPlace !== activeWorktreeId ? { trustWorktreeId: activeWorktreeId } : {}),
groupId:
placementWorkspaceId && !inPlace
? (liveStore.activeGroupIdByWorktree[activeWorktreeId] ?? activeWorktreeId)
: ((inPlace ? liveStore.activeGroupIdByWorktree[inPlace] : undefined) ??
activeGroupId ??
placementWorkspaceId ??
activeWorktreeId),
...((!placementWorkspaceId || inPlace) && initialCwd ? { initialCwd } : {}),
prompt: commandInput,
...projection,
...(placementWorkspaceId ? { requestedSurface: 'terminal' as const } : {}),
...(executionHostId ? { selectedExecutionHostId: executionHostId } : {}),
promptDelivery: 'submit-after-ready',
launchPlatform: activeSourceControlLaunchPlatform,
launchSource: 'source_control_recovery'
@@ -221,9 +251,13 @@ export async function launchSourceControlRecoveryAgentWithDefault({
return false
}
if (result.surface.kind === 'local-terminal') {
if (result.surface.kind === 'local-terminal' && (!placementWorkspaceId || inPlace)) {
focusTerminalTabSurface(result.surface.tabId)
}
toast.success(copy.success)
if (placementWorkspaceId && !inPlace) {
showSourceControlAgentStartedIn(activeWorktreeId, executionHostId)
} else {
toast.success(copy.success)
}
return true
}
@@ -1,7 +1,13 @@
import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import type { SourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
import type * as RuntimeGitStatusClientModule from '@/runtime/runtime-git-status-client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import {
makeFolderWorkspace,
makeWorktree,
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
const mocks = vi.hoisted(() => ({
focusTerminalTabSurface: vi.fn(),
@@ -11,6 +17,8 @@ const mocks = vi.hoisted(() => ({
onSaveAgentDefault: vi.fn(),
onStart: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
navigate: vi.fn(),
mutationInFlight: vi.fn()
}))
vi.mock('@/runtime/runtime-git-status-client', async (importOriginal) => ({
@@ -27,7 +35,20 @@ vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
toast: { error: mocks.toastError, success: mocks.toastSuccess }
}))
vi.mock('@/lib/workspace-navigation-toast-action', () => ({
workspaceNavigationToastAction: ({
workspaceId,
executionHostId
}: {
workspaceId: string
executionHostId?: string
}) => ({
label: 'Go to workspace',
onClick: () => mocks.navigate(workspaceId, executionHostId)
})
}))
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
@@ -44,7 +65,6 @@ function buildArgs(
saveTargetValue: 'none',
actionId: 'resolveComments',
repoId: null,
settings: null,
repo: null,
worktreeId: 'wt-1',
groupId: 'group-1',
@@ -66,12 +86,120 @@ function buildArgs(
}
}
const initialState = useAppStore.getState()
afterEach(() => useAppStore.setState(initialState, true))
describe('runSourceControlAgentActionStart', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({
repos: [{ ...TEST_REPO, connectionId: 'box' }],
worktreesByRepo: {
[TEST_REPO.id]: [makeWorktree({ id: 'child', repoId: TEST_REPO.id, hostId: 'ssh:box' })]
},
folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'ssh:box' })]
})
mocks.mutationInFlight.mockResolvedValue({ inFlight: false })
})
it.each(['resolveConflicts', 'fixCommitFailure', 'fixPushFailure'] as const)(
'keeps %s in the folder group with the child cwd and recovery scope',
async (actionId) => {
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'agent' }
})
const recoveryContext = {
target: { kind: 'local' as const },
worktreeId: 'child',
worktreePath: '/folder/repo',
connectionId: 'box'
}
await expect(
runSourceControlAgentActionStart(
buildArgs({
actionId,
worktreeId: 'child',
groupId: 'folder-group',
placementWorkspaceId: 'folder:feature',
initialCwd: '/folder/repo',
recoveryContext,
recipe: { agentId: 'codex', surface: 'chat', agentArgs: '--model gpt-5' },
launchChoice: {
agent: 'codex',
route: 'structured-native-chat',
requestedSurface: 'chat',
executionHostId: 'ssh:box'
}
})
)
).resolves.toBe(true)
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'folder:feature',
groupId: 'folder-group',
initialCwd: '/folder/repo',
trustWorktreeId: 'child',
selectedExecutionHostId: 'ssh:box',
requestedSurface: 'terminal',
agentArgs: '--model gpt-5'
})
)
expect(mocks.mutationInFlight).toHaveBeenCalledWith(recoveryContext)
}
)
it('keeps a cross-host section launch child-owned instead of executing its path in the folder', async () => {
useAppStore.setState({
folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'local' })],
activeGroupIdByWorktree: { child: 'child-group' },
activeWorktreeId: 'folder:feature',
activeWorkspaceKey: 'folder:feature',
activeWorkspaceExecutionHostId: 'local',
worktreesByRepo: {
...useAppStore.getState().worktreesByRepo,
local: [
makeWorktree({ id: 'local::/tmp/wt', repoId: 'local', path: '/tmp/wt', hostId: 'local' })
]
}
})
mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' } })
await expect(
runSourceControlAgentActionStart(
buildArgs({
worktreeId: 'child',
placementWorkspaceId: 'folder:feature',
groupId: 'folder-group',
initialCwd: '/tmp/wt',
recipe: { agentId: 'codex', surface: 'chat' },
launchChoice: {
agent: 'codex',
route: 'structured-native-chat',
requestedSurface: 'chat',
executionHostId: 'ssh:box'
}
})
)
).resolves.toBe(true)
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'child',
groupId: 'child-group',
activate: false,
requestedSurface: 'terminal',
selectedExecutionHostId: 'ssh:box'
})
)
expect(mocks.launchAgentInNewTab.mock.calls[0]?.[0]).not.toHaveProperty('initialCwd')
expect(mocks.launchAgentInNewTab.mock.calls[0]?.[0]).not.toHaveProperty('trustWorktreeId')
expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature')
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
expect(mocks.onLaunched).toHaveBeenCalledWith('child')
expect(mocks.navigate).not.toHaveBeenCalled()
mocks.toastSuccess.mock.calls[0]?.[1].action.onClick()
expect(mocks.navigate).toHaveBeenCalledWith('child', 'ssh:box')
})
it('notifies onLaunchAccepted as soon as the agent tab is created, before prompt delivery', async () => {
let resolveDelivery: (value: {
delivered: boolean
@@ -122,8 +250,8 @@ describe('runSourceControlAgentActionStart', () => {
surface: { kind: 'local-terminal', tabId: 'tab-1' },
promptDeliveryResult
})
useAppStore.setState({ settings: getDefaultSettings('/tmp') })
const args = buildArgs({
settings: getDefaultSettings('/tmp'),
saveTargetValue: 'global',
onSaveAgentDefault,
onLaunchAccepted,
@@ -1,3 +1,10 @@
import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity'
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
import { useAppStore } from '@/store'
import {
canLaunchSourceControlSectionAgentInPlace,
showSourceControlAgentStartedIn
} from './section-agent-placement'
import type { DirectWorkItemLaunchResult } from '@/lib/workspace/creation/work-item/direct-work-item-setup'
import {
sourceControlActionRecipeEntry,
@@ -8,7 +15,6 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab'
import { newAgentLaunchRequestId } from '@/lib/agent-session/launch/routing/agent-launch-request-id'
import type { RuntimeGitContext } from '@/runtime/runtime-git-client-context'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent'
import type { AgentLaunchChoice } from '@/lib/agent-menu-selection'
@@ -39,10 +45,11 @@ export type RunSourceControlAgentActionStartArgs = {
actionId: SourceControlLaunchActionId
repoId?: string | null
agentLaunchPreferences?: AgentLaunchPreferences
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
repo: Pick<Repo, 'id' | 'sourceControlAi' | 'connectionId' | 'executionHostId'> | null
worktreeId?: string | null
groupId?: string | null
placementWorkspaceId?: string
initialCwd?: string
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
launchSource: LaunchSource
@@ -74,7 +81,7 @@ export type RunSourceControlAgentActionStartArgs = {
onLaunchAccepted?: () => void
/** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */
onLaunchAborted?: () => void
onLaunched?: () => void
onLaunched?: (backgroundWorkspaceId?: string) => void
onClose: () => void
}
@@ -86,11 +93,12 @@ export async function runSourceControlAgentActionStart({
saveTargetValue,
actionId,
repoId,
settings,
agentLaunchPreferences,
repo,
repo: submittedRepo,
worktreeId,
groupId,
placementWorkspaceId,
initialCwd,
promptDelivery,
launchPlatform,
launchSource,
@@ -115,6 +123,13 @@ export async function runSourceControlAgentActionStart({
if (signal?.aborted) {
return false
}
const state = useAppStore.getState()
const settings = state.settings
const repo = submittedRepo
? (state.repos.find((candidate) =>
repoMatchesHostIdentity(candidate, submittedRepo.id, getRepoExecutionHostId(submittedRepo))
) ?? null)
: null
let launched = false
let launchFailureNotified = false
let launchAcceptedNotified = false
@@ -138,11 +153,18 @@ export async function runSourceControlAgentActionStart({
) ?? recipe
launchRecipe = sourceControlActionRecipeEntry(recipeToSave)
}
const store = useAppStore.getState()
const inPlace =
placementWorkspaceId &&
worktreeId &&
canLaunchSourceControlSectionAgentInPlace(store, worktreeId, placementWorkspaceId)
? placementWorkspaceId
: undefined
const projection = projectSelectedSourceControlActionLaunch(
launchRecipe,
selectedAgent,
{ renderedPrompt: trimmedCommandInput, settings },
launchChoice
placementWorkspaceId ? { ...launchChoice, requestedSurface: 'terminal' } : launchChoice
)
const {
commandInput: _prompt,
@@ -211,9 +233,18 @@ export async function runSourceControlAgentActionStart({
} else if (worktreeId) {
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
...(placementWorkspaceId && !inPlace ? { activate: false } : {}),
agent: selectedAgent,
worktreeId,
groupId: groupId ?? worktreeId,
worktreeId: inPlace ?? worktreeId,
...(inPlace && inPlace !== worktreeId ? { trustWorktreeId: worktreeId } : {}),
groupId:
placementWorkspaceId && !inPlace
? (store.activeGroupIdByWorktree[worktreeId] ?? worktreeId)
: ((inPlace ? store.activeGroupIdByWorktree[inPlace] : undefined) ??
groupId ??
placementWorkspaceId ??
worktreeId),
...((!placementWorkspaceId || inPlace) && initialCwd ? { initialCwd } : {}),
prompt: trimmedCommandInput,
agentArgs: launchAgentArgs,
...launchOptions,
@@ -225,7 +256,7 @@ export async function runSourceControlAgentActionStart({
launchSource
})
launched = Boolean(result)
if (result?.surface.kind === 'local-terminal') {
if (result?.surface.kind === 'local-terminal' && (!placementWorkspaceId || inPlace)) {
focusTerminalTabSurface(result.surface.tabId)
}
// Park launch state before slow submit-after-ready completes; host writes still wait for delivery.
@@ -258,7 +289,12 @@ export async function runSourceControlAgentActionStart({
return false
}
onLaunched?.()
const backgroundWorkspaceId =
placementWorkspaceId && !inPlace && worktreeId ? worktreeId : undefined
if (backgroundWorkspaceId) {
showSourceControlAgentStartedIn(backgroundWorkspaceId, selectedExecutionHostId)
}
onLaunched?.(backgroundWorkspaceId)
if (!signal?.aborted) {
onClose()
}
@@ -0,0 +1,40 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name'
import { workspaceNavigationToastAction } from '@/lib/workspace-navigation-toast-action'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { resolveOwner } from '@/lib/resolve-owner'
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
import { authorityKey } from '../../../../../../shared/host-authority'
/** Terminal content still belongs to its placement workspace; a cwd cannot move execution to another host. */
export function canLaunchSourceControlSectionAgentInPlace(
state: WorktreeOperationRouteState,
worktreeId: string,
placementWorkspaceId: string
): boolean {
const content = resolveOwner(state, { workspaceId: worktreeId })
const placement = resolveOwner(state, { workspaceId: placementWorkspaceId })
return (
content.kind === 'resolved' &&
placement.kind === 'resolved' &&
authorityKey(content.owner) === authorityKey(placement.owner)
)
}
export function showSourceControlAgentStartedIn(
worktreeId: string,
executionHostId?: ExecutionHostId
): void {
const worktree = useAppStore.getState().getKnownWorktreeById(worktreeId, executionHostId)
const name = worktree
? resolveWorktreeDisplayName(worktree)
: translate('auto.components.sidebar.WorktreeContextMenu.workspaceSection', 'Workspace')
toast.success(
translate('sourceControl.sectionAgentStarted', 'Started in {{value0}}', { value0: name }),
{
action: workspaceNavigationToastAction({ workspaceId: worktreeId, executionHostId })
}
)
}
@@ -1,3 +1,4 @@
import { useAppStore } from '@/store'
import React, { useCallback, useMemo, useState } from 'react'
import { RefreshCw, Save, Sparkles, Terminal, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -26,7 +27,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../../../../../../
import { formatLinkedIssueTemplateValue } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-action-variables'
import type { SourceControlTextActionId } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { SourceControlRenderSettings } from '../listing/use-worktree-context'
import type { Repo } from '../../../../../../shared/repo-types'
import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent'
import { toast } from 'sonner'
@@ -47,7 +48,7 @@ export type SourceControlTextGenerationSaveTarget = {
type SourceControlTextGenerationDialogFormProps = {
actionId: SourceControlTextActionId
generateLabel: string
settings: GlobalSettings | null
settings: SourceControlRenderSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
baseParams: ResolvedSourceControlAiGenerationParams | null
basePromptPreview?: string
@@ -129,14 +130,16 @@ export function SourceControlTextGenerationDialogForm({
return sourceControlTextGenerationSaveTargetKey(saveTarget.target) === saveTargetKey
}) ?? saveTargets[0]
const params = buildCommitMessageGenerationParams({
agentId,
commandTemplate,
agentArgs,
baseParams,
settings,
customAgentCommand: baseParams?.customAgentCommand
})
const buildParams = (settings: SourceControlRenderSettings | null) =>
buildCommitMessageGenerationParams({
agentId,
commandTemplate,
agentArgs,
baseParams,
settings,
customAgentCommand: baseParams?.customAgentCommand
})
const params = buildParams(settings)
// Only chips use workspace previews; synthetic planning keeps empty linkedIssue from blocking repo/global recipe saves.
const variablePreviews = useMemo(() => {
const previews: Record<string, string> = {}
@@ -227,7 +230,7 @@ export function SourceControlTextGenerationDialogForm({
)
return
}
onGenerate(params)
onGenerate(buildParams(useAppStore.getState().settings) ?? params)
onOpenChange(false)
}
@@ -13,7 +13,7 @@ import {
type ResolvedSourceControlAiGenerationParams
} from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution'
import type { SourceControlTextActionId } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { SourceControlRenderSettings } from '../listing/use-worktree-context'
import type { Repo } from '../../../../../../shared/repo-types'
import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save'
import { buildBranchNamePrompt } from '../../../../../../shared/source-control/ai/naming/branch-name-from-work'
@@ -31,7 +31,7 @@ import './text-generation-params'
type SourceControlTextGenerationBaseDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
settings: GlobalSettings | null
settings: SourceControlRenderSettings | null
repo?: Pick<Repo, 'id' | 'sourceControlAi'> | null
discoveryHostKey: string
/** Omitted by workspace-less callers (Settings dry-run); `null` means "linked to nothing". */
@@ -3,7 +3,11 @@ import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key'
import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
import {
makeFolderWorkspace,
makeWorktree,
TEST_REPO
} from '@/store/slices/worktrees-slice-test-fixtures'
import { clearHostAgentMenuSettingsForTests } from '@/runtime/agent-launch-settings/host-agent-launch-preferences-resource'
import {
launchSourceControlRecoveryAgentWithDefault,
@@ -35,11 +39,26 @@ vi.mock('@/runtime/runtime-git-status-client', async (importOriginal) => ({
getRuntimeGitMutationInFlight: mutationInFlight
}))
const navigate = vi.hoisted(() => vi.fn())
vi.mock('@/lib/workspace-navigation-toast-action', () => ({
workspaceNavigationToastAction: ({
workspaceId,
executionHostId
}: {
workspaceId: string
executionHostId?: string
}) => ({
label: 'Go to workspace',
onClick: () => navigate(workspaceId, executionHostId)
})
}))
const initialState = useAppStore.getState()
describe('AI operation recovery composer', () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear()
vi.mocked(toast.success).mockClear()
navigate.mockClear()
hostRpc.mockReset()
mutationInFlight.mockReset().mockResolvedValue({ inFlight: false })
detectAgents.mockReset().mockResolvedValue(['codex'])
@@ -47,8 +66,13 @@ describe('AI operation recovery composer', () => {
setLocalRuntimeCapabilitiesForTests([])
useAppStore.setState({
isRemoteOperationActive: false,
folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'local' })],
repos: [TEST_REPO],
worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] },
worktreesByRepo: {
[TEST_REPO.id]: [
makeWorktree({ id: 'repo1::/tmp/wt', repoId: TEST_REPO.id, hostId: 'local' })
]
},
ensureDetectedAgents: async () => {
const ids: TuiAgent[] = await detectAgents()
useAppStore.setState({ localDetectedAgentIdsByContext: { host: ids } })
@@ -71,6 +95,84 @@ describe('AI operation recovery composer', () => {
useAppStore.setState(initialState, true)
})
it('launches a section recovery in its folder with the child working directory', async () => {
useAppStore.setState({
settings: { ...getDefaultSettings(path.resolve('tmp')), defaultTuiAgent: 'codex' }
})
await expect(
launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId: 'repo1::/tmp/wt',
activeGroupId: 'folder-group',
activeSourceControlLaunchPlatform: 'linux',
sourceRepoConnectionId: null,
placementWorkspaceId: 'folder:feature',
initialCwd: '/folder/repo',
executionHostId: 'local',
actionId: 'fixCommitFailure',
basePrompt: 'fix this commit',
getLaunchActionRecipe: () =>
sourceControlActionRecipeEntry({ agentId: 'codex', surface: 'chat' }),
getStoreState: useAppStore.getState,
copy: getDefaultSourceControlRecoveryLaunchCopy('commit')
})
).resolves.toBe(true)
expect(launchAgent).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'folder:feature',
groupId: 'folder-group',
initialCwd: '/folder/repo',
trustWorktreeId: 'repo1::/tmp/wt',
selectedExecutionHostId: 'local',
requestedSurface: 'terminal'
})
)
expect(detectAgents).toHaveBeenCalledOnce()
})
it('uses the child workspace for cross-host recovery without applying its cwd to the folder', async () => {
useAppStore.setState({
settings: { ...getDefaultSettings(path.resolve('tmp')), defaultTuiAgent: 'codex' },
folderWorkspaces: [makeFolderWorkspace({ id: 'feature', executionHostId: 'ssh:other' })],
activeGroupIdByWorktree: { 'repo1::/tmp/wt': 'child-group' },
activeWorktreeId: 'folder:feature',
activeWorkspaceKey: 'folder:feature',
activeWorkspaceExecutionHostId: 'ssh:other'
})
await expect(
launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId: 'repo1::/tmp/wt',
activeGroupId: 'folder-group',
activeSourceControlLaunchPlatform: 'linux',
sourceRepoConnectionId: null,
placementWorkspaceId: 'folder:feature',
initialCwd: '/child',
executionHostId: 'local',
actionId: 'fixCommitFailure',
basePrompt: 'fix this commit',
getLaunchActionRecipe: () =>
sourceControlActionRecipeEntry({ agentId: 'codex', surface: 'chat' }),
getStoreState: useAppStore.getState,
copy: getDefaultSourceControlRecoveryLaunchCopy('commit')
})
).resolves.toBe(true)
expect(launchAgent).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: 'repo1::/tmp/wt',
groupId: 'child-group',
activate: false,
requestedSurface: 'terminal',
selectedExecutionHostId: 'local'
})
)
expect(launchAgent.mock.calls[0]?.[0]).not.toHaveProperty('initialCwd')
expect(launchAgent.mock.calls[0]?.[0]).not.toHaveProperty('trustWorktreeId')
expect(useAppStore.getState().activeWorktreeId).toBe('folder:feature')
expect(toast.success).toHaveBeenCalledOnce()
expect(navigate).not.toHaveBeenCalled()
const action = vi.mocked(toast.success).mock.calls[0]?.[1]?.action
expect(action).toEqual(expect.objectContaining({ onClick: expect.any(Function) }))
})
it('claims recovery success only after the terminal and task handoff settle', async () => {
vi.mocked(toast.success).mockClear()
const completed = Promise.withResolvers<{
@@ -89,7 +191,7 @@ describe('AI operation recovery composer', () => {
})
const run = () =>
launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId: 'wt-1',
activeWorktreeId: 'repo1::/tmp/wt',
activeGroupId: null,
activeSourceControlLaunchPlatform: 'linux',
sourceRepoConnectionId: null,
@@ -1,3 +1,6 @@
import { useSourceControlScope } from '../panel/worktree-scope'
import { repoMatchesHostIdentity } from '@/store/slices/repo-host-identity'
import { getRepoExecutionHostId } from '../../../../../../shared/execution-host'
import {
captureSourceControlActionSelection,
resolveSourceControlActionRecipe,
@@ -12,7 +15,6 @@ import { useAppStore } from '@/store'
import { getRuntimeGitScope } from '@/runtime/runtime-git-client-context'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../../../shared/source-control/ai/generation/commit-message-host-key'
import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS } from '../../../../../../shared/source-control/ai/settings/source-control-ai-settings'
import {
resolveSourceControlAiForOperation,
type ResolvedSourceControlAiGenerationParams
@@ -34,15 +36,6 @@ import { useConflictRecoveryComposer } from './use-conflict-recovery-composer'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export function getSourceControlAiControllerDiscoveryHostKey(
target: SourceControlAiControllerParams['target'],
activeConnectionId: string | null | undefined
): string {
return getCommitMessageModelDiscoveryHostKeyForScope(
getRuntimeGitScope(target ?? { kind: 'local' }, activeConnectionId)
)
}
export function useSourceControlAi({
settings,
target,
@@ -65,6 +58,8 @@ export function useSourceControlAi({
openSettingsPage,
getStoreState = useAppStore.getState
}: SourceControlAiControllerParams) {
const scope = useSourceControlScope()
const repoHostId = activeRepo ? getRepoExecutionHostId(activeRepo) : undefined
const savedStart = useSavedSourceControlActionStart()
const [resolveConflictsEditor, setResolveConflictsEditor] = useState<
Partial<SourceControlAgentActionDialogProps>
@@ -72,9 +67,8 @@ export function useSourceControlAi({
const [commitGenerationDialogOpen, setCommitGenerationDialogOpen] = useState(false)
const [pullRequestGenerationDialogOpen, setPullRequestGenerationDialogOpen] = useState(false)
const sourceControlAiDiscoveryHostKey = getSourceControlAiControllerDiscoveryHostKey(
target,
activeConnectionId
const sourceControlAiDiscoveryHostKey = getCommitMessageModelDiscoveryHostKeyForScope(
getRuntimeGitScope(target ?? { kind: 'local' }, activeConnectionId)
)
const sourceControlAiActionsVisible = useMemo(
() => (settings ? resolveSourceControlAiEnabled({ settings, repo: activeRepo }) : false),
@@ -92,31 +86,31 @@ export function useSourceControlAi({
: null,
[activeRepo, settings, sourceControlAiDiscoveryHostKey]
)
const resolvedPrCreationDefaults = useMemo(() => {
if (!settings) {
return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
}
const resolved = resolveSourceControlAiForOperation({
settings,
repo: activeRepo,
operation: 'pullRequest',
discoveryHostKey: sourceControlAiDiscoveryHostKey,
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
})
return resolved.ok
? resolved.value.prCreationDefaults
: resolveSourceControlAiPrCreationDefaults({
settings,
repo: activeRepo,
prCreationProductDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS
})
}, [activeRepo, settings, sourceControlAiDiscoveryHostKey])
const resolvedPrCreationDefaults = useMemo(
() =>
settings
? resolveSourceControlAiPrCreationDefaults({ settings, repo: activeRepo })
: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
[activeRepo, settings]
)
const getLaunchActionRecipe = useCallback(
(actionId: SourceControlLaunchActionId): SelectedSourceControlActionRecipe =>
resolveSourceControlActionRecipe({ settings, repo: activeRepo, actionId }),
[activeRepo, settings]
)
const getCurrentLaunchActionRecipe = useCallback(
(actionId: SourceControlLaunchActionId) => {
const state = getStoreState()
const repo = activeRepo
? (state.repos.find((candidate) =>
repoMatchesHostIdentity(candidate, activeRepo.id, getRepoExecutionHostId(activeRepo))
) ?? null)
: null
return resolveSourceControlActionRecipe({ settings: state.settings, repo, actionId })
},
[activeRepo, getStoreState]
)
const saveActionRecipeForTarget = useCallback(
(
target: SourceControlAiWriteTarget,
@@ -125,6 +119,7 @@ export function useSourceControlAi({
customAgentCommand?: string
): Promise<void> =>
saveSourceControlAiActionRecipeForTarget({
hostId: repoHostId,
getStoreState,
updateSettings,
updateRepo,
@@ -133,7 +128,7 @@ export function useSourceControlAi({
recipe,
customAgentCommand
}),
[getStoreState, updateRepo, updateSettings]
[getStoreState, updateRepo, updateSettings, repoHostId]
)
const saveLaunchActionDefault = useCallback(
(
@@ -142,6 +137,7 @@ export function useSourceControlAi({
recipe: SourceControlActionRecipe
): Promise<void> =>
saveSourceControlAiActionRecipeForTarget({
hostId: repoHostId,
getStoreState,
updateSettings: updateSettingsOrThrow,
updateRepo,
@@ -149,7 +145,7 @@ export function useSourceControlAi({
actionId,
recipe
}),
[getStoreState, updateRepo, updateSettingsOrThrow]
[getStoreState, updateRepo, updateSettingsOrThrow, repoHostId]
)
const openSourceControlAiSettings = useCallback((): void => {
@@ -177,14 +173,19 @@ export function useSourceControlAi({
onAdmitted: async (recoveryContext, signal) => {
const editor = {
initialSelection: captureSourceControlActionSelection({
settings,
settings: getStoreState().settings,
repo: activeRepo,
actionId: 'resolveConflicts'
}),
recoveryContext,
baseCommandInput: resolveConflictsPrompt,
worktreeId: activeWorktreeId,
groupId: activeGroupId,
placementWorkspaceId: scope?.placementWorkspaceId,
initialCwd: scope ? (worktreePath ?? undefined) : undefined,
groupId:
(scope
? useAppStore.getState().activeGroupIdByWorktree[scope.placementWorkspaceId]
: undefined) ?? activeGroupId,
connectionId: activeConnectionId,
repoId: activeRepo?.id,
launchPlatform: activeSourceControlLaunchPlatform
@@ -194,13 +195,13 @@ export function useSourceControlAi({
basePrompt: resolveConflictsPrompt,
...editor,
actionId: 'resolveConflicts',
settings,
repo: activeRepo,
promptDelivery: 'submit-after-ready',
launchSource: 'conflict_resolution',
signal,
onClose: () => {},
onLaunched: () =>
onLaunched: (backgroundWorkspaceId) =>
!backgroundWorkspaceId &&
toast.success(
translate(
'auto.components.right.sidebar.SourceControl.e48caaf0dd',
@@ -235,7 +236,7 @@ export function useSourceControlAi({
commitError,
pushRecoveryPrompt,
stagedEntries,
getLaunchActionRecipe,
getLaunchActionRecipe: getCurrentLaunchActionRecipe,
getStoreState
})
@@ -1,3 +1,4 @@
import { useSourceControlScope } from '../panel/worktree-scope'
import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import { useCallback, useMemo, useState } from 'react'
import type { SourceControlLaunchActionId } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
@@ -42,6 +43,7 @@ export function useSourceControlRecoveryAi({
getLaunchActionRecipe,
getStoreState
}: SourceControlRecoveryAiParams) {
const scope = useSourceControlScope()
const [isLaunchingCommitFailureAgent, setIsLaunchingCommitFailureAgent] = useState(false)
const [isLaunchingPushFailureAgent, setIsLaunchingPushFailureAgent] = useState(false)
@@ -68,6 +70,9 @@ export function useSourceControlRecoveryAi({
try {
return await launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId,
placementWorkspaceId: scope?.placementWorkspaceId,
initialCwd: scope ? (worktreePath ?? undefined) : undefined,
executionHostId: scope?.executionHostId,
activeGroupId,
activeSourceControlLaunchPlatform,
sourceRepoConnectionId,
@@ -83,6 +88,8 @@ export function useSourceControlRecoveryAi({
}
},
[
scope,
worktreePath,
activeGroupId,
activeWorktreeId,
activeSourceControlLaunchPlatform,
@@ -105,6 +112,9 @@ export function useSourceControlRecoveryAi({
try {
return await launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId,
placementWorkspaceId: scope?.placementWorkspaceId,
initialCwd: scope ? (worktreePath ?? undefined) : undefined,
executionHostId: scope?.executionHostId,
activeGroupId,
activeSourceControlLaunchPlatform,
sourceRepoConnectionId,
@@ -120,6 +130,8 @@ export function useSourceControlRecoveryAi({
}
},
[
scope,
worktreePath,
activeGroupId,
activeWorktreeId,
activeSourceControlLaunchPlatform,
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { getDefaultSettings } from '../../../../../../shared/constants'
import { canonicalSourceControlActionSelection } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
import { useSavedSourceControlActionStart } from './use-saved-source-control-action-start'
const { launch } = vi.hoisted(() => ({ launch: vi.fn() }))
vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({
launchAgentInNewTab: launch
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() }))
vi.mock('@/lib/host-chat-program-acquisition', () => ({
AgentLaunchChoiceCancelled: class extends Error {},
acquireAgentLaunchChoices: async () => ({ detected: ['claude'] })
}))
vi.mock('@/runtime/agent-launch-settings/use-host-agent-launch-preferences', () => ({
ensureAgentLaunchPreferences: async () => undefined
}))
const initial = useAppStore.getState()
afterEach(() => {
cleanup()
useAppStore.setState(initial, true)
})
it('uses the current Claude permission default when a saved action launches after render', async () => {
const settings = { ...getDefaultSettings('/tmp'), agentPermissionDefault: 'full-access' as const }
useAppStore.setState({
settings,
repos: [TEST_REPO],
worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'child', repoId: TEST_REPO.id })] }
})
launch.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 'agent' } })
const { result } = renderHook(() => {
const saved = useSavedSourceControlActionStart()
return () =>
saved.start({
selection: canonicalSourceControlActionSelection({
agentId: 'claude',
surface: 'terminal',
agentArgs: '--model opus'
}),
basePrompt: 'Fix the failed commit',
actionId: 'fixCommitFailure',
repo: TEST_REPO,
repoId: TEST_REPO.id,
worktreeId: 'child',
connectionId: null,
promptDelivery: 'auto-submit',
launchPlatform: 'linux',
launchSource: 'source_control_recovery',
onClose: () => {},
onFallback: () => {
throw new Error('Unexpected fallback')
}
})
})
useAppStore.setState({ settings: { ...settings, agentPermissionDefault: 'ask' } })
await act(async () => {
await result.current()
})
expect(launch).toHaveBeenCalledWith(
expect.objectContaining({ agentArgs: '--permission-mode default --model opus' })
)
})
@@ -63,10 +63,6 @@ export function useSavedSourceControlActionStart() {
onFallback()
return
}
const projection = projectSelectedSourceControlActionLaunch(recipe, agent, {
basePrompt: args.basePrompt,
settings: args.settings
})
const workspace = { worktreeId, kind: workspaceKindForWorktreeId(worktreeId) }
const [choices, agentLaunchPreferences] = await Promise.all([
acquireAgentLaunchChoices(
@@ -77,6 +73,11 @@ export function useSavedSourceControlActionStart() {
ensureAgentLaunchPreferences(store, workspace, signal)
])
signal?.throwIfAborted()
const settings = useAppStore.getState().settings
const projection = projectSelectedSourceControlActionLaunch(recipe, agent, {
basePrompt: args.basePrompt,
settings: settings
})
const route = isTuiAgent(agent)
? buildAgentLaunchRouteInput(store, {
agent,
@@ -103,7 +104,7 @@ export function useSavedSourceControlActionStart() {
projection.preferredSurface ? agent : undefined
)
const plan = buildSourceControlAgentDeliveryPlan({
settings: args.settings,
settings,
agentLaunchPreferences,
selectedAgent: agent,
commandInput: projection.commandInput,
@@ -1,8 +1,8 @@
import { useAppStateView } from '@/store/app-state-view'
import { buildAgentLaunchRouteInput } from '@/lib/agent-session/launch/routing/agent-launch-route-input'
import { workspaceKindForWorktreeId } from '../../../../../../shared/workspace-launch-kind'
import { AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-launch/contracts/agent-launch-runtime-capability'
import { useMemo } from 'react'
import { useAppStore } from '@/store/index'
import { isCustomAgentId } from '../../../../../../shared/source-control/ai/generation/commit-message-agent-spec'
import { pickSourceControlLaunchAgent } from '../../../../../../shared/source-control/ai/source-control-launch-agent-selection'
import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
@@ -39,7 +39,9 @@ export function useSourceControlActionAgentChoice(args: {
recipe: SelectedSourceControlActionRecipe
/** This open's deliberate pick; absent replays the saved recipe. */
livePick: AgentLaunchSurface | undefined
terminalOnly?: boolean
}) {
const store = useAppStateView()
const { recipe } = args
const worktreeDetectionTarget = useAgentDetectionTargetForWorktree(args.worktreeId ?? null)
const rows = useMemo(
@@ -55,8 +57,9 @@ export function useSourceControlActionAgentChoice(args: {
wakeOnOpen: true
})
const { detectedIds, detectionFailed, needsServerUpdate } = observation.detection
const detectedAgents =
observation.model.state === 'settled'
const detectedAgents = args.terminalOnly
? (detectedIds ?? NO_AGENTS)
: observation.model.state === 'settled'
? [
...new Set(
observation.model.rows
@@ -76,15 +79,18 @@ export function useSourceControlActionAgentChoice(args: {
detectedAgents: detectedIds ?? NO_AGENTS,
disabledAgents,
chosenOpensAsChat: (agent) =>
!args.terminalOnly &&
observation.model.state === 'settled' &&
observation.model.rows.some((row) => row.kind === 'chat' && row.agent === agent)
})
const savedSurface = recipe.agentId === selectedAgent ? recipe.surface : undefined
const liveSurface = args.livePick ?? savedSurface
const liveSurface = args.terminalOnly ? 'terminal' : (args.livePick ?? savedSurface)
const selection = observation.selectionFor(
selectedAgent,
args.livePick ?? (savedSurface === 'terminal' ? 'terminal' : undefined),
savedSurface === 'chat' ? 'chat' : undefined
args.terminalOnly
? 'terminal'
: (args.livePick ?? (savedSurface === 'terminal' ? 'terminal' : undefined)),
!args.terminalOnly && savedSurface === 'chat' ? 'chat' : undefined
)
const enabledDetectedAgents = detectedAgents.filter((agent) =>
isTuiAgentEnabled(agent, disabledAgents)
@@ -95,7 +101,7 @@ export function useSourceControlActionAgentChoice(args: {
selectedAgent &&
!isSourceControlAgentDetectedAndEnabled(selectedAgent, detectedAgents, disabledAgents)
)
const route = buildAgentLaunchRouteInput(useAppStore.getState(), {
const route = buildAgentLaunchRouteInput(store.getState(), {
agent: selectedAgent ?? 'claude',
workspace: args.worktreeId
? { worktreeId: args.worktreeId, kind: workspaceKindForWorktreeId(args.worktreeId) }
@@ -1,3 +1,4 @@
import { useSourceControlState } from '../panel/worktree-scope'
import { useCallback, useState } from 'react'
import { useAppStore } from '@/store/index'
import {
@@ -24,7 +25,7 @@ export function useSourceControlLaunchableAgents(args: {
const workspace = sourceControlLaunchWorkspace(target)
useChatProgramFacts(workspace, active && !connectionUnavailable)
// Source Control renders with the dialog closed; only an open dialog lists agents.
const detected = useAppStore((state) =>
const detected = useSourceControlState((state) =>
!active
? null
: worktreeId
@@ -1,3 +1,4 @@
import { useSourceControlState } from '../panel/worktree-scope'
import type {
AgentPickerDraft,
AgentPickerEntry
@@ -14,8 +15,7 @@ import {
import { captureSourceControlActionSelection } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy'
import { useCallback, useMemo, useState } from 'react'
import { resolveSourceControlLaunchAgentScope } from '../../../../../../shared/source-control/ai/source-control-launch-agent-selection'
import { useAppStore } from '@/store/index'
import { useRepoById } from '@/store/selectors'
import { selectRepoByIdForActiveWorkspace } from '@/store/selectors'
import { renderSourceControlActionCommandTemplate } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent'
import type { AgentLaunchSurface } from '@/lib/agent-session/launch/routing/agent-session-launch-plan'
@@ -41,6 +41,8 @@ export function useSourceControlAgentActionDialog({
baseCommandInput,
worktreeId,
groupId,
placementWorkspaceId,
initialCwd,
connectionId,
repoId,
promptDelivery = 'submit-after-ready',
@@ -53,8 +55,10 @@ export function useSourceControlAgentActionDialog({
onLaunched,
onStart
}: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult {
const settings = useAppStore((state) => state.settings)
const repo = useRepoById(repoId ?? null)
const settings = useSourceControlState((state) => state.settings)
const repo = useSourceControlState((state) =>
selectRepoByIdForActiveWorkspace(state, repoId ?? null)
)
const [currentSelection, setCurrentSelection] = useState<CurrentActionSelection>(
() =>
initialSelection ??
@@ -83,7 +87,8 @@ export function useSourceControlAgentActionDialog({
repoId,
repo,
recipe,
livePick
livePick,
terminalOnly: Boolean(placementWorkspaceId)
})
const launchAgentScope = useMemo(
() =>
@@ -130,11 +135,12 @@ export function useSourceControlAgentActionDialog({
saveTargetValue,
actionId,
repoId,
settings,
agentLaunchPreferences: choice.observation.preferences,
repo,
worktreeId,
groupId,
placementWorkspaceId,
initialCwd,
promptDelivery,
launchPlatform,
// Why: an SSH host runs the plain `orca` shim; keep the previewed command
@@ -1,3 +1,4 @@
import { useAppStore } from '@/store'
import { projectSelectedSourceControlActionLaunch } from '../../../../../../shared/source-control/ai/recipes/source-control-action-launch-preferences'
import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import type { AgentLaunchPreferences } from '@/runtime/agent-launch-settings/host-agent-launch-preferences-resource'
@@ -9,7 +10,6 @@ import type {
SourceControlActionRecipe
} from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent'
import type { AgentLaunchChoice } from '@/lib/agent-menu-selection'
@@ -36,10 +36,11 @@ type UseSourceControlAgentActionStartArgs = {
actionId: SourceControlLaunchActionId
repoId?: string | null
agentLaunchPreferences: AgentLaunchPreferences
settings: GlobalSettings | null
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
worktreeId?: string | null
groupId?: string | null
placementWorkspaceId?: string
initialCwd?: string
promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready'
launchPlatform?: NodeJS.Platform
/** SSH uses plain orca; omit the local Linux orca-ide rename in its preview. */
@@ -59,7 +60,7 @@ type UseSourceControlAgentActionStartArgs = {
) => void | Promise<void>
onLaunchAccepted?: () => void
onLaunchAborted?: () => void
onLaunched?: () => void
onLaunched?: RunSourceControlAgentActionStartArgs['onLaunched']
onClose: () => void
}
@@ -92,11 +93,12 @@ export function useSourceControlAgentActionStart({
saveTargetValue,
actionId,
repoId,
settings,
agentLaunchPreferences,
repo,
worktreeId,
groupId,
placementWorkspaceId,
initialCwd,
promptDelivery,
launchPlatform,
isRemote,
@@ -138,7 +140,7 @@ export function useSourceControlAgentActionStart({
submitted: SelectedSourceControlActionRecipe
): SourceControlAgentActionDeliveryPlanState =>
buildSourceControlAgentDeliveryPlan({
settings,
settings: useAppStore.getState().settings,
agentLaunchPreferences,
selectedAgent,
commandInput,
@@ -147,7 +149,7 @@ export function useSourceControlAgentActionStart({
agentArgsApply && selectedAgent
? projectSelectedSourceControlActionLaunch(submitted, selectedAgent, {
renderedPrompt: commandInput,
settings
settings: useAppStore.getState().settings
}).agentArgs
: undefined,
detectedAgents: currentDetectedAgents,
@@ -156,7 +158,6 @@ export function useSourceControlAgentActionStart({
isRemote
}),
[
settings,
agentLaunchPreferences,
agentArgsApply,
commandInput,
@@ -197,11 +198,12 @@ export function useSourceControlAgentActionStart({
saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none',
actionId,
repoId,
settings,
agentLaunchPreferences,
repo,
worktreeId,
groupId,
placementWorkspaceId,
initialCwd,
promptDelivery,
launchPlatform,
launchSource,
@@ -241,6 +243,8 @@ export function useSourceControlAgentActionStart({
prepareRecipe,
connectionUnavailable,
groupId,
placementWorkspaceId,
initialCwd,
launchSource,
recoveryContext,
launchPlatform,
@@ -257,7 +261,6 @@ export function useSourceControlAgentActionStart({
repoId,
saveLaunchRecipe,
saveTargetValue,
settings,
selectedAgent,
trimmedCommandInput,
worktreeId
@@ -1,22 +1,5 @@
import type { SourceControlViewMode } from '../../../../../../shared/ui-chrome-types'
export type CommitDraftsByWorktree = Record<string, string>
export function normalizeSourceControlViewMode(value: unknown): SourceControlViewMode {
return value === 'tree' || value === 'list' ? value : 'list'
}
export function readCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string | null | undefined
): string {
return drafts[worktreeId ?? ''] ?? ''
}
export function writeCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string,
value: string
): CommitDraftsByWorktree {
return { ...drafts, [worktreeId]: value }
}
@@ -1,6 +1,10 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
import { SourceControlScopeProvider } from '../panel/worktree-scope'
import type { SourceControlToastTestOptions } from './source-control-toast-test-options'
const { toastError, toastDismiss } = vi.hoisted(() => ({
@@ -83,4 +87,29 @@ describe('showSourceControlEntryFailureToast', () => {
dismissSourceControlEntryFailureToast('wt-2')
expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation')
})
it('keeps Retry while a folder section shows the worktree under the active folder workspace', () => {
storeState.activeWorktreeId = 'folder:feature'
const owner = {
worktreeId: 'wt-1',
repoId: 'repo-1',
executionHostId: 'local' as const,
worktree: makeWorktree('wt-1', 'feature-a'),
attachedAt: 1,
repo: makeRepo(),
placementWorkspaceId: 'folder:feature'
}
const root = createRoot(document.createElement('div'))
act(() => root.render(createElement(SourceControlScopeProvider, { owner }, null)))
const onRetry = vi.fn()
show({ onRetry })
clickRetry()
expect(onRetry).toHaveBeenCalledTimes(1)
act(() => root.unmount())
clickRetry()
expect(onRetry).toHaveBeenCalledTimes(1)
expect(toastDismiss).toHaveBeenCalledWith('source-control-entry-mutation')
})
})
@@ -2,11 +2,20 @@ import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { readIpcErrorMessage } from '@/lib/ipc-error'
import { useAppStore } from '@/store'
import { isSourceControlScopeMounted } from '../panel/worktree-scope'
export type SourceControlEntryOperation = 'stage' | 'unstage' | 'discard'
const ENTRY_FAILURE_TOAST_ID = 'source-control-entry-mutation'
// A mounted folder section shows the worktree even when the folder workspace is the active one.
function entryRowsVisible(worktreeId: string | null): boolean {
return (
useAppStore.getState().activeWorktreeId === worktreeId ||
isSourceControlScopeMounted(worktreeId)
)
}
// Wrap nullable ownership to distinguish an empty slot from a null-owned one.
let entryFailureSlotOwner: { worktreeId: string | null } | null = null
@@ -75,7 +84,7 @@ export function showSourceControlEntryFailureToast({
}): void {
const isActiveWorktree = useAppStore.getState().activeWorktreeId === worktreeId
const title = entryFailureTitle(operation, filePath, deletesFile)
const offerRetry = Boolean(onRetry) && isActiveWorktree
const offerRetry = Boolean(onRetry) && entryRowsVisible(worktreeId)
entryFailureSlotOwner = { worktreeId }
toast.error(
isActiveWorktree || !worktreeName
@@ -97,7 +106,7 @@ export function showSourceControlEntryFailureToast({
onClick: (event) => {
// Own dismissal instead of Sonner's delayed ID removal, which could erase a fast retry failure in the same slot.
event.preventDefault()
if (useAppStore.getState().activeWorktreeId !== worktreeId) {
if (!entryRowsVisible(worktreeId)) {
dismissSourceControlEntryFailureToast(worktreeId)
return
}
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target'
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope'
import { translate } from '@/i18n/i18n'
import { readIpcErrorMessage } from '@/lib/ipc-error'
import {
@@ -56,6 +56,7 @@ export function useSourceControlBulkActions({
clearSelection: () => void
refreshActiveGitStatusAfterMutation: () => Promise<void>
}) {
const sourceControlScope = useSourceControlScope()
const [isExecutingBulk, setIsExecutingBulk] = useState(false)
// Reset during render so switches never paint the previous worktree's busy state.
const [bulkActionsWorktreeId, setBulkActionsWorktreeId] = useState(activeWorktreeId)
@@ -92,7 +93,7 @@ export function useSourceControlBulkActions({
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const connectionId = sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null)
await bulkStageRuntimeGitPaths(
{
// Route staging through workspace ownership rather than focused runtime.
@@ -117,7 +118,8 @@ export function useSourceControlBulkActions({
clearSelection,
activeWorktreeId,
isExecutingBulk,
refreshActiveGitStatusAfterMutation
refreshActiveGitStatusAfterMutation,
sourceControlScope
])
const handleBulkUnstage = useCallback(async () => {
@@ -126,7 +128,7 @@ export function useSourceControlBulkActions({
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const connectionId = sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null)
await bulkUnstageRuntimeGitPaths(
{
// Route unstaging through workspace ownership rather than focused runtime.
@@ -151,7 +153,8 @@ export function useSourceControlBulkActions({
clearSelection,
activeWorktreeId,
isExecutingBulk,
refreshActiveGitStatusAfterMutation
refreshActiveGitStatusAfterMutation,
sourceControlScope
])
// Scopes replace explicit paths; only the host can enumerate changes beyond the listing cap.
@@ -167,7 +170,7 @@ export function useSourceControlBulkActions({
target: requireGitOwnerTarget(activeRepoTarget),
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId ?? null) ?? undefined
connectionId: sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null)
}
await (scope
? stageRuntimeGitWorktreeScope(context, scope)
@@ -181,6 +184,7 @@ export function useSourceControlBulkActions({
}
},
[
sourceControlScope,
activeRepoTarget,
activeWorktreeId,
clearSelection,
@@ -209,7 +213,7 @@ export function useSourceControlBulkActions({
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const connectionId = sourceControlConnectionId(sourceControlScope, activeWorktreeId ?? null)
await bulkUnstageRuntimeGitPaths(
{
// Route unstaging through workspace ownership rather than focused runtime.
@@ -229,6 +233,7 @@ export function useSourceControlBulkActions({
}
},
[
sourceControlScope,
activeRepoTarget,
activeWorktreeId,
clearSelection,
@@ -2,14 +2,13 @@ import { useCallback } from 'react'
import { toast } from 'sonner'
import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target'
import { translate } from '@/i18n/i18n'
import { getConnectionId } from '@/lib/connection-context'
import { sourceControlConnectionId, useSourceControlScope } from '../panel/worktree-scope'
import { commitRuntimeGit, completeRuntimeGitMerge } from '@/runtime/runtime-git-sync-client'
import type { SourceControlOperationTarget } from '../listing/operation-target'
import type { SourceControlStoreActions } from '../listing/use-store-actions'
import type { SourceControlWorktreeContext } from '../listing/use-worktree-context'
import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state'
import type { SourceControlStatusRefresh } from '../sync/use-status-refresh'
import { writeCommitDraftForWorktree } from './commit-drafts'
/** Explicit targets keep Create PR commits on their starting worktree after navigation. */
export function useSourceControlCommitAction({
@@ -17,36 +16,33 @@ export function useSourceControlCommitAction({
activeWorktree,
activeWorktreeId,
beginGitBranchCompareRequest,
commitInFlightRef,
commitMessage,
compareBaseRef,
operationStore,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
setCommitErrorForWorktree,
setCommitInFlightByWorktree,
stagedCount,
unresolvedConflictCount,
updateCommitDrafts,
updateCommitDraft,
worktreePath
}: {
activeRepoTarget: SourceControlWorktreeContext['activeRepoTarget']
activeWorktree: SourceControlWorktreeContext['activeWorktree']
activeWorktreeId: string | null
beginGitBranchCompareRequest: SourceControlStoreActions['beginGitBranchCompareRequest']
commitInFlightRef: SourceControlWorktreeOperationState['commitInFlightRef']
commitMessage: string
compareBaseRef: string | null
operationStore: SourceControlWorktreeOperationState['operationStore']
refreshActiveGitStatusAfterMutation: SourceControlStatusRefresh['refreshActiveGitStatusAfterMutation']
refreshBranchCompareRef: React.RefObject<() => Promise<void>>
refreshGitHistoryRef: React.RefObject<() => Promise<void>>
setCommitErrorForWorktree: SourceControlWorktreeOperationState['setCommitErrorForWorktree']
setCommitInFlightByWorktree: SourceControlWorktreeOperationState['setCommitInFlightByWorktree']
stagedCount: number
unresolvedConflictCount: number
updateCommitDrafts: SourceControlWorktreeOperationState['updateCommitDrafts']
updateCommitDraft: SourceControlWorktreeOperationState['updateCommitDraft']
worktreePath: string | null
}) {
const scope = useSourceControlScope()
const handleCommit = useCallback(
async (
messageOverride?: string,
@@ -63,7 +59,7 @@ export function useSourceControlCommitAction({
target: activeRepoTarget,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
connectionId: sourceControlConnectionId(scope, activeWorktreeId),
pushTarget: activeWorktree?.pushTarget
}
: null)
@@ -79,13 +75,11 @@ export function useSourceControlCommitAction({
return false
}
if (commitInFlightRef.current[target.worktreeId]) {
// Admission reads the owner record synchronously so a double-click or remount cannot resubmit.
if (operationStore.getState().isCommitting) {
return false
}
commitInFlightRef.current[target.worktreeId] = true
setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: true }))
setCommitErrorForWorktree(target.worktreeId, null)
operationStore.setState({ isCommitting: true, commitError: null })
try {
const commitResult = await commitRuntimeGit(
{
@@ -98,19 +92,13 @@ export function useSourceControlCommitAction({
message
)
if (!commitResult.success) {
setCommitErrorForWorktree(target.worktreeId, commitResult.error ?? 'Commit failed')
operationStore.setState({ commitError: commitResult.error ?? 'Commit failed' })
return false
}
// Clear only an unchanged submitted draft so typing during commit cannot lose new edits.
updateCommitDrafts((prev) => {
const current = prev[target.worktreeId]
if (current !== undefined && current.trim() !== message) {
return prev
}
return writeCommitDraftForWorktree(prev, target.worktreeId, '')
})
setCommitErrorForWorktree(target.worktreeId, null)
updateCommitDraft((current) => (current.trim() !== message ? current : ''))
operationStore.setState({ commitError: null })
if (!options?.target) {
void refreshActiveGitStatusAfterMutation()
}
@@ -128,31 +116,28 @@ export function useSourceControlCommitAction({
}
return true
} catch (error) {
setCommitErrorForWorktree(
target.worktreeId,
error instanceof Error ? error.message : 'Commit failed'
)
operationStore.setState({
commitError: error instanceof Error ? error.message : 'Commit failed'
})
return false
} finally {
setCommitInFlightByWorktree((prev) => ({ ...prev, [target.worktreeId]: false }))
commitInFlightRef.current[target.worktreeId] = false
operationStore.setState({ isCommitting: false })
}
},
[
scope,
activeRepoTarget,
activeWorktree?.pushTarget,
activeWorktreeId,
beginGitBranchCompareRequest,
commitInFlightRef,
commitMessage,
compareBaseRef,
operationStore,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
setCommitErrorForWorktree,
setCommitInFlightByWorktree,
stagedCount,
updateCommitDrafts,
updateCommitDraft,
unresolvedConflictCount,
worktreePath
]
@@ -162,24 +147,22 @@ export function useSourceControlCommitAction({
if (!activeWorktreeId || !worktreePath || unresolvedConflictCount > 0) {
return
}
if (commitInFlightRef.current[activeWorktreeId]) {
if (operationStore.getState().isCommitting) {
return
}
commitInFlightRef.current[activeWorktreeId] = true
setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
operationStore.setState({ isCommitting: true })
let result: { success: boolean; error?: string }
try {
result = await completeRuntimeGitMerge({
target: requireGitOwnerTarget(activeRepoTarget),
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined
connectionId: sourceControlConnectionId(scope, activeWorktreeId)
})
} catch (error) {
result = { success: false, error: error instanceof Error ? error.message : undefined }
} finally {
setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
commitInFlightRef.current[activeWorktreeId] = false
operationStore.setState({ isCommitting: false })
}
void refreshActiveGitStatusAfterMutation()
if (!result.success) {
@@ -206,12 +189,12 @@ export function useSourceControlCommitAction({
activeRepoTarget,
activeWorktreeId,
beginGitBranchCompareRequest,
commitInFlightRef,
compareBaseRef,
operationStore,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
setCommitInFlightByWorktree,
scope,
unresolvedConflictCount,
worktreePath
])
@@ -15,43 +15,28 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
allocateCommitMessageGenerationRequestId,
beginGitBranchCompareRequest,
branchName,
commitInFlightRef,
commitMessage,
commitMessageGenerationRecords,
compareBaseRef,
conflictOperation,
effectiveBaseRef,
fastForwardBranch,
fetchBranch,
generateErrors,
generateInFlightByWorktree,
generateInFlightRef,
getCreatePrIntentOperationTarget,
grouped,
isAbortingOperation,
openCommitGenerationDialog,
operationStore,
pullBranch,
pushBranch,
rebaseFromBase,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
remoteActionErrorSequenceByWorktreeRef,
remoteStatus,
remoteStatusForActions,
resolvedCommitMessageAi,
setAbortOperationInFlightByWorktree,
setCommitErrorForWorktree,
setCommitInFlightByWorktree,
setCommitMessageGenerationRecord,
setGenerateErrors,
setGenerateInFlightByWorktree,
setRemoteActionErrors,
settings,
sourceControlAiActionsVisible,
syncBranch,
unresolvedConflicts,
updateCommitDrafts,
updateCommitDraft,
updateCommitMessageGenerationRecord,
worktreePath
} = foundation
@@ -61,17 +46,15 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
activeWorktree,
activeWorktreeId,
beginGitBranchCompareRequest,
commitInFlightRef,
commitMessage,
compareBaseRef,
operationStore,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
setCommitErrorForWorktree,
setCommitInFlightByWorktree,
stagedCount: grouped.staged.length,
unresolvedConflictCount: unresolvedConflicts.length,
updateCommitDrafts,
updateCommitDraft,
worktreePath
})
const commitMessageGeneration = useSourceControlCommitMessageGeneration({
@@ -79,18 +62,10 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
activeRepoTarget,
activeWorktreeId,
allocateCommitMessageGenerationRequestId,
commitMessageGenerationRecords,
generateErrors,
generateInFlightByWorktree,
generateInFlightRef,
openCommitGenerationDialog,
resolvedCommitMessageAi,
operationStore,
setCommitMessageGenerationRecord,
setGenerateErrors,
setGenerateInFlightByWorktree,
settings,
sourceControlAiActionsVisible,
updateCommitDrafts,
updateCommitDraft,
updateCommitMessageGenerationRecord,
worktreePath
})
@@ -108,12 +83,8 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
)
const createPrIntentCommitMessage = useSourceControlCreatePrIntentCommitMessage({
activeRepo,
generateInFlightRef,
getCreatePrIntentOperationTarget,
resolvedCommitMessageAi,
setGenerateErrors,
setGenerateInFlightByWorktree,
settings
operationStore
})
const remoteActionRunner = useSourceControlRemoteActionRunner({
activeRepoTarget,
@@ -125,16 +96,15 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
fetchBranch,
grouped,
handleCommit,
operationStore,
pullBranch,
pushBranch,
rebaseFromBase,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
remoteActionErrorSequenceByWorktreeRef,
remoteStatus,
remoteStatusForActions,
setRemoteActionErrors,
syncBranch,
worktreePath
})
@@ -142,12 +112,10 @@ export function useSourceControlCommitFlows(foundation: SourceControlPanelFounda
activeRepoTarget,
activeWorktreeId,
conflictOperation,
isAbortingOperation,
operationStore,
refreshActiveGitStatusAfterMutation,
refreshBranchCompareRef,
refreshGitHistoryRef,
setAbortOperationInFlightByWorktree,
setRemoteActionErrors,
worktreePath
})
@@ -4,7 +4,10 @@ import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { useAppStore } from '@/store'
import type { CommitDraftsByWorktree } from './commit-drafts'
import {
getWorktreeOperationStore,
writeWorktreeCommitDraft
} from '../panel/worktree-operation-store'
const host = vi.hoisted(() => {
const held: {
@@ -26,33 +29,21 @@ import { useSourceControlCommitMessageGeneration } from './use-commit-message-ge
const WORKTREE_ID = 'repo-1::/repo'
const initialAppState = useAppStore.getInitialState()
let drafts: CommitDraftsByWorktree = {}
const generateInFlightRef: { current: Record<string, boolean> } = { current: {} }
const operationStore = getWorktreeOperationStore(WORKTREE_ID, null, undefined)
let latest: ReturnType<typeof useSourceControlCommitMessageGeneration> | null = null
let root: Root | null = null
function Probe(): null {
const records = useAppStore((s) => s.commitMessageGenerationRecords)
const state = useAppStore.getState()
latest = useSourceControlCommitMessageGeneration({
activeRepo: null,
activeRepoTarget: { kind: 'local' },
activeWorktreeId: WORKTREE_ID,
allocateCommitMessageGenerationRequestId: state.allocateCommitMessageGenerationRequestId,
commitMessageGenerationRecords: records,
generateErrors: {},
generateInFlightByWorktree: {},
generateInFlightRef,
openCommitGenerationDialog: () => {},
resolvedCommitMessageAi: null,
operationStore,
setCommitMessageGenerationRecord: state.setCommitMessageGenerationRecord,
setGenerateErrors: () => {},
setGenerateInFlightByWorktree: () => {},
settings: null,
sourceControlAiActionsVisible: true,
updateCommitDrafts: (update) => {
drafts = typeof update === 'function' ? update(drafts) : update
},
updateCommitDraft: (update) => writeWorktreeCommitDraft(operationStore, update),
updateCommitMessageGenerationRecord: state.updateCommitMessageGenerationRecord,
worktreePath: '/repo'
})
@@ -75,8 +66,7 @@ async function startGenerating(): Promise<{ pending: Promise<void> | undefined }
beforeEach(() => {
useAppStore.setState({ recordFeatureInteraction: async () => {} })
drafts = {}
generateInFlightRef.current = {}
writeWorktreeCommitDraft(operationStore, '')
})
afterEach(() => {
@@ -97,6 +87,6 @@ describe('a commit message generation that was canceled', () => {
host.succeed?.('feat: generated for the old diff')
await pending
})
expect(drafts[WORKTREE_ID]).toBeUndefined()
expect(operationStore.getState().draft).toBe('')
})
})

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